/** * Tier-1 pane-spawn seam — injectable herdr CLI boundary. * * Owns ALL herdr CLI access for the pane-spawn path: * worktree create → agent start (in the worktree workspace's root pane) * → report-agent → release-agent → pane close → worktree remove. * * Every method is behind an injectable `HerdrInvoker` interface so unit tests * mock the invoker and never touch a live herdr server. * * Run-level pane only (not per subagent). One real pi process per run. * * ## Herdr 0.8.0 contract (protocol 19) * * Herdr 0.8.0 split layout from agent lifecycle. `agent start` no longer creates * layout — it adopts an EXISTING pane that is sitting at an interactive shell * prompt, and it requires `--kind`: * * herdr agent start --kind --pane [--timeout MS] [-- ] * * `herdr worktree create` already returns a workspace whose ROOT PANE is an idle * shell at the worktree checkout, so the run pane is that root pane — no split is * needed in the common case. A split is only required when the manager is rooted * at a repo SUBDIRECTORY and the run must start below the worktree root. * * Response shapes are the socket-API envelope `{ id, result: { … } }`, NOT bare * payloads. See `herdr api schema --json`. */ import { spawn } from "node:child_process"; import { resolve } from "node:path"; import type { ConductorRunStatus, ConductorStatusName } from "./conductor-types.js"; // ── HerdrInvoker interface ────────────────────────────────────────────────── /** * Result of `herdr worktree create`. * * `workspaceId` and `rootPaneId` come from the same create response, so the * caller never has to correlate a second lookup. `workspaceId` is what * `worktree remove --workspace ` needs; `rootPaneId` is the idle shell pane * `agent start --pane` adopts. * * `sourceWorkspaceId` is set when herdr had to OPEN a workspace for the parent * repo in order to create the worktree. That workspace is not a linked worktree, * so `worktree remove` refuses it (`not_linked_worktree`) — cleanup must use * `workspace close`. Undefined when the parent repo already had a workspace. */ export interface HerdrWorktree { cwd: string; branch: string; /** Workspace holding the linked worktree checkout (`worktree remove --workspace`). */ workspaceId?: string; /** Idle shell pane at `cwd`, created by herdr with the workspace. */ rootPaneId?: string; /** Parent-repo workspace herdr auto-opened for this create, if any. */ sourceWorkspaceId?: string; } /** * Injectable boundary for every herdr CLI call in the pane-spawn path. * All methods return void/Promise and never throw into the runtime. */ export interface HerdrInvoker { /** * `herdr worktree create --branch --cwd ` → * `{cwd, branch, workspaceId, rootPaneId, sourceWorkspaceId}`. * * Worktree commands are JSON-only; `--json` was dropped from help in 0.8.0 and * is not passed. The response envelope is `{id, result: {worktree, workspace, * root_pane, tab}}`. */ worktreeCreate(opts: { cwd: string; branch: string }): Promise; /** * `herdr worktree remove --workspace ` — deletes a Herdr-managed worktree * through Herdr's own CLI so Herdr's internal workspace/group bookkeeping stays * consistent. 0.8.0 removed `--branch`/`--cwd`: the ONLY selector is the * workspace id. * * `workspaceId` is optional so callers holding only a legacy persisted record * (branch + repo root, no workspace id) still clean up: the invoker falls back * to `worktree list --cwd ` and matches on branch. */ worktreeRemove(opts: { workspaceId?: string; cwd?: string; branch?: string }): Promise; /** * `herdr workspace close ` — closes a workspace that is not a linked * worktree (`worktree remove` rejects those with `not_linked_worktree`). Used * to clean up the parent-repo workspace herdr auto-opens during * `worktree create`. */ workspaceClose(workspaceId: string): Promise; /** * `herdr pane split --pane --direction down --cwd --no-focus` → * new pane id. Layout only — 0.8.0's `agent start` never creates layout. */ paneSplit(opts: { pane: string; cwd: string; direction?: "right" | "down" }): Promise<{ paneId: string }>; /** * `herdr agent start --kind --pane [-- ]` * * Adopts an EXISTING idle shell pane. `pane` is normally the worktree * workspace's root pane from {@link HerdrWorktree.rootPaneId}; when the run * must start in a subdirectory of the worktree, the caller splits first and * passes the new pane. * * Returns `{paneId: ""}` on any failure so callers can fail closed. */ agentStart( opts: { name: string; kind: string; pane: string; timeoutMs?: number; }, argv: string[], ): Promise<{ paneId: string }>; /** * `herdr pane report-agent --source --agent --state [--message ] [--seq ]` * Reports the live state of the agent running in the spawned pane. * `--ttl-ms` is NOT supported by report-agent (only report-metadata); do not add it. */ reportAgent( pane: string, opts: { source: string; agent: string; state: "idle" | "working" | "blocked"; message?: string; customStatus?: string; seq?: string; }, ): void; /** * `herdr pane report-metadata --source --seq [--title ] [--ttl-ms ]` * Layers a one-line custom status on the same pane. */ reportMetadata( pane: string, opts: { source: string; seq: string; customStatus?: string; ttlMs?: number; }, ): void; /** `herdr pane release-agent --source --agent ` — marks the agent done. */ releaseAgent( pane: string, opts: { source: string; agent: string; }, ): void; /** `herdr pane close ` — closes the spawned pane. */ paneClose(pane: string): void; /** * `herdr notification show --sound <done|request>` — raises a desktop * toast/sound so a kept-open pane's terminal state (needs-finalize, * needs-human, completed, failed) is surfaced even when the operator is not * watching the pane. Fire-and-forget; never throws. */ notify(pane: string, opts: { title: string; sound: "done" | "request" }): void; } // ── Socket-API envelope helpers ───────────────────────────────────────────── /** * Shape of `herdr worktree create`'s `.result` payload (the fields we read). * * Verified against a live 0.8.0 server (protocol 19); see `herdr api schema * --json` → `WorktreeInfo` / `WorkspaceInfo`. Every field is optional here so a * schema addition or a terser response never throws — the caller degrades to an * empty `cwd` and fails closed with an actionable message. */ interface WorktreeCreateResult { worktree?: { path?: string; branch?: string | null; open_workspace_id?: string | null; }; workspace?: { workspace_id?: string; worktree?: { checkout_path?: string; repo_root?: string }; }; root_pane?: { pane_id?: string }; } /** Shape of `herdr worktree list`'s `.result` payload (the fields we read). */ interface WorktreeListResult { source?: { source_workspace_id?: string | null }; worktrees?: Array<{ branch?: string | null; path?: string; open_workspace_id?: string | null; is_linked_worktree?: boolean; }>; } /** * Run `herdr <args...>` and resolve its stdout. * * Unlike {@link createDefaultHerdrInvoker}'s fire-and-forget `runSync`, this * awaits completion because the caller needs the response body. Rejects on a * non-zero exit or a spawn error; every call site catches and degrades. */ function runJson(args: string[]): Promise<string> { return new Promise<string>((resolveOut, reject) => { const child = spawn("herdr", args, { stdio: ["ignore", "pipe", "pipe"] }); let out = ""; let err = ""; child.stdout?.on("data", (chunk: Buffer) => { out += chunk; }); child.stderr?.on("data", (chunk: Buffer) => { err += chunk; }); child.on("error", reject); child.on("close", (code) => { if (code === 0) resolveOut(out); // Socket errors are JSON on stderr with exit 1; syntax errors exit 2. else reject(new Error(`herdr ${args[0]} ${args[1] ?? ""} exited ${code}: ${err.trim() || out.trim()}`)); }); child.unref(); }); } /** * Unwrap the socket-API envelope `{id, result: {...}}` and return `.result`. * * Returns `undefined` for unparsable output, a missing `result`, or an * `{error: {...}}` response — callers treat that as failure and degrade. This is * the parse that was wrong before: the CLI never emits a bare payload, so * `JSON.parse(stdout) as HerdrWorktree` silently produced `{cwd: undefined}`. */ function parseEnvelope<T>(stdout: string): T | undefined { const trimmed = stdout.trim(); if (!trimmed) return undefined; try { const parsed = JSON.parse(trimmed) as { result?: T; error?: unknown }; if (parsed?.error) return undefined; return parsed?.result; } catch { return undefined; } } /** * Resolve the workspace id holding `branch`'s linked worktree under `repoCwd`. * * Fallback for legacy persisted runs that recorded only `{branch, repoRoot}` * before `workspaceId` was captured at create time. Returns `undefined` when the * worktree is already gone or herdr is unavailable. */ async function lookupWorktreeWorkspaceId(repoCwd: string, branch: string): Promise<string | undefined> { try { const result = parseEnvelope<WorktreeListResult>(await runJson(["worktree", "list", "--cwd", repoCwd])); const match = result?.worktrees?.find((wt) => wt.branch === branch && wt.is_linked_worktree !== false); return match?.open_workspace_id ?? undefined; } catch { return undefined; } } /** * Identify the parent-repo workspace herdr auto-opened during `worktree create`. * * `worktree create` opens a workspace for the parent repo when the repo has none, * so a create can produce TWO workspaces. That parent workspace is not a linked * worktree, so `worktree remove` rejects it (`not_linked_worktree`) and cleanup * must use `workspace close`. Returns `undefined` when the repo already had a * workspace open (nothing for us to clean up) or when it cannot be determined. * * Note: this cannot distinguish "herdr just opened it" from "it was already * open", so callers must only close it when they also created the worktree in the * same operation and the workspace is otherwise unused. */ async function findSourceWorkspaceId(repoCwd: string, worktreeWorkspaceId?: string): Promise<string | undefined> { try { const result = parseEnvelope<WorktreeListResult>(await runJson(["worktree", "list", "--cwd", repoCwd])); const sourceId = result?.source?.source_workspace_id ?? undefined; // Never report the linked-worktree workspace as the source workspace. return sourceId && sourceId !== worktreeWorkspaceId ? sourceId : undefined; } catch { return undefined; } } // ── Default invoker (spawn-based, fire-and-forget) ────────────────────────── /** * Default HerdrInvoker that shells `herdr` via `spawn(...).unref()`. * Fire-and-forget: swallows all errors so a missing/broken herdr binary * never throws into the workflow runtime. */ export function createDefaultHerdrInvoker(): HerdrInvoker { const runSync = (args: string[]): void => { try { const child = spawn("herdr", args, { stdio: "ignore" }); child.on("error", () => {}); child.unref(); } catch { // herdr binary missing / spawn failed — silently degrade. } }; return { async worktreeCreate(opts: { cwd: string; branch: string }): Promise<HerdrWorktree> { try { // --base is a git *ref*, --cwd is the repo path. Pass the repo path via // --cwd so herdr creates the worktree under the right repo. Worktree // commands are JSON-only in 0.8.0; --json was dropped from help. const stdout = await runJson(["worktree", "create", "--branch", opts.branch, "--cwd", opts.cwd, "--no-focus"]); const result = parseEnvelope<WorktreeCreateResult>(stdout); if (!result) return { cwd: "", branch: opts.branch }; // The checkout path lives on .result.worktree.path; .result.workspace.worktree // .checkout_path carries the same value. Never a bare `cwd` field. const cwd = result.worktree?.path ?? result.workspace?.worktree?.checkout_path ?? ""; const branch = result.worktree?.branch ?? opts.branch; const workspaceId = result.worktree?.open_workspace_id ?? result.workspace?.workspace_id; const rootPaneId = result.root_pane?.pane_id; // Creating a worktree can create TWO workspaces: the linked worktree and, // when the parent repo had no workspace open, one for the parent repo. // Detect the latter so cleanup can `workspace close` it — `worktree remove` // rejects it with `not_linked_worktree`. const sourceWorkspaceId = await findSourceWorkspaceId(opts.cwd, workspaceId); return { cwd, branch, workspaceId, rootPaneId, sourceWorkspaceId }; } catch { // Degrade: return a best-effort placeholder — caller sees a broken // worktree path and the run will fail with an actionable message. return { cwd: "", branch: opts.branch }; } }, async worktreeRemove(opts: { workspaceId?: string; cwd?: string; branch?: string }): Promise<void> { // Herdr worktrees are Herdr workspaces, so removing the checkout behind // Herdr's back via local git can leave a stale Herdr workspace/group entry. // 0.8.0 removed --branch/--cwd: --workspace <id> is the only selector. try { let workspaceId = opts.workspaceId; if (!workspaceId && opts.cwd && opts.branch) { // Legacy persisted runs recorded only branch + repo root. Resolve the // workspace id by listing the repo's worktrees and matching on branch. workspaceId = await lookupWorktreeWorkspaceId(opts.cwd, opts.branch); } if (!workspaceId) return; // nothing resolvable — already gone or never created await runJson(["worktree", "remove", "--workspace", workspaceId]); } catch { // Already gone / Herdr binary missing — silently degrade. } }, async workspaceClose(workspaceId: string): Promise<void> { try { await runJson(["workspace", "close", workspaceId]); } catch { // Already closed / Herdr binary missing — silently degrade. } }, async paneSplit(opts: { pane: string; cwd: string; direction?: "right" | "down" }): Promise<{ paneId: string }> { try { const stdout = await runJson([ "pane", "split", "--pane", opts.pane, "--direction", opts.direction ?? "down", "--cwd", opts.cwd, "--no-focus", ]); const result = parseEnvelope<{ pane?: { pane_id?: string } }>(stdout); return { paneId: result?.pane?.pane_id ?? "" }; } catch { return { paneId: "" }; } }, async agentStart( opts: { name: string; kind: string; pane: string; timeoutMs?: number }, argv: string[], ): Promise<{ paneId: string }> { // 0.8.0: `agent start` adopts an EXISTING idle shell pane and requires // --kind. It never creates/splits/moves layout — the caller supplies the // pane (worktree root pane, or a split of it for the subdir case). const args: string[] = ["agent", "start", opts.name, "--kind", opts.kind, "--pane", opts.pane]; if (opts.timeoutMs != null) args.push("--timeout", String(opts.timeoutMs)); if (argv.length > 0) args.push("--", ...argv); try { const stdout = await runJson(args); const result = parseEnvelope<{ pane?: { pane_id?: string }; agent?: { pane_id?: string } }>(stdout); // agent start adopts the pane we passed; prefer the echoed id, fall back // to the requested pane so a terse success response still succeeds. return { paneId: result?.pane?.pane_id ?? result?.agent?.pane_id ?? (result ? opts.pane : "") }; } catch { // Degrade gracefully — the pane is missing so later calls are no-ops. return { paneId: "" }; } }, reportAgent( pane: string, opts: { source: string; agent: string; state: "idle" | "working" | "blocked"; message?: string; customStatus?: string; seq?: string; }, ): void { const args: string[] = [ "pane", "report-agent", pane, "--source", opts.source, "--agent", opts.agent, "--state", opts.state, ]; if (opts.message) args.push("--message", opts.message); // customStatus is the one-line conductor status; report-agent has no // --custom-status flag in herdr 0.8.0, so ride on --message instead. if (opts.customStatus) args.push("--message", opts.customStatus); if (opts.seq) args.push("--seq", opts.seq); runSync(args); }, reportMetadata( pane: string, opts: { source: string; seq: string; customStatus?: string; ttlMs?: number; }, ): void { const args: string[] = ["pane", "report-metadata", pane, "--source", opts.source, "--seq", opts.seq]; // customStatus is the one-line conductor cell title; report-metadata uses // --title (herdr 0.8.0 has no --custom-status flag). if (opts.customStatus) args.push("--title", opts.customStatus); if (opts.ttlMs != null) args.push("--ttl-ms", String(opts.ttlMs)); runSync(args); }, releaseAgent(pane: string, opts: { source: string; agent: string }): void { runSync(["pane", "release-agent", pane, "--source", opts.source, "--agent", opts.agent]); }, paneClose(pane: string): void { runSync(["pane", "close", pane]); }, notify(_pane: string, opts: { title: string; sound: "done" | "request" }): void { runSync(["notification", "show", opts.title, "--sound", opts.sound]); }, }; } // ── conductorToHerdrState (docs §6 mapping) ────────────────────────────────── /** * Pure mapping from a ConductorRunStatus to the herdr report-agent state. * Implements the docs §6 table exactly: * * | ConductorStatus | herdr state | custom status | release | closePane | notify | * |---------------------------|-------------|----------------------------|---------|-----------|---------| * | spawned | working • spawned | — | — | — | * | workflow-running | working ▶ <phase> (reason) | — | — | — | * | workflow-complete-pane-open | working ◐ complete (pane open) | — | — | — | * | needs-finalize | blocked ! needs finalize | — | — | request | * | finalizing | working ⟳ finalizing | — | — | — | * | completed | idle ✓ done | yes | yes | done | * | failed | blocked ✗ failed | — | — | request | * | needs-human | blocked ? needs human | — | — | request | */ export interface HerdrStateMapping { state: "idle" | "working" | "blocked"; customStatus: string; release?: boolean; closePane?: boolean; notify?: "done" | "request"; } const CONDUCTOR_STATUS_LABELS_BY_NAME: Record<ConductorStatusName, string> = { spawned: "Spawned", "workflow-running": "Running", "workflow-complete-pane-open": "Complete (pane open)", "needs-finalize": "Needs finalize", finalizing: "Finalizing", completed: "Completed", failed: "Failed", "needs-human": "Needs human", }; /** * Map a ConductorRunStatus to the herdr cell state. * Pure function — unit-tested directly. */ export function conductorToHerdrState(status: ConductorRunStatus): HerdrStateMapping { const name: ConductorStatusName = status.status; const label = CONDUCTOR_STATUS_LABELS_BY_NAME[name]; switch (name) { case "spawned": return { state: "working", customStatus: "• spawned" }; case "workflow-running": { // §6: render the live phase (`▶ <phase>`). The active phase is carried in // `status.reason` (the conductor sets it to the current stage description). // Fall back to the fixed label when no reason is present. const phase = status.reason?.trim() || label; return { state: "working", customStatus: `▶ ${phase}` }; } case "workflow-complete-pane-open": return { state: "working", customStatus: "◐ complete (pane open)" }; case "needs-finalize": return { state: "blocked", customStatus: "! needs finalize", notify: "request" }; case "finalizing": return { state: "working", customStatus: "⟳ finalizing" }; case "completed": return { state: "idle", customStatus: "✓ done", release: true, closePane: true, notify: "done" }; case "failed": return { state: "blocked", customStatus: "✗ failed", notify: "request" }; case "needs-human": return { state: "blocked", customStatus: "? needs human", notify: "request" }; default: { // Fallback for any unknown status const _exhaustive: never = name; return { state: "idle", customStatus: String(_exhaustive) }; } } } // ── PaneSpawnCoordinator (concurrency cap) ────────────────────────────────── /** Lease returned by `acquire()` — must be returned via `release()`. */ export interface SpawnLease { runId: string; /** Release this lease back to the pool. */ release: () => void; } /** * Enforces `herdrMaxPanes` concurrency cap. * `acquire(runId)` returns a lease when under the cap, or `null` when the * cap is exceeded — never throws. The caller fails closed when null. */ export class PaneSpawnCoordinator { private static _cache = new Map<string, PaneSpawnCoordinator>(); private active = new Set<string>(); private cap: number; constructor(maxPanes: number = 4) { this.cap = maxPanes; } /** * Get or create a shared coordinator for a given project directory. * Ensures all WorkflowManager instances in the same project share * the same concurrency cap. If a new maxPanes is TIGHTER (lower) than the * cached cap, lower the cached cap so a manager reload honors the reduced * setting immediately (raising it is ignored to avoid loosening a cap set * by a stricter instance). Lowering only affects future `acquire()` calls; * in-flight leases are unaffected. */ static get(projectCwd: string, maxPanes: number = 4): PaneSpawnCoordinator { const key = resolve(projectCwd); let coord = PaneSpawnCoordinator._cache.get(key); if (!coord) { coord = new PaneSpawnCoordinator(maxPanes); PaneSpawnCoordinator._cache.set(key, coord); } else if (maxPanes < coord.cap) { coord.cap = maxPanes; } return coord; } /** Clear the shared cache (for testing). */ static reset(): void { PaneSpawnCoordinator._cache.clear(); } /** * Acquire a concurrency slot for `runId`. * Returns a lease on success, `null` when the cap is exceeded. * Never throws. * * Idempotent for the same `runId`: a failed/paused pane-spawn run intentionally * keeps its lease while the pane stays open (see executeRun's finally), and * `resume()` builds a fresh `ManagedRun` for the same `runId`. Re-acquiring * would count the run's own retained pane against the cap and return null * under `herdrMaxPanes: 1`, blocking the run from reattaching to its own pane. * When `runId` is already active, return a lease without re-adding it (release is * a no-op idempotent delete) so resume reuses the retained slot. */ acquire(runId: string): SpawnLease | null { if (this.active.has(runId)) { return { runId, release: () => { this.active.delete(runId); }, }; } if (this.active.size >= this.cap) { return null; } this.active.add(runId); return { runId, release: () => { this.active.delete(runId); }, }; } /** Current active count (for observability). */ get activeCount(): number { return this.active.size; } /** Configured concurrency cap (for observability / error messages). */ get maxPanes(): number { return this.cap; } /** * Reconcile the in-memory `active` set with persisted pane-spawn runs after a * process restart. A persisted run with a live `paneId` kept its Herdr pane * open (failed/paused/attention states retain the pane), so it must still count * against the cap — otherwise a fresh manager permits another full cap of * pane-spawn runs and defeats the VM memory ceiling. Each `runId` is seeded via * the idempotent `acquire()` so existing membership is not double-counted. * Returns the runIds that were newly seeded (already-active ones are skipped). */ reconcile(persistedPaneRunIds: Iterable<string>): string[] { const seeded: string[] = []; for (const runId of persistedPaneRunIds) { if (this.active.has(runId)) continue; // Seed membership directly (bypass the cap check): the pane is already open, // so the run is legitimately active regardless of the configured cap. New // acquisitions still enforce the cap against this seeded count. this.active.add(runId); seeded.push(runId); } return seeded; } } // ── RunPaneHandle ─────────────────────────────────────────────────────────── /** * Handle for a spawned pane — update the pane status or close it. Returned by * {@link createPaneHandle} after the manager has run `worktreeCreate` + * `agentStart` on the invoker. There is no single `spawnRunPane` orchestrator: * the manager drives the worktree/agent-start steps directly so it can interleave * persistence and concurrency-lease acquisition between them. */ export interface RunPaneHandle { paneId: string; /** Push a new conductor status into the herdr cell. */ updateStatus(status: ConductorRunStatus): void; /** Close the pane and release the agent. */ close(): void; } const PANE_SPAWN_SOURCE = "pi-workflows"; const PANE_SPAWN_AGENT = "pi-workflow"; const PANE_TTL_MS = 20_000; /** * Create a pane handle from a paneId. The handle manages report-agent / release / close * through the injected invoker. */ export function createPaneHandle(invoker: HerdrInvoker, paneId: string): RunPaneHandle { let seq = 0; const bumpSeq = () => { seq = Math.max(seq + 1, Date.now()); return String(seq); }; return { paneId, updateStatus(status: ConductorRunStatus): void { const mapping = conductorToHerdrState(status); invoker.reportAgent(paneId, { source: PANE_SPAWN_SOURCE, agent: PANE_SPAWN_AGENT, state: mapping.state, customStatus: mapping.customStatus, seq: bumpSeq(), }); // Self-heal: layer a ttl on the cell title via report-metadata, which // is the only report verb that supports --ttl-ms (docs §2). report-agent // itself does not accept --ttl-ms, so the TTL must live here. invoker.reportMetadata(paneId, { source: PANE_SPAWN_SOURCE, seq: bumpSeq(), customStatus: mapping.customStatus, ttlMs: PANE_TTL_MS, }); if (mapping.release) { invoker.releaseAgent(paneId, { source: PANE_SPAWN_SOURCE, agent: PANE_SPAWN_AGENT, }); } // Route the docs §6 notify mapping through the invoker so kept-open // terminal/attention states (needs-finalize, needs-human, completed, // failed) raise a herdr desktop toast/sound. Without this the pane cell // changes but the operator can miss the finalization/attention prompt. if (mapping.notify) { invoker.notify(paneId, { title: mapping.customStatus, sound: mapping.notify, }); } }, close(): void { invoker.paneClose(paneId); }, }; } // PANE_SPAWN_SOURCE is inlined above — re-export is not needed since it's a module-internal constant.