Files
zopu-code/packages/agents/src/orb/orb-project-manager.ts
-Puter 50ccf4e418 feat(agents/orb): wire project-manager orchestration over merged Orb runtime
Add a thin orchestration layer that drives the existing Orb runtime to
progress issue-scoped work. OrbProjectManager creates/resumes Orb runs,
assembles context packs, projects OrbEvents into durable project events,
detects needs-input and work-complete conditions, forwards follow-up
messages, and runs the Git/Gitea lifecycle on completion.

Port interfaces (OrbAdapter, OrbRunPort, GitLifecyclePort) make the
orchestrator testable with fakes. The real adapters wrap OrbRuntime and
runPostRunGiteaLifecycle without reimplementing Docker, AgentOS, or
OpenCode.

Idempotency: duplicate starts reuse active runs, duplicate completion
returns cached results, cancel is idempotent. Never auto-merges.

88 tests pass (19 orchestration + 12 event-mapping + orb suite), 2 live
tests skipped; lint and type checks clean.
2026-07-25 01:53:16 +05:30

637 lines
19 KiB
TypeScript

/* eslint-disable max-classes-per-file -- domain errors are grouped by concern. */
import { Schema } from "effect";
import { buildContextPack } from "./context-pack";
import type { ContextPackInput } from "./context-pack";
import type { OrbEvent } from "./events";
import type {
GitLifecyclePort,
GitLifecycleResult,
OrbAdapter,
OrbCreatePortInput,
OrbRunPort,
ProjectArtifact,
RunStatus,
} from "./ports";
import { isWorkComplete, mapOrbEvent } from "./project-events";
import type { ProjectRunEvent } from "./project-events";
// ---------------------------------------------------------------------------
// Tagged errors — the failure-mapping surface
// ---------------------------------------------------------------------------
export const ProjectManagerErrorReason = Schema.Literals([
"RunNotFound",
"SessionNotReady",
"RunTerminal",
"DuplicateActiveRun",
"InfrastructureFailure",
"NeedsInput",
"GitRejection",
"PullRequestFailure",
"Cancelled",
"UnrecoverableFailure",
]);
export type ProjectManagerErrorReason = typeof ProjectManagerErrorReason.Type;
export class ProjectManagerError extends Schema.TaggedErrorClass<ProjectManagerError>()(
"ProjectManagerError",
{
issueId: Schema.String,
message: Schema.String,
reason: ProjectManagerErrorReason,
}
) {}
// ---------------------------------------------------------------------------
// Active run record — one per managed issue
// ---------------------------------------------------------------------------
interface ActiveRun {
readonly issueId: string;
readonly orbId: string;
readonly runId: string;
readonly orb: OrbRunPort;
sessionId: string | undefined;
status: RunStatus;
readonly projectEvents: ProjectRunEvent[];
result: GitLifecycleResult | undefined;
needsInputQuestion: string | undefined;
readonly contextPack: string;
lastTurnEventIndex: number;
readonly baseBranch: string;
readonly branchName: string;
readonly issueNumber: number;
readonly issueTitle: string;
readonly repositoryPath: string;
readonly workspacePath: string;
}
// ---------------------------------------------------------------------------
// Dependencies injected into the orchestrator
// ---------------------------------------------------------------------------
export interface OrbProjectManagerDeps {
readonly orbAdapter: OrbAdapter;
readonly createGitLifecycle: (orb: OrbRunPort) => GitLifecyclePort;
readonly onProjectEvent?: (event: ProjectRunEvent) => void;
readonly onArtifact?: (artifact: ProjectArtifact) => void;
}
// ---------------------------------------------------------------------------
// Input types for the orchestration API
// ---------------------------------------------------------------------------
export interface StartIssueInput {
readonly issueId: string;
readonly projectId: string;
readonly runId: string;
readonly context: OrbCreatePortInput["context"];
readonly gateway: OrbCreatePortInput["gateway"];
readonly docker: OrbCreatePortInput["docker"];
readonly baseBranch: string;
readonly branchName: string;
readonly contextPack: ContextPackInput;
readonly workspacePath?: string;
readonly repositoryPath?: string;
readonly issueNumber?: number;
readonly issueTitle?: string;
}
export interface StartIssueResult {
readonly issueId: string;
readonly orbId: string;
readonly runId: string;
readonly sessionId: string | undefined;
readonly status: RunStatus;
readonly needsInputQuestion?: string;
}
export interface SendMessageResult {
readonly issueId: string;
readonly status: RunStatus;
readonly needsInputQuestion?: string;
}
export interface CompleteInput {
readonly issueId: string;
readonly verification?: "passed" | "failed" | "not-run";
readonly commitMessage?: string;
}
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
const isTerminal = (status: RunStatus): boolean =>
status === "completed" || status === "failed" || status === "cancelled";
const isSessionValid = (run: ActiveRun): boolean =>
run.sessionId !== undefined && !isTerminal(run.status);
const timestamp = () => new Date().toISOString();
const wrapError = (
error: unknown,
issueId: string,
reason: ProjectManagerErrorReason,
fallback: string
): ProjectManagerError => {
const message = error instanceof Error ? error.message : String(error);
return new ProjectManagerError({
issueId,
message: message.length > 0 ? message : fallback,
reason,
});
};
const isGitRejection = (error: unknown): boolean => {
if (!(error instanceof Error)) {
return false;
}
const message = error.message.toLowerCase();
return (
message.includes("rejected") ||
message.includes("authentication") ||
message.includes("permission denied") ||
message.includes("remote")
);
};
// ---------------------------------------------------------------------------
// OrbProjectManager — thin orchestration agent over the merged Orb runtime
//
// Responsibilities:
// - Create or resume an Orb run per issue (idempotent)
// - Assemble a context pack and send it as the implementation objective
// - Project OrbEvents into durable ProjectRunEvents (no parallel event system)
// - Detect needs-input and work-complete conditions
// - Forward follow-up messages to the same OpenCode session
// - Drive the Git publish lifecycle on completion (never auto-merge)
// - Store branch/commit/diff/PR/summary as project artifacts
// ---------------------------------------------------------------------------
export class OrbProjectManager {
private readonly activeRuns = new Map<string, ActiveRun>();
private readonly deps: OrbProjectManagerDeps;
constructor(deps: OrbProjectManagerDeps) {
this.deps = deps;
}
// -----------------------------------------------------------------------
// Public state queries
// -----------------------------------------------------------------------
getRunStatus(issueId: string): RunStatus | undefined {
return this.activeRuns.get(issueId)?.status;
}
getRunEvents(issueId: string): readonly ProjectRunEvent[] {
return this.activeRuns.get(issueId)?.projectEvents ?? [];
}
getRunResult(issueId: string): GitLifecycleResult | undefined {
return this.activeRuns.get(issueId)?.result;
}
// -----------------------------------------------------------------------
// Start or resume an Orb run for an issue
// -----------------------------------------------------------------------
async startIssue(input: StartIssueInput): Promise<StartIssueResult> {
const existing = this.activeRuns.get(input.issueId);
// Idempotency: a still-active run is reused, never duplicated.
if (existing && !isTerminal(existing.status)) {
return {
issueId: input.issueId,
needsInputQuestion: existing.needsInputQuestion,
orbId: existing.orbId,
runId: existing.runId,
sessionId: existing.sessionId,
status: existing.status,
};
}
let orb: OrbRunPort;
try {
orb = await this.deps.orbAdapter.createOrb({
context: input.context,
docker: input.docker,
gateway: input.gateway,
identity: {
projectId: input.projectId,
runId: input.runId,
workUnitId: input.issueId,
},
});
} catch (error) {
throw wrapError(
error,
input.issueId,
"InfrastructureFailure",
"Failed to create Orb run"
);
}
const contextPack = buildContextPack(input.contextPack);
const run: ActiveRun = {
baseBranch: input.baseBranch,
branchName: input.branchName,
contextPack,
issueId: input.issueId,
issueNumber: input.issueNumber ?? 0,
issueTitle: input.issueTitle ?? input.issueId,
lastTurnEventIndex: 0,
needsInputQuestion: undefined,
orb,
orbId: orb.orbId,
projectEvents: [],
repositoryPath: input.repositoryPath ?? "",
result: undefined,
runId: orb.runId,
sessionId: undefined,
status: "starting",
workspacePath: input.workspacePath ?? "/mnt/sandbox/repository",
};
this.activeRuns.set(input.issueId, run);
// Subscribe to OrbEvents and project them into durable ProjectRunEvents.
orb.onEvent((orbEvent: OrbEvent) => {
this.processOrbEvent(orbEvent, run);
});
this.emitProjectEvent(run, "run.started", {
text: `Started Orb run for issue ${input.issueId}`,
});
try {
await orb.prepareRepository({
baseBranch: input.baseBranch,
branchName: input.branchName,
});
this.emitProjectEvent(run, "run.repository_prepared", {
text: `Repository prepared on branch ${input.branchName}`,
});
const sessionId = await orb.openSession();
run.sessionId = sessionId;
run.status = "working";
this.emitProjectEvent(run, "run.session_opened", {
text: `Session ${sessionId} opened`,
});
// Record turn boundary before sending the implementation objective.
run.lastTurnEventIndex = run.projectEvents.length;
// Send the implementation objective (the assembled context pack).
await orb.sendTask(contextPack);
// After the turn, check for needs-input or work-complete signals.
OrbProjectManager.evaluateTurnOutcome(run);
} catch (error) {
if (error instanceof ProjectManagerError) {
throw error;
}
throw wrapError(
error,
input.issueId,
"InfrastructureFailure",
"Orb run failed during startup"
);
}
return {
issueId: input.issueId,
needsInputQuestion: run.needsInputQuestion,
orbId: run.orbId,
runId: run.runId,
sessionId: run.sessionId,
status: run.status,
};
}
// -----------------------------------------------------------------------
// Forward a follow-up message to the same OpenCode session
// -----------------------------------------------------------------------
async sendMessage(
issueId: string,
message: string
): Promise<SendMessageResult> {
const run = this.activeRuns.get(issueId);
if (!run) {
throw new ProjectManagerError({
issueId,
message: `No active run for issue ${issueId}`,
reason: "RunNotFound",
});
}
if (isTerminal(run.status)) {
throw new ProjectManagerError({
issueId,
message: `Run for issue ${issueId} is in terminal state ${run.status}`,
reason: "RunTerminal",
});
}
if (!isSessionValid(run)) {
throw new ProjectManagerError({
issueId,
message: `Session is not open for issue ${issueId}`,
reason: "SessionNotReady",
});
}
// Clear any prior needs-input condition and record turn boundary.
run.needsInputQuestion = undefined;
run.status = "working";
run.lastTurnEventIndex = run.projectEvents.length;
try {
await run.orb.sendTask(message);
OrbProjectManager.evaluateTurnOutcome(run);
} catch (error) {
throw wrapError(
error,
issueId,
"InfrastructureFailure",
"Failed to forward message to OpenCode session"
);
}
return {
issueId,
needsInputQuestion: run.needsInputQuestion,
status: run.status,
};
}
// -----------------------------------------------------------------------
// Cancel — terminate OpenCode and sandbox, then dispose
// -----------------------------------------------------------------------
async cancel(issueId: string): Promise<void> {
const run = this.activeRuns.get(issueId);
if (!run) {
// Idempotent cancel: a non-existent run is already "cancelled".
return;
}
if (run.status === "cancelled") {
return;
}
try {
await run.orb.cancel();
} catch {
// best-effort: proceed to dispose even if cancel failed
}
try {
await run.orb.dispose();
} catch {
// best-effort cleanup
}
run.status = "cancelled";
this.emitProjectEvent(run, "run.cancelled", {
text: "Run cancelled by user",
});
}
// -----------------------------------------------------------------------
// Complete — run Git publish lifecycle, store artifacts, mark completed
// -----------------------------------------------------------------------
async complete(input: CompleteInput): Promise<GitLifecycleResult> {
const run = this.activeRuns.get(input.issueId);
if (!run) {
throw new ProjectManagerError({
issueId: input.issueId,
message: `No active run for issue ${input.issueId}`,
reason: "RunNotFound",
});
}
// Idempotency: a completed run with an existing result is returned as-is.
if (run.status === "completed" && run.result !== undefined) {
return run.result;
}
if (run.status === "cancelled") {
throw new ProjectManagerError({
issueId: input.issueId,
message: "Cannot complete a cancelled run",
reason: "Cancelled",
});
}
const git = this.deps.createGitLifecycle(run.orb);
const verification = input.verification ?? "passed";
run.status = "completing";
let gitResult: GitLifecycleResult;
try {
gitResult = await git.publish({
baseBranch: run.baseBranch,
branchName: run.branchName,
commitMessage: input.commitMessage,
issueNumber: run.issueNumber,
issueTitle: run.issueTitle,
repositoryPath: run.repositoryPath,
verification,
workspace: run.workspacePath,
});
} catch (error) {
run.status = "failed";
const reason = isGitRejection(error)
? "GitRejection"
: "PullRequestFailure";
this.emitProjectEvent(run, "run.failed", {
text: error instanceof Error ? error.message : String(error),
});
throw wrapError(
error,
input.issueId,
reason,
"Git publish lifecycle failed"
);
}
run.result = gitResult;
this.storeArtifacts(run, gitResult);
// Mark completed only when a PR exists or a verified no-change result.
if (
(gitResult.status === "pull_request_open" && gitResult.pullRequest) ||
gitResult.status === "no_changes"
) {
run.status = "completed";
this.emitProjectEvent(run, "run.completed", {
text: gitResult.pullRequest
? `PR #${gitResult.pullRequest.number} created: ${gitResult.pullRequest.url}`
: "No changes to publish",
});
} else {
run.status = "failed";
this.emitProjectEvent(run, "run.failed", {
text: `Git lifecycle stopped at ${gitResult.status} without a pull request`,
});
throw new ProjectManagerError({
issueId: input.issueId,
message: `Git lifecycle did not produce a pull request (status: ${gitResult.status})`,
reason: "PullRequestFailure",
});
}
return gitResult;
}
// -----------------------------------------------------------------------
// Dispose all runs (for graceful shutdown)
// -----------------------------------------------------------------------
async disposeAll(): Promise<void> {
const issues = [...this.activeRuns.keys()];
await Promise.allSettled(
issues.map(async (issueId) => {
const run = this.activeRuns.get(issueId);
if (run && !isTerminal(run.status)) {
try {
await run.orb.dispose();
} catch {
// best-effort
}
}
})
);
}
// -----------------------------------------------------------------------
// Internal: OrbEvent processing
// -----------------------------------------------------------------------
private processOrbEvent(orbEvent: OrbEvent, run: ActiveRun): void {
const projectEvent = mapOrbEvent(orbEvent, run.issueId, run.runId);
if (projectEvent !== undefined) {
run.projectEvents.push(projectEvent);
this.deps.onProjectEvent?.(projectEvent);
if (
projectEvent.type === "run.needs_input" &&
run.needsInputQuestion === undefined
) {
run.needsInputQuestion = projectEvent.text;
}
}
}
// -----------------------------------------------------------------------
// Internal: evaluate the outcome of a completed model turn
// -----------------------------------------------------------------------
private static evaluateTurnOutcome(run: ActiveRun): void {
// Only scan events from the current turn (after the last turn boundary).
const turnEvents = run.projectEvents.slice(run.lastTurnEventIndex);
// Check for needs-input: the mapOrbEvent step already extracted the marker
// into a run.needs_input event with the question text. A run.needs_input
// event IS the signal — no need to re-extract the marker from its text.
const needsInputEvent = [...turnEvents]
.toReversed()
.find((event) => event.type === "run.needs_input");
if (needsInputEvent !== undefined) {
run.status = "needs-input";
run.needsInputQuestion = needsInputEvent.text ?? "Agent requires input";
return;
}
// Check for work-complete marker in agent messages from this turn.
const turnMessages = turnEvents.filter(
(event) => event.type === "run.agent_message"
);
const hasWorkComplete = turnMessages.some(
(event) => event.text !== undefined && isWorkComplete(event.text)
);
if (hasWorkComplete && run.status === "working") {
run.status = "completing";
}
}
// -----------------------------------------------------------------------
// Internal: emit a synthetic project event (not derived from an OrbEvent)
// -----------------------------------------------------------------------
private emitProjectEvent(
run: ActiveRun,
type: ProjectRunEvent["type"],
fields: { text?: string; exitCode?: number; toolName?: string }
): void {
const event: ProjectRunEvent = {
exitCode: fields.exitCode,
issueId: run.issueId,
runId: run.runId,
sequence: run.projectEvents.length + 1,
text: fields.text,
timestamp: timestamp(),
toolName: fields.toolName,
type,
};
run.projectEvents.push(event);
this.deps.onProjectEvent?.(event);
}
// -----------------------------------------------------------------------
// Internal: store artifacts from the Git lifecycle result
// -----------------------------------------------------------------------
private storeArtifacts(run: ActiveRun, result: GitLifecycleResult): void {
const ts = timestamp();
const base = { issueId: run.issueId, runId: run.runId };
const emitArtifact = (
type: ProjectArtifact["type"],
path: string,
content: string
): void => {
const artifact: ProjectArtifact = {
...base,
content,
path,
timestamp: ts,
type,
};
this.deps.onArtifact?.(artifact);
};
emitArtifact("branch", "branch.txt", result.branch);
if (result.commitSha) {
emitArtifact("commit", "commit.txt", result.commitSha);
}
if (result.pullRequest) {
emitArtifact(
"pull_request",
"pull_request.json",
JSON.stringify(result.pullRequest, null, 2)
);
}
const lastMessage = [...run.projectEvents]
.toReversed()
.find(
(event) =>
event.type === "run.agent_message" || event.type === "run.needs_input"
);
if (lastMessage?.text) {
emitArtifact("agent_summary", "summary.md", lastMessage.text);
}
}
}