/* 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", { 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(); 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 { 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 { 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 { 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 { 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 { 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); } } }