// backends/types.ts — the subagent-runtime seam contract. The queue tools and // the extension depend on THIS interface, never on a backend's internals, so // pi / opencode / claude implementations can be swapped without touching the // queue logic. // // One backend is active per framework instance. The pi backend (backends/pi.ts) // uses pi-subagents' in-process RPC spawn + file control channel; the opencode // backend (backends/opencode.ts) spawns detached `oc run` child processes. import type { CompletionEvent } from "../types.ts"; export interface SubagentBackend { /** Each backend builds its completion event from ITS run record (the raw * runtime payloads differ — pi's async-complete carries no per-child * agent/output; opencode's is built in the plugin). The shared machinery * (attribution, verdict routing) only depends on this normalized shape. */ buildCompletionEvent?(raw: unknown): CompletionEvent; /** Spawn a detached async run. Resolves the run id (known immediately; the * backend owns completion detection — process exit for opencode, the * async-complete bus event for pi). `cwd` targets the repo the worker * operates on (worktree isolation + the worker's shell starts there); * defaults to the session cwd when omitted. `model` (`provider/id[:thinking]`) * overrides the child's model for this launch; omitted = the child inherits * the session's model and the backend adds NOTHING to the launch. */ spawn(task: string, opts: { agent?: string; timeoutMs?: number; worktree?: boolean; cwd?: string; model?: string }): Promise; /** * Reconcile-only fleet view — the number of RUNS the backend currently * considers active. The engine's store/ledger decides occupancy; this is a * cross-check (and the authoritative count for opencode, where every run is * a real child process). * * RUNS, NOT ENTRIES (AUTOPILOT-95): `totalActive` counts logical runs — a * workflow wrapper and its nested child are ONE. `runs` mirrors it explicitly * (and `entries`, pi backend only, optional) is the raw fleet-ROW count * (wrapper + child = two), exposed so a reader can see the underlying entries * without mistaking one run for two. * * MODE SCOPING (AUTOPILOT-98): the count is scoped to THIS harness's mode — * a run from another mode sharing the async-run root never counts. `foreignRuns` * (pi backend only, optional) is how many in-flight runs were EXCLUDED because * they belong to a different profile, and `unattributedRuns` is how many * counted runs carried no mode marker (counted conservatively as ours). Both * are diagnostic — where a backend cannot attribute, they are absent. * * AUTOPILOT-117: `fleetActive` (pi backend only, optional) is the runtime's * raw RPC fleet count, surfaced for observability. The RPC cannot be * mode-attributed (its rows carry no session path), so it does NOT set the * authoritative count — the mode-scoped disk inventory does — but it is kept * as a FAIL-CLOSED FLOOR: it can only RAISE `totalActive`, never lower it * (under-count flips live workers; over-count is safe). `modeFilterDisabled` * is true when no mode identity was available, so every in-flight run * counted. `inventoryRootMatchesWriter` (pi backend only, optional) is false * when the scanned async-run root differs from where pi-subagents itself * writes — the degraded state where occupancy can read 0 while runs are live * (AUTOPILOT-117 fix 3). */ fleetStatus(): Promise<{ totalActive: number; runs?: number; entries?: number; foreignRuns?: number; unattributedRuns?: number; fleetActive?: number; modeFilterDisabled?: boolean; inventoryRootMatchesWriter?: boolean } | null>; /** Steer a running run. Backends that cannot deliver (headless) throw a * clear error — never claim delivery. */ steer(runId: string, message: string, mode?: "steer" | "follow_up", ackTimeoutMs?: number): Promise<{ id: string; ack: "delivered" | "queued" }>; /** Resolve the run's state dir (or null if the run is gone). */ asyncDirFor(runId: string): string | null; } /** The pi-subagents surface the pi backend needs (events bus for RPC). */ export interface PiLike { events: { on(channel: string, handler: (data: unknown) => void): () => void; emit(channel: string, data: unknown): void; }; } /** The opencode backend's options. `runsDir` is the state root where each * run's status.json + output live — injectable (tests use temp dirs). */ export interface OpenCodeBackendOptions { /** State root for run records (default ~/.local/state/orchestrator-opencode/runs). */ runsDir: string; /** The opencode launcher to exec (default "opencode"; local setups * override via AUTOPILOT_OPENCODE_BIN, e.g. the oc wrapper). Injectable. */ ocBin?: string; /** Best-effort window to capture the opencode session id from the output * stream (default 30s). Not required for correctness — the run id is our * own uuid. */ sessionIdTimeoutMs?: number; /** Fired when a run's process exits (completion signal for the extension). */ onComplete?: (runId: string, record: OpenCodeRunRecord) => void; now?: () => number; } export interface OpenCodeRunRecord { runId: string; agent: string; status: "running" | "completed" | "failed"; startedAt: number; finishedAt?: number; exitCode: number | null; pid: number; logPath: string; sessionId?: string; /** The TASK this run was spawned with (KEY: AUTOPILOT-104) — persisted so a * reviewer verdict can be attributed to its item by the run's mission when * the run id alone cannot (an autoReview task names its item, `KEY: `). */ task?: string; }