/** * 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 FoundationHostCapability } from "./foundation-host.js"; import type { HarnessSelection } from "./harness-selector.js"; import { type HerdrInvoker, type RunPaneHandle, type SpawnLease } from "./pane-spawn.js"; import { type ControlActionEvent, type ControlAuthorizer, type PersistedRunState, type RunLease, type RunPersistence, type RunStateRetentionConfig, 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"]; /** Package-authorized host callbacks captured at run start/resume. */ hostCapabilities?: FoundationHostCapability[]; /** Stable package execution-policy identity used by resume hashes. */ hostCapabilityPolicyKey?: string; /** Initial isolated-worktree HEAD used for committed/uncommitted scope validation. */ hostEditScopeBaseRef?: string; /** 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). * `workspaceId` is the herdr workspace holding the linked worktree checkout — the ONLY * selector `worktree remove` accepts on 0.8.0. Absent on legacy persisted runs, which fall * back to a `worktree list --cwd ` lookup by branch. */ worktree?: { cwd: string; branch?: string; repoRoot?: string; workspaceId?: 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; /** Bounded usage-limit resume attempt count for this run (issue #136 §5). * Increments on each resume of a usage_limit-paused run; once it exceeds * `maxResumeAttempts` the run settles terminal instead of looping. */ resumeAttempts?: number; /** Cap on usage-limit resume attempts for this run (runtime default when unset). */ maxResumeAttempts?: number; /** ISO timestamp of the last resume of this run. */ lastResumeAt?: string; /** True once the run exhausted its usage-limit resume budget (terminal marker). */ usageExhausted?: boolean; /** Bounded control-action audit log (in-memory mirror of the persisted field). */ controlActions?: ControlActionEvent[]; /** Steering snapshot captured at start so resume keeps the original * routing/context/tool-policy unless an explicit change invalidates the suffix. */ steeringSnapshot?: { contextMode?: string; harnessType?: string; harnessConfig?: string; }; } /** 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; /** Package-authorized, narrowly typed host callbacks for this saved workflow. */ hostCapabilities?: FoundationHostCapability[]; /** Stable capability policy/version identity folded into resume hashes. */ hostCapabilityPolicyKey?: string; /** * 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; workspaceId?: 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; /** * Bounding + retention policy for persisted run state. Overrides the * `runStateRetention` setting (project/global) when both are present. Only the * payload spill bound is applied at save time; completed-run pruning is opt-in * via {@link WorkflowManager.pruneCompletedRuns} or the configured limits. */ runStateRetention?: RunStateRetentionConfig; } 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; /** Bounding + retention policy for persisted run state (payload bound + pruning). */ private runStateRetention; /** Cap on usage-limit resume attempts (issue #136 §5). Runtime default when unset. */ private defaultMaxResumeAttempts; /** Whether usage-limit auto-resume is enabled (issue #136 §2). */ private usageLimitAutoResume; 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; /** * Record a control-action event into the bounded audit log (issue #136 §1). * Keeps only the last {@link MAX_CONTROL_ACTION_LOG} events so the persisted * record never grows unbounded. Idempotent-safe: appends + persists. */ private recordControlAction; /** Resolve the per-run usage-limit resume cap, falling back to the manager default. */ private resolveMaxResumeAttempts; private persistRun; /** * Pause a running workflow (operator action). Distinct from a usage-limit * pause (which the engine sets itself with reason `usage_limit`): this is an * explicit operator pause, recorded as a `pause` control action with no * `usage_limit` reason so the navigator/task notification can tell them apart. */ 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. * * Bounded usage-limit resume (issue #136 §2/§5): each resume of a * `usage_limit`-paused run increments `resumeAttempts`; once it exceeds * `maxResumeAttempts` the run settles into a terminal `failed`/`needs-human` * state with `pauseReason: "usage_exhausted"` instead of silently looping. * Manual resume is always allowed up to the cap; `usageLimitAutoResume` * schedules one bounded auto-resume per pause. Resume keeps the original * routing/context/tool-policy snapshot unless an explicit change invalidates * the journal suffix (already enforced by runWorkflow's call-hash). A * `steer()` override applied between pauses invalidates the suffix deliberately. */ resume(runId: string, opts?: { authorizer?: ControlAuthorizer; reason?: string; }): Promise; /** * Mark a usage_limit-paused run as exhausted: terminal `failed` + * `needs-human` semantic status with an actionable reason, so the navigator * shows an actionable terminal state instead of a silent infinite-retry loop. * Persists the state and emits a terminal `error` so the task notification + * herdr reporter surface it. Best-effort lease handling: the caller releases * the lease it acquired. */ private markUsageExhausted; /** * Schedule a single bounded system-driven resume of a usage_limit-paused run * (issue #136 §2). The resume() guard enforces maxResumeAttempts + the * `usageExhausted` marker, so this can never create an unbounded loop: a run * that pauses again after the cap settles terminal. The timer is unref'd so it * never keeps the process alive on its own, and cancelled if the run is * stopped/deleted before it fires (checked via the in-memory run state + the * persisted pauseReason). Idempotent per run: a second pause replaces the * pending timer. */ private scheduleUsageLimitAutoResume; /** Pending usage-limit auto-resume timers, keyed by runId so stop()/deleteRun * can cancel a scheduled resume instead of racing a stopped/deleted run. */ private pendingAutoResumes; /** Cancel a pending usage-limit auto-resume for a run (best-effort cleanup on * stop/deleteRun). Idempotent. */ private cancelAutoResume; /** * Steer a paused run: apply a routing/context/tool-policy override that * deliberately invalidates the journal suffix so the next resume re-runs the * affected agents live instead of replaying stale output (issue #136 §1 safe * steering). Only allowed on a paused (not running) run; a running run must be * paused first. Returns false when the run is not paused or unknown. * * The override is recorded as a `steer` control action and persisted into * `steeringSnapshot`; resume() threads it through so the run keeps the new * routing while the completed prefix (unchanged agents) still replays. */ steer(runId: string, override: { contextMode?: string; harnessType?: string; harnessConfig?: string; }): boolean; /** * Stop a running (or paused) workflow (operator terminal action). Recorded * as a `stop` control action so the audit trail distinguishes an explicit * stop from a usage-limit exhaustion (system `stop` with reason `cap_exceeded`). */ 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; /** * Prune COMPLETED persisted runs that exceed the configured retention policy * (age and/or count). Active, paused, failed, and aborted runs are never * removed, and any artifact still referenced by a surviving run is never * deleted (deletion removes only the targeted run's own state + artifact dir). * Returns the run IDs that were removed. No-op when no pruning limits are set. * * Pass an explicit config to override the manager's configured policy for this * call only (e.g. an operator `--max-age` flag). */ pruneCompletedRuns(config?: RunStateRetentionConfig): string[]; }