/** * LifecycleController — the single, per-agent owner of subagent lifecycle * state. * * Sensors (the internal session poll loop, sentinel watch, timers, and the * external steer/resume/spawner/reconciler drivers) REPORT facts as typed * `LifecycleEvent`s. The controller alone DECIDES transitions by applying the * `DECISIONS` table and is the only code allowed to call the tracker's * mutation methods (`transitionTo` / `setResult` / `setError` / * `interruptAgent` / `resumeAgent`). Consequences flow outward: record * effects populate the tracker before the transition (so observers fire with * a complete record); IO effects (stop combo, teardown sequence, monitor * re-attach) run after it. * * "Single owner" means a single point of AUTHORITY (this class), not a single * instance: one controller is created per subagent and owns that agent's * sensors, timers, and abort signal. It is disposed when the agent reaches a * terminal state, releasing its timers and loops. * * This class absorbs the former `GuardMonitor` poll loop and preserves its * observable behavior exactly: sentinel-gated completion (complete only when * `harness.isComplete()` AND a result is extractable, with a bounded * result-flush grace and truthful fallback), salvage-on-abort, turn-threshold * steer prompts (deduplicated), grace overflow → stop + interrupted, opt-in * staleness (`stalenessTimeoutMs: 0` disables), the opt-in overall * `maxPollTimeMs` bound (`0` disables), and session archival at each turn * increment. */ import type { GuardConfig } from "#src/guard-config"; import type { CodingHarness, HarnessEnvironment } from "#src/harness/interface"; import { type LifecycleContext, type LifecycleEvent } from "#src/lifecycle-decisions"; import type { TmuxManager } from "#src/tmux-manager"; import type { DurableTracker } from "#src/tracker"; /** * agentId → live controller map, so external drivers (steer, resume, * reconciler, spawner failure paths) can route events to the owning * controller. Controllers register themselves on construction and unregister * on disposal. */ export declare class LifecycleRegistry { private controllers; register(agentId: string, controller: LifecycleController): void; get(agentId: string): LifecycleController | undefined; unregister(agentId: string): void; /** * Route an event to the agent's live controller, falling back to a * detached (record-only) application when no controller exists — e.g. a * steer-kill of a record recovered from a previous session whose monitor * never re-attached. Both paths run the same decision table. */ report(tracker: DurableTracker, agentId: string, event: LifecycleEvent, ctx?: Partial): void; } export interface LifecycleControllerOptions { harness: CodingHarness; env: HarnessEnvironment; agentId: string; tracker: DurableTracker; tmux: TmuxManager; sessionName: string; /** * Window NAME, not index. With `renumber-windows on`, killing any sibling * window in a parallel wave shifts every higher-indexed window down, so a * numeric index captured once at spawn time can go stale mid-run. Window * names embed the agentId and never renumber — used as the identity check * and as the fallback target for legacy records with no `windowId`. */ windowName: string; /** * Stable tmux window ID captured atomically at creation (design D3/D4). * Preferred target for every live tmux operation (stop combo, threshold * nudges) when present; falls back to `windowName` for legacy records. */ windowId?: string; config: GuardConfig; stopKeyCombo: string; /** Parent abort signal — triggers salvage-on-abort when it fires. */ signal?: AbortSignal; /** Registry to self-register in (and unregister from on disposal). */ registry?: LifecycleRegistry; /** * The ordered worktree merge → teardown sequence, run when a rule requests * the `RunTeardownSequence` effect. Owned by the controller precisely * because the ordering (merge BEFORE delete) cannot be expressed through * order-independent observers. */ runTeardownSequence?: () => Promise; } export declare class LifecycleController { private readonly harness; private readonly env; private readonly agentId; private readonly tracker; private readonly tmux; private readonly sessionName; private readonly windowName; private readonly windowId?; private readonly config; private readonly stopKeyCombo; private readonly parentSignal?; private readonly registry?; private readonly runTeardownSequence?; /** Wakes sleeps/waits when the parent aborts OR the controller is disposed. */ private loopAbort; /** True once dispose() ran — the loop exits without salvage handling. */ private stopped; /** True while the monitor loop is executing (double-start guard). */ private loopRunning; /** Tracks atTurnsRemaining values that have already been fired — dedup. */ private firedThresholds; /** Last known turn count from harness.readTurnCount(). -1 = not yet read. */ private lastTurnCount; /** Last known entry count from session file analysis. */ private lastEntryCount; /** Consecutive polls with no entry count change (sentinel-absent branch only). */ private stalePolls; /** * Poll budget before an inactive (sentinel-absent) agent is failed as * wedged. Derived from `config.stalenessTimeoutMs` and the poll interval. * `STALENESS_DISABLED` (Infinity) when `stalenessTimeoutMs <= 0` — the * default, meaning inactivity never fails the agent. */ private readonly maxStalePolls; /** Timestamp when monitoring started. Used for the overall timeout. */ private startTime; /** Timestamp the lifecycle-end sentinel was first observed set. Bounds the * result-flush grace before completing with a fallback. */ private sentinelSeenAt?; constructor(opts: LifecycleControllerOptions); /** * Report a fact about this agent. The controller applies the matching * decision-table rule: record effects → transition (DAG-validated by the * tracker) → IO effects → disposal on terminal states. * * Called by the internal poll loop for sensed facts and by external * drivers (steer, resume, spawner failure paths) for theirs. */ report(event: LifecycleEvent, ctx?: Partial): void; /** * Apply an event for an agent that has NO live controller (e.g. a record * recovered from disk whose window is gone, or a steer-kill of a stray). * Same decision table, record-only: IO effects cannot run without a * controller and are dropped with a warning. Lives on the class so * transition authority stays in one place. */ static reportDetached(tracker: DurableTracker, agentId: string, event: LifecycleEvent, ctx?: Partial): void; /** * Start the monitoring loop. * * Designed to be called fire-and-forget (the spawner chains cleanup on the * returned promise). Runs until a terminal event is decided, the parent * signal aborts (salvage), or the controller is disposed externally. */ start(): Promise; /** * Poll the harness's late-binding `discoverSessionFile` locator until it * yields a path, the timeout expires, or the loop aborts — the * harness-aware twin of `waitForSessionFile` (same budget/semantics). */ private waitForDiscoveredSessionFile; private monitorLoop; /** * Terminal handling when the parent abort signal fires: assemble the * salvage facts (sentinel state + any extractable result) and report * `ParentAbort` — the decision table's salvage branch keeps finished work * as `completed` and unfinished work as `interrupted`. Only a genuine * error in this salvage falls back to `crashed`. */ private finalizeOnAbort; /** * Release the controller: stop the poll loop, cancel pending sleeps, and * unregister from the registry. Called automatically when the agent * reaches a terminal state; safe to call repeatedly. */ dispose(): void; /** Whether the controller has been disposed. */ get disposed(): boolean; private runIoEffect; /** * Send the stop key combo to the tmux window. Runs AFTER the interrupted * transition so the tracker state is durable before any tmux operation * that might throw. The combo defaults to "C-c" (Ctrl+C); any other combo * is sent as a tmux KEY NAME (non-literal, no Enter) — a literal send * would TYPE the combo text into the composer (codex's `Escape`). */ private sendStopKeyCombo; /** * Read the current turn count from the harness. * * Uses `harness.readTurnCount(env)` if available, falling back to * `harness.analyzeSession()` for the turn count. */ private readTurnCount; /** True once GraceOverflow has been reported for this controller instance. */ private graceOverflowFired; /** * Grace overflow: fire at most once per controller instance when turnCount * is at/over budget. Combined with constructor seeding of `lastTurnCount` * from the record, a resume re-attach does not immediately re-interrupt; * the next turn that lands over budget still can. Caller should invoke * AFTER completion detection so a finishing-over-budget turn completes. * @returns true if overflow was reported (caller should return from poll). */ private checkGraceOverflow; /** * Archive the current session file state. * * Extracts parentSessionId from env.metadata. * Best-effort — failures are silently ignored. */ private archiveCurrent; /** * Check all turn thresholds against the current turn count and fire * any that match. */ private checkThresholds; /** * Execute a threshold action (deduplicated). * * Currently supports only the "steer" action type, which sends a * prompt template to the agent via tmux send-keys. Threshold steers are a * sensor-side nudge, not a state transition — no event is reported. */ private fireThresholdAction; } //# sourceMappingURL=lifecycle-controller.d.ts.map