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.
637 lines
19 KiB
TypeScript
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);
|
|
}
|
|
}
|
|
}
|