/** * Workflow manager for background execution, pause/resume, and run management. */ import { EventEmitter } from "node:events"; import type { ModelRegistry } from "@earendil-works/pi-coding-agent"; import type { WorkflowAgent } from "./agent.js"; import type { ConductorRunStatus } from "./conductor-types.js"; import type { ContextModeRegistry } from "./context-mode.js"; import { type WorkflowSnapshot } from "./display.js"; import { WorkflowError } from "./errors.js"; import type { HarnessSelection } from "./harness-selector.js"; import { type HerdrInvoker, type RunPaneHandle, type SpawnLease } from "./pane-spawn.js"; import { type PersistedRunState, type RunLease, type RunPersistence, type RunStatus } from "./run-persistence.js"; import { type JournalEntry, type WorkflowRunOptions, type WorkflowRunResult } from "./workflow.js"; export interface ManagedRun { runId: string; status: RunStatus; snapshot: WorkflowSnapshot; result?: WorkflowRunResult; error?: WorkflowError; controller: AbortController; startedAt: Date; /** The real script, kept so the run can be resumed. */ script: string; args?: unknown; /** Accumulated agent results for resume (deterministic call index -> result). */ journal: JournalEntry[]; /** Cross-process execution lease for this run, when it is actively executing. */ lease?: RunLease; /** * True when the run was started in the background (or resumed) and the caller is * not awaiting its result inline. Only background runs deliver their result back * into the conversation; a foreground sync run already returns it as the tool * result, so re-delivering would duplicate it. */ background: boolean; /** * Directory each subagent's NDJSON transcript is written to (one file per * subagent), so a failed run is debuggable — matching Claude Code's per- * subagent `agent-.jsonl` transcripts. Undefined when transcript persistence * is disabled via `persistSubagentTranscripts: false`. */ transcriptDir?: string; /** Per-run override of the wall-clock timeout, captured when the run started * so persistence/resume keep the original explicit/settings default. null * disables the run-wide timeout; undefined means the runtime constant applies. */ workflowTimeoutMs?: number | null; /** Whether `workflowTimeoutMs` was explicitly captured for this run. Always * true for fresh runs (startInBackground/runSync resolve it from exec/manager * default at start, even when that resolves to undefined). For resumed runs it * is true only when the persisted state had the field — false for old runs * persisted before this feature, so executeRun passes `undefined` to * runWorkflow and the runtime `DEFAULT_WORKFLOW_TIMEOUT_MS` applies instead of * the current manager/settings default. */ workflowTimeoutMsCaptured?: boolean; /** Path to the persisted run-state JSON (runsDir/.json), so a failed * run can link to it from the chat block. Set regardless of whether * subagent transcript persistence is enabled (the run state is always saved). */ runStatePath?: string; /** Effective run-level hard per-agent context cap captured at start/resume. */ agentMaxContextTokens?: number | null; /** Effective run-level context reserve override captured at start/resume. */ agentContextReserveTokens?: number | null; /** Effective run-level compaction policy captured at start/resume. */ compactionPolicy?: WorkflowRunOptions["compactionPolicy"]; /** Effective run-level loop-guard policy captured at start/resume. */ loopGuard?: WorkflowRunOptions["loopGuard"]; /** Harness selection snapshot captured at run start and reused on resume. */ harnessSelection?: HarnessSelection; /** Persisted run state used only to resume deterministic harness selection snapshots. */ persistedRunState?: PersistedRunState; /** Run-level isolation worktree (persisted so resume reuses it; undefined when not isolated). */ worktree?: { cwd: string; branch?: string; repoRoot?: string; }; /** Optional conductor-level semantic status, layered on top of the engine * `status` above. Older runs may omit this. */ semanticStatus?: ConductorRunStatus; /** Optional herdr-pane handle (only when paneSpawn isolation is active). */ paneHandle?: RunPaneHandle; /** Persisted herdr pane id for a pane-spawn run, so resume can recreate the * pane handle and keep driving the pane's lifecycle/finalization. */ paneId?: string; /** Optional pane-spawn coordinator lease (released in executeRun's finally). */ _spawnLease?: SpawnLease; } /** Per-execution options shared by sync, background, and resume runs. */ export interface ExecOptions { /** Replay these journaled agent results for the unchanged prefix (resume). */ resumeJournal?: Map; /** Cap on total agents for this run. */ maxAgents?: number; /** Per-agent timeout in milliseconds. null/omitted means no hard timeout. */ agentTimeoutMs?: number | null; /** Wall-clock timeout for the whole async workflow script. null means no hard timeout. */ workflowTimeoutMs?: number | null; /** Host signal (e.g. tool/Esc) that should abort this run when fired. */ externalSignal?: AbortSignal; /** Called with the live snapshot on every progress event. */ onProgress?: (snapshot: WorkflowSnapshot) => void; /** Hard token budget for this run; once spent reaches it, agent() throws. */ tokenBudget?: number | null; /** Default hard cap for provider input/context tokens per agent. */ agentMaxContextTokens?: number | null; /** Default reserve subtracted from model context windows for occupancy. */ agentContextReserveTokens?: number | null; /** Default per-agent compaction posture for this execution. */ compactionPolicy?: WorkflowRunOptions["compactionPolicy"]; /** Detect repeated identical agent() calls. Default is warn-only. */ loopGuard?: WorkflowRunOptions["loopGuard"]; /** Max concurrent agents for this execution. */ concurrency?: number; /** Retry attempts after recoverable agent failures for this execution. */ agentRetries?: number; /** Full subagent tool set for this execution; when omitted, the default coding tools are used. */ tools?: WorkflowRunOptions["tools"]; /** Resolve a checkpoint() question with a human reply (only for UI-bearing runs). */ confirm?: (promptText: string, options: unknown) => Promise; /** Run-level default context posture for this execution (e.g. slash --mode). */ contextMode?: string; /** Tentative run-level harness runtime selector; inert until Issue D wires expansion. */ harness_type?: string; /** Tentative run-level harness capability/config selector; inert until Issue D wires expansion. */ harness_config?: string; /** * Directory to persist each subagent's NDJSON transcript into for this run. * Overrides the manager's default (computed from the run id) when set. */ transcriptDir?: string; /** * Run-level isolation: when `worktree` is true, the run executes in its own git * worktree (branch `pi/wf/`) and never touches the primary checkout's * working branch; the worktree is removed when the run settles. The conductor's * finalization then delivers a PR from that worktree. (Tier-1 isolation seam.) */ isolation?: RunIsolationOptions; /** First-class alias for `isolation: { worktree: true }` (demand an isolated run). */ worktreeRequired?: boolean; /** * Resume reuse: when set, executeRun reuses this existing worktree (from a * paused run's persisted state) instead of creating a new one, so a resumed * run continues in the same isolated tree with its edits intact. */ reuseWorktree?: { cwd: string; branch?: string; repoRoot?: string; }; } /** * Run-level isolation options for {@link WorkflowManager.startInBackground}/ * {@link WorkflowManager.runSync}. The foundation of the Tier-1 conductor seam * (see docs/herdr-integration.md §4): a run gets its own git worktree so it never * touches the primary checkout, and finalization delivers a PR from it. * * The tmux/herdr pane spawn (a real `pi` per run) is a tracked follow-up validated * against the admin-portal worked example; this option delivers the worktree * isolation + PR-from-worktree leg today. */ export interface RunIsolationOptions { /** Create + run in a git worktree (branch `pi/wf/`); removed on settle. */ worktree?: boolean; /** Base cwd/repo to add the worktree to (defaults to the manager's cwd). */ base?: string; /** * Enable herdr pane-spawn: spawn a real pi process in a herdr-managed pane, * nested under the caller's workspace/tab/split. The herdr-managed worktree * replaces src/worktree.ts — single source of truth, no double bookkeeping. * Requires `herdrPaneSpawn` setting to be enabled on the manager. */ paneSpawn?: boolean; } export interface WorkflowManagerOptions { cwd?: string; concurrency?: number; /** Resolve a saved-workflow name to its script, enabling nested `workflow('name')`. */ loadSavedWorkflow?: (name: string) => string | undefined; /** Inject a custom agent runner (tests); defaults to a real subagent session. */ agent?: Pick; /** The session's main model (provider/id), for auto-tiering explore agents. */ mainModel?: string; /** Host session ModelRegistry shared with workflow subagents (upstream #49 port). */ modelRegistry?: ModelRegistry; /** The pi session id to tag runs with (see setSessionId). */ sessionId?: string; /** Default per-agent timeout when a run does not pass agentTimeoutMs. null means no hard timeout. */ defaultAgentTimeoutMs?: number | null; /** * Default hard wall-clock timeout for a whole run, in milliseconds, applied * when a run does not pass its own `workflowTimeoutMs`. null disables the * run-wide timeout explicitly; undefined (the default) lets the runtime * constant (`DEFAULT_WORKFLOW_TIMEOUT_MS`) still apply. Normalized exactly * like `defaultAgentTimeoutMs`. */ defaultWorkflowTimeoutMs?: number | null; /** Default retry attempts after recoverable agent failures. */ defaultAgentRetries?: number; /** Default hard cap for provider input/context tokens per agent. */ defaultAgentMaxContextTokens?: number | null; /** Default reserve subtracted from model context windows for occupancy. */ defaultAgentContextReserveTokens?: number | null; /** Named context-mode registry (built-ins + project-defined) for tool-driven runs. */ contextModeRegistry?: ContextModeRegistry; /** Injectable HerdrInvoker for pane-spawning (tests inject a mock). */ herdrInvoker?: HerdrInvoker; } export declare class WorkflowManager extends EventEmitter { private runs; private persistence; private cwd; private concurrency; private loadSavedWorkflow?; private agent?; /** The session's main model (provider/id), for auto-tiering explore agents. */ private mainModel?; /** Host session ModelRegistry shared with workflow subagents (see setModelRegistry). */ private modelRegistry?; /** True once installTaskPanel() has registered the below-editor panel — lets the * workflow tool suppress redundant chat streaming only when a panel will show * live progress (see workflow-tool.ts). Set by installTaskPanel in task-panel.ts. */ hasTaskPanel: boolean; /** The current pi session id; runs are stamped with it and listRuns() filters by it. */ private sessionId?; private defaultAgentTimeoutMs; /** Resolved settings/option default for the run-wide timeout. undefined keeps the runtime default. */ private defaultWorkflowTimeoutMs; private defaultAgentRetries; private defaultAgentMaxContextTokens; private defaultAgentContextReserveTokens; /** Named context-mode registry threaded into every run so project modes resolve. */ private contextModeRegistry?; /** Cached setting: whether subagent transcripts are persisted to disk. */ private persistSubagentTranscripts; /** Injectable herdr invoker for pane-spawning (real or mock). */ private herdrInvoker; /** Pane-spawn concurrency coordinator (enforces herdrMaxPanes cap). */ private paneCoordinator; /** Cached herdrPaneSpawn setting ('off' | 'auto'). */ private herdrPaneSpawnSetting; constructor(options?: WorkflowManagerOptions); /** Directory each subagent's transcript is written to for a given run id. */ private transcriptDirFor; /** Path to the persisted run-state JSON for a run id (runsDir/.json) — * delegates to the shared runStateJsonPath so this can never drift from where * RunPersistence actually writes the file. */ private runStatePathFor; /** * Resolve the transcript dir for a run, creating it (best-effort) when * persistence is enabled. Returns undefined when the user opted out. */ private resolveTranscriptDir; /** Resolve the per-run wall-clock timeout to capture at start time, so it is * persisted and survives resume. A per-call exec override wins; otherwise the * manager/settings default applies. undefined is preserved (runtime constant). */ private resolveStartWorkflowTimeoutMs; private resolveStartAgentMaxContextTokens; private resolveStartAgentContextReserveTokens; /** Bind the manager to the current pi session, so new runs are tagged with it and * the navigator/task-panel show only this session's runs (set on session_start). */ setSessionId(id: string | undefined): void; /** * On startup, any persisted run still marked "running" belongs to a process * that died mid-run (this fresh manager has it nowhere in memory). Reconcile it * to "paused" — never "failed" — so its journal is preserved and resume() can * replay the completed prefix and finish the rest. */ private recoverStaleRuns; /** * Seed the pane-spawn coordinator with persisted runs that still have a live * Herdr pane (a `paneId` on disk) after a process restart. Such runs kept * their pane open across the restart (failed/paused/attention states retain * the pane + lease), so they must continue counting against `herdrMaxPanes` — * otherwise the fresh manager over-allocates panes and breaches the VM memory * ceiling. Runs already in memory (an in-process lease) are skipped. */ private reconcilePaneCap; private readConductorReconciliationSignals; private readConductorStateEnvs; private readJsonSidecar; private readTextSidecar; /** Set the session's main model (provider/id). Used to auto-tier explore agents. */ setMainModel(spec: string | undefined): void; /** * Share the host session's ModelRegistry with workflow subagents (upstream #49 * port). Set on session_start; runs started afterwards resolve tier/phase/model * routing against the same registry as the main Pi session, so extension- * registered providers are routable instead of silently falling back. */ setModelRegistry(registry: ModelRegistry | undefined): void; /** The shared host ModelRegistry, when one has been set (see setModelRegistry). */ getModelRegistry(): ModelRegistry | undefined; /** * Start a workflow in the background. * Returns immediately with a run ID; the workflow executes asynchronously. */ startInBackground(script: string, args?: unknown, exec?: ExecOptions): { runId: string; promise: Promise; }; /** * Execute a workflow synchronously (blocking) while still tracking it like a * background run, so the `/workflows` navigator and the live task panel see it. * `onProgress` fires on every progress event with the current snapshot, letting * a caller (e.g. the workflow tool) drive its own inline display. */ runSync(script: string, args?: unknown, exec?: ExecOptions): Promise; /** Build a fresh managed run with an empty snapshot. */ private createManaged; private executeRun; /** Close a pane-spawn run's pane slot: close the herdr pane, release the * coordinator lease, clear the in-memory handle and persisted `paneId`, and * persist the cleared state. Shared by the abort finally (executeRun unwinding) * and stop() for an already-paused pane-spawn run (no finally runs in that * case, so the pane handle, lease, and paneId would otherwise leak and keep * seeding the herdrMaxPanes cap on restart). Idempotent: safe to call when the * lease/handle/paneId are already gone. */ private closePaneSlot; private releaseRunLease; private persistRun; /** * Pause a running workflow. */ pause(runId: string): boolean; /** * Resume an interrupted run: replay journaled results for the unchanged prefix * and run the rest live. Returns false if there is nothing resumable. */ resume(runId: string): Promise; /** * Stop a running workflow. */ stop(runId: string): boolean; /** * Get status of a specific run. */ getRun(runId: string): ManagedRun | undefined; /** * Push the engine terminal state through the pane handle. * * Pane updates normally flow from `setSemanticStatus` (the conductor's * `onSemanticStatus` callback), but a pane-spawn workflow that completes * without setting a semantic `completed` — or throws before setting semantic * `failed` — would leave its `paneHandle` stale and open after the engine * emits complete/error. Route the manager's terminal `completed`/`failed`/ * `aborted` transitions through the pane handle here so generic pane-spawn runs * and early failures still apply the documented close/failed mapping. */ private applyEngineTerminalPaneStatus; /** * Set the conductor-level semantic status for a run. * The status is persisted (so it survives resume) and returned by * listRuns() alongside the existing engine `status`. * When a pane handle is attached (pane-spawn isolation), this also pushes * the conductorToHerdrState mapping into the herdr cell and auto-closes * the pane on `completed`. */ setSemanticStatus(runId: string, semanticStatus: ConductorRunStatus): void; /** Cheap in-memory active-run check (no persistence scan) for the task panel's * idle timer — avoids listRuns() disk I/O every tick when nothing is running. */ hasActiveRuns(): boolean; /** * List all runs (active + persisted). */ /** * Runs for the navigator/task panel. Once bound to a session (setSessionId), only * that session's runs are returned — runs from other sessions stay on disk and * reappear when you switch back. Unbound (tests/legacy) returns everything. */ listRuns(): PersistedRunState[]; /** All persisted runs regardless of session (used by cross-session recovery). */ listAllRuns(): PersistedRunState[]; /** * Get snapshot of a run. */ getSnapshot(runId: string): WorkflowSnapshot | null; /** * Delete a persisted run. */ deleteRun(runId: string): boolean; /** * Get the persistence layer (for saving workflows). */ getPersistence(): RunPersistence; }