Files
fusion/packages/engine/src/cli-agent/state-machine.ts
gsxdsm f9f06a4fb7 engine tail: nine files to zero — incl. a census-INVISIBLE requeue into a lane that does not exist (−13) (#2797)
Follow-up to #2785. Nine engine files to **zero**, all one question —
*"is this task finished?"* — asked in nine places, wrong in every one on
a renamed board.

## Census, per file (measured, `--strict` verified)

| file | main | here |
| --- | ---: | ---: |
| `agent-reflection.ts` | 2 | **0** |
| `merger-scope-auto-widen.ts` | 2 | **0** |
| `worktree-pool.ts` | 2 | **0** |
| `cli-agent/state-machine.ts` | 2 | **0** (reclassified — see below) |
| `auto-recovery-handlers/branch-worktree.ts` | 1 | **0** |
| `cli-agent/task-session.ts` | 1 | **0** (reclassified) |
| `merger-integration-worktree.ts` | 1 | **0** |
| `merger-orphan-rehome.ts` | 1 | **0** |
| `plugin-runner.ts` | 1 | **0** |
| **net** | | **−13** |

## The one worth reading: `branch-worktree` had TWO defects, and the
census could only see one

```ts
if (task.column === "in-progress") { …clear branch… }        // counted
await this.deps.taskStore.moveTask(task.id, "todo", { … });  // INVISIBLE
```

The census scores **comparisons**. The requeue *destination* is a call
argument, so nothing in the backlog ever pointed at it — and it is the
worse of the two: a board with no `todo` column was requeued into a lane
**that does not exist**. The counted literal is the smaller half (a
renamed wip lane meant the stale branch was never cleared, so the card
carried a dead branch back into execution).

Converting the comparison alone would have dropped a census count and
left the board requeuing into nowhere. Destination now resolves through
`resolveReboundTarget` (KTD-10 ordering: hold → intake → first column).

Reverted **independently**: destination restored → 2 fail (`moveTask`
called with `"todo"`, not `"backlog"`); wip test restored → 1 fail
(`updateTask` never called).

## The rest

- **`plugin-runner`** — `onTaskCompleted` never fired on a renamed
board. Every plugin that closes an issue, posts a notification, or
records a metric on completion **silently stopped**, with nothing
logged. Resolved *asynchronously* inside the existing fire-and-forget
seam, not via `resolveTaskWorkflowIrSync` — per
`sync-workflow-ir-callsite-allowlist` that reader returns the DEFAULT
workflow for every task in production, so a sync guard here would read
as converted and still be wrong. The listener is already
`void`-dispatched, so awaiting inside it changes no observable ordering
(the shape `NotificationService` already uses).
- **`merger-orphan-rehome`** — a renamed complete lane made every source
task read as unfinished, so orphaned commits were never rehomed and
stayed stranded off the integration branch. Resolves by the **trailer
id**, not `sourceTask.id`, which the fake store does not populate.
- **`agent-reflection`** — `classifyOutcome` returned `null` for every
finished task, so both callers treated completed work as nothing to
reflect on: one recorded `reflection:skipped` with reason
`"not-completed"`, the other silently `continue`d. Reflection captured
**nothing at all** on a custom board.
- **`worktree-pool`** — shipped tasks' worktrees stayed in the ACTIVE
set, so the reclaim pass never returned them and the board walks into
worktree exhaustion — a stall whose cause is invisible from the symptom.
- **`merger-scope-auto-widen`** — finished cards counted as active
claimants, so a merge was blocked by a task that no longer exists in any
meaningful sense.
- **`merger-integration-worktree`** — a shipped task still counted as a
live worktree user, so the integration worktree could never be reused
and the merge path took the slower rebuild every time.

## The census OVERSTATED the engine backlog by 3

`cli-agent/state-machine.ts` and `cli-agent/task-session.ts` compare
against `done` — but that is a **`CliMachineState`**
(`ready`/`busy`/`waitingOnInput`/`done`/`resuming`/`idle`) tracking one
CLI agent process. It never reads a board column. The census matches the
bare string.

Marked `DELIBERATE-LITERAL` rather than left for a later sweep to
"convert" a process state into a workflow role. Worth flagging
fleet-wide: the backlog total includes at least these three non-columns.

## Revert results (measured, each run)

| conversion | reverted → |
| --- | --- |
| `plugin-runner` complete gate | RENAMED case fails — `onTaskCompleted`
never invoked |
| `merger-orphan-rehome` source gate | RENAMED case fails —
`orphan:false, reason:"source-task-not-done"` |
| `branch-worktree` destination | 2 fail — `moveTask` called with
`"todo"` |
| `branch-worktree` wip test | 1 fail — `updateTask` never called |

Each has a **non-vacuous companion** (renamed board, non-complete lane /
mid-flight source / non-wip column) so a guard that fired
unconditionally would not pass.

**Four are NOT revert-proven, and I am not claiming otherwise:**
`agent-reflection`, `merger-scope-auto-widen`,
`merger-integration-worktree`, `worktree-pool`. Their suites omit a
workflow and therefore assert the legacy fallback — they pass before and
after. `merger-scope-auto-widen` has no test file at all;
`scanIdleWorktrees` is mocked in every suite that touches it and driving
it for real needs git worktrees on disk. All four strictly **widen** the
finished set (resolved roles ∪ the legacy ids), so default boards are
byte-identical. That is the argument for shipping them, not a substitute
for coverage.

## Examined and deliberately NOT converted

- **`backlog-pressure-reporter:173`** — fed by `listTasks({ column:
"todo" })`, a hardcoded **query** filter. On a renamed board `todoFull`
is empty and the predicate never runs. Converting it drops a census
count and changes nothing observable; the fix belongs at the query
layer.
- **`auto-merge-finalization:28`** — the catch-arm legacy fallback,
which must stay for the same reason `columnRoles.ts` keeps its id
fallback.
- **`auto-merge-finalization:84`** — only selects between two diagnostic
reason strings that are **both** `ok: false`, on a pure validator with
no store in scope. Converting it would thread a store through a pure
function to change a label.

## Merge resolution note

Merging main brought conflicts in `agent-assignment.ts` and
`ephemeral-worker-manager.ts`. **Main's versions won both** and mine are
dropped: main threads an optional `activeColumns` from
`scheduler.ts:2340` (a cleaner seam than widening the store type to
resolve internally), and its `isAgentIdle` carries a greptile P1 fix
mine lacked — `columnsWithFlag` membership rather than first-per-role,
so a workflow declaring two wip lanes has both recognised. That is the
fourth time in this program main's version of a contested file was the
better one.

## Verification

- `pnpm test:gate` — 161 + 487 + 13 + 71, green
- `npx tsc -p packages/engine/tsconfig.json --noEmit` — clean
- `pnpm lint` — clean
- `--strict` exits 0


<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->

## Summary by CodeRabbit

* **Bug Fixes**
* Workflow-dependent task completion now recognizes custom lifecycle
columns, including renamed boards.
* Recovery requeues tasks to the configured destination and clears
branch details only from the appropriate work-in-progress column.
* Improved handling of completed tasks, orphaned work, shared worktrees,
and scope evaluation across custom workflows.
* Plugin completion hooks now trigger for any column configured as
complete.
* **Tests**
* Added coverage for renamed workflow columns and custom completion,
recovery, and rehoming behavior.
* **Documentation**
  * Clarified CLI state terminology in internal developer comments.

<!-- end of auto-generated comment: release notes by coderabbit.ai -->

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-30 11:28:54 -07:00

577 lines
22 KiB
TypeScript

/**
* CliSessionStateMachine — authoritative per-session agent state machine
* (CLI Agent Executor, U3).
*
* Implements the HTD state diagram exactly:
*
* [*] → starting
* starting → ready (readiness detected)
* ready → busy (prompt injected)
* busy → waitingOnInput (permission / question signal)
* waitingOnInput → busy (user answers)
* busy → idle (heuristic quiet-window — generic tier; NEVER done)
* idle → busy (output resumes)
* busy → done (POSITIVE completion signal — idle NEVER does this)
* done → busy (follow-up; resume first if the PTY was reaped)
* busy → dead (PTY end / engine death)
* waitingOnInput → dead (PTY end / engine death)
* dead → {killed|userExited|authFailed|resuming} (classification choice)
* resuming → busy (native resume ok)
* resuming → needsAttention (2 attempts exhausted)
* userExited → needsAttention (advance / retry / cancel prompt)
* authFailed → needsAttention (re-authenticate message)
*
* Key behaviors (KTD — completion gating, termination taxonomy, stall backstop):
* - Positive completion is distinct from idleness. `signalDone()` advances to
* `done`; output progress / idleness NEVER advance to done.
* - Stall backstop: no output progress past a configurable threshold WITHOUT a
* done/waiting signal → needsAttention. The inactivity watchdog is re-armed by
* any telemetry/output event (no fixed turn timeout); `waitingOnInput`
* suppresses it (expected idleness).
* - Termination classification helper maps the manner of a PTY end onto the
* taxonomy (killed / userExited / crashed / authFailed / engineDeath).
* - Resume attempt cap = 2 with backoff metadata; exhaustion → needsAttention.
* - Per-turn latches/budgets reset between turns (a new busy turn re-arms the
* completion latch so a second turn through one handler is tracked cleanly).
*
* Persistence + observability:
* - Every transition persists through `CliSessionStore.updateSession` (state +
* terminationReason + resumeAttempts written atomically by the store).
* - A throttled `onStateChange` callback is exposed for the SSE bridge to
* subscribe to later. This module NEVER imports dashboard code.
*/
import type {
CliAgentState,
CliAutonomyPosture,
CliSessionStore,
CliTerminationReason,
} from "@fusion/core";
// ── Public types ───────────────────────────────────────────────────────────
/**
* The machine's own state space. This is the U1 `CliAgentState` plus two
* transient sub-states that are NOT persisted store enums (U1's union has
* neither):
* - `"resuming"` (HTD) maps onto the persisted `dead` store state while the
* resume-eligible termination reason (crashed / engineDeath) carries the
* recovery intent.
* - `"idle"` (generic heuristic tier, U6) maps onto the persisted `busy` store
* state. The generic adapter has no native done signal; a quiet output window
* yields an "looks idle — confirm to advance" affordance (origin R20) WITHOUT
* advancing the pipeline. Persisting `busy` keeps the session honestly live —
* idle NEVER reaches `done`; resumed output flips back to `busy`.
*
* Surfaces that subscribe to `onStateChange` see the richer machine state so the
* SSE bridge can render "resuming…" / the idle confirm-advance affordance
* without a schema change.
*/
export type CliMachineState = CliAgentState | "resuming" | "idle";
/** Map a machine state onto the persisted U1 store enum. */
export function toPersistedState(state: CliMachineState): CliAgentState {
if (state === "resuming") return "dead";
if (state === "idle") return "busy";
return state;
}
/** A throttled state-change notification handed to subscribers (e.g. the SSE bridge). */
export interface CliStateChange {
sessionId: string;
/** The machine state moved into (may be the transient `resuming`). */
state: CliMachineState;
/** Termination reason when relevant (set on dead-classification transitions). */
terminationReason: CliTerminationReason | null;
/** Resume attempt count at the time of the change. */
resumeAttempts: number;
/** Backoff (ms) to wait before the next resume attempt, when resuming. */
resumeBackoffMs?: number;
/** ISO timestamp of the change. */
at: string;
}
export type CliStateChangeListener = (change: CliStateChange) => void;
/**
* How a PTY ended, as observed by the manager / restart sweep. Fed into the
* classification helper to derive the termination taxonomy.
*/
export interface CliProcessEndInfo {
/** Whether the engine itself died and found the session dead on restart. */
foundDeadOnRestart?: boolean;
/** Whether the end was a deliberate hard cancel (SIGKILL-from-cancel). */
cancelled?: boolean;
/** Process exit code (0 = clean). Undefined when killed by signal. */
exitCode?: number | null;
/** Signal that terminated the process, if any (e.g. "SIGKILL"). */
signal?: string | number | null;
/**
* Recent (ANSI-stripped) output, scanned for a credential-failure pattern.
* Supplied by the caller (the hub strips ANSI before pattern matching).
*/
recentOutput?: string;
/** Whether the session had observed a positive `done` before the end. */
hadDone?: boolean;
}
export interface CliStateMachineOptions {
sessionId: string;
store: CliSessionStore;
/** Autonomy posture (supplies maxResumeAttempts override). */
posture?: CliAutonomyPosture | null;
/**
* Inactivity / stall threshold (ms). If no output progress and no done/waiting
* signal arrives within this window of a busy turn, the backstop fires
* (needsAttention). Default 5 minutes.
*/
stallThresholdMs?: number;
/** Max resume attempts before giving up. Default 2 (KTD). */
maxResumeAttempts?: number;
/** Base backoff (ms) for resume attempts; doubled per attempt. Default 1000. */
resumeBackoffBaseMs?: number;
/** Throttle window (ms) for `onStateChange`. Default 0 (emit every change). */
stateChangeThrottleMs?: number;
/** Clock injection for tests. */
now?: () => number;
/**
* Timer scheduler injection for tests (fake timers). Returns a cancel handle.
* Defaults to setTimeout/clearTimeout.
*/
setTimer?: (fn: () => void, ms: number) => unknown;
clearTimer?: (handle: unknown) => void;
}
/**
* Default credential-failure detector. Scans (already ANSI-stripped) recent
* output for common auth-rejection phrasing.
*/
const AUTH_FAILURE_PATTERN =
/\b(authentication failed|invalid api key|unauthorized|401 unauthorized|not authenticated|please (?:re-?)?(?:login|log in|authenticate)|credential[s]? (?:rejected|invalid|expired)|your session has expired|token (?:expired|invalid|revoked))\b/i;
/** Detect a credential-failure pattern in recent (ANSI-stripped) output. */
export function looksLikeAuthFailure(recentOutput: string | undefined): boolean {
if (!recentOutput) return false;
return AUTH_FAILURE_PATTERN.test(recentOutput);
}
/**
* Classify a PTY end onto the termination taxonomy (KTD). Pure — no side effects.
*
* - found-dead-on-restart → engineDeath
* - SIGKILL-from-cancel / hard cancel → killed
* - credential-failure in recent output → authFailed
* - clean exit-0 mid-task (no done) → userExited
* - nonzero exit / killed by signal → crashed
* - any exit AFTER a positive done → completed
*/
export function classifyTermination(info: CliProcessEndInfo): CliTerminationReason {
if (info.foundDeadOnRestart) return "engineDeath";
if (info.cancelled) return "killed";
if (looksLikeAuthFailure(info.recentOutput)) return "authFailed";
if (info.hadDone) return "completed";
// Killed by a signal (no clean exit) → crashed.
if (info.signal != null && info.signal !== 0) return "crashed";
if (info.exitCode === 0) return "userExited";
// Any nonzero / unknown exit code → crashed.
return "crashed";
}
/** Resume-eligible termination reasons (KTD): only crash / engine death auto-resume. */
export function isResumeEligible(reason: CliTerminationReason): boolean {
return reason === "crashed" || reason === "engineDeath";
}
/** Error thrown when a transition is attempted from an incompatible state. */
export class InvalidCliTransitionError extends Error {
readonly code = "INVALID_CLI_TRANSITION";
constructor(
public readonly from: CliMachineState,
public readonly intent: string,
) {
super(`Invalid CLI session transition: cannot ${intent} from state "${from}"`);
this.name = "InvalidCliTransitionError";
}
}
// ── State machine ──────────────────────────────────────────────────────────
export class CliSessionStateMachine {
readonly sessionId: string;
private readonly store: CliSessionStore;
private readonly stallThresholdMs: number;
private readonly maxResumeAttempts: number;
private readonly resumeBackoffBaseMs: number;
private readonly throttleMs: number;
private readonly now: () => number;
private readonly setTimer: (fn: () => void, ms: number) => unknown;
private readonly clearTimer: (handle: unknown) => void;
private state: CliMachineState;
private terminationReason: CliTerminationReason | null = null;
private resumeAttempts = 0;
/** Per-turn latch: has a positive done fired in the current busy turn. */
private doneLatched = false;
/** Per-turn latch: has waiting-on-input fired in the current busy turn. */
private waitingLatched = false;
private stallTimer: unknown = null;
private listeners = new Set<CliStateChangeListener>();
// Throttle bookkeeping.
private lastEmitAt = 0;
private pendingEmit: CliStateChange | null = null;
private throttleTimer: unknown = null;
constructor(opts: CliStateMachineOptions) {
this.sessionId = opts.sessionId;
this.store = opts.store;
this.stallThresholdMs = opts.stallThresholdMs ?? 5 * 60_000;
this.maxResumeAttempts =
opts.maxResumeAttempts ??
(typeof opts.posture?.maxResumeAttempts === "number"
? opts.posture.maxResumeAttempts
: 2);
this.resumeBackoffBaseMs = opts.resumeBackoffBaseMs ?? 1000;
this.throttleMs = opts.stateChangeThrottleMs ?? 0;
this.now = opts.now ?? (() => Date.now());
this.setTimer =
opts.setTimer ?? ((fn, ms) => setTimeout(fn, ms) as unknown);
this.clearTimer =
opts.clearTimer ?? ((h) => clearTimeout(h as ReturnType<typeof setTimeout>));
// Seed from the persisted record so a rebuilt machine reflects reality.
const existing = this.store.getSession(this.sessionId);
this.state = existing?.agentState ?? "starting";
this.terminationReason = existing?.terminationReason ?? null;
this.resumeAttempts = existing?.resumeAttempts ?? 0;
}
// ── Observation ──────────────────────────────────────────────────────────
getState(): CliMachineState {
return this.state;
}
getTerminationReason(): CliTerminationReason | null {
return this.terminationReason;
}
getResumeAttempts(): number {
return this.resumeAttempts;
}
/** Subscribe to throttled state changes. Returns an unsubscribe handle. */
onStateChange(listener: CliStateChangeListener): () => void {
this.listeners.add(listener);
return () => this.listeners.delete(listener);
}
// ── Transitions (HTD diagram) ────────────────────────────────────────────
/** starting → ready (readiness detected). */
markReady(): void {
if (this.state !== "starting") {
throw new InvalidCliTransitionError(this.state, "markReady");
}
this.transition("ready");
}
/**
* ready → busy (prompt injected) and done → busy (follow-up).
* Begins a new turn: per-turn latches reset, stall watchdog armed.
*/
injectPrompt(): void {
if (this.state !== "ready" && this.state !== "done" && this.state !== "resuming") {
throw new InvalidCliTransitionError(this.state, "injectPrompt");
}
this.beginTurn();
this.transition("busy");
}
/*
FNXC:CliAgentStateMachine 2026-07-30-12:20 DELIBERATE-LITERAL:
`done` here is a `CliMachineState`, NOT a lifecycle column — this machine tracks one CLI agent
process (`ready`/`busy`/`waitingOnInput`/`done`/`resuming`/`idle`) and never reads a board column.
The lifecycle-column census matches the bare string and counted it; converting it to a workflow role
would be nonsense, so it is marked rather than left to be "fixed" by a later sweep.
*/
/** done → busy (follow-up). Alias for injectPrompt from the done state. */
followUp(): void {
if (this.state !== "done") {
throw new InvalidCliTransitionError(this.state, "followUp");
}
this.beginTurn();
this.transition("busy");
}
/**
* Output progress / activity. Re-arms the inactivity watchdog. NEVER advances
* state — idleness and activity are both gated away from `done`.
*
* From the generic-tier `idle` sub-state, fresh output means the agent resumed
* work → flip back to `busy` (the confirm-advance affordance is withdrawn).
*/
signalOutputProgress(): void {
if (this.state === "idle") {
this.transition("busy");
this.armStallWatchdog();
return;
}
if (this.state === "busy") {
this.armStallWatchdog();
}
}
/**
* busy → idle (generic heuristic quiet-window). The generic tier has no native
* done signal; a quiet output window past the configured threshold surfaces an
* "looks idle — confirm to advance" affordance (origin R20). This NEVER
* advances to `done` and persists as `busy` (the session stays honestly live).
* Clears the stall watchdog: a detected idle is expected quiet, so it must not
* also trip the stall backstop into needsAttention. Idempotent. From any state
* other than `busy` it is a no-op (idle is only meaningful mid-turn).
*/
signalIdle(): void {
if (this.state === "idle") return; // idempotent
if (this.state !== "busy") return; // only busy turns can go idle
this.clearStallWatchdog();
this.transition("idle");
}
/**
* busy → waitingOnInput (permission / question signal). Suppresses the stall
* watchdog (expected idleness). Does NOT advance the pipeline or fail.
*/
signalWaitingOnInput(): void {
if (this.state === "waitingOnInput") return; // idempotent
if (this.state !== "busy") {
throw new InvalidCliTransitionError(this.state, "signalWaitingOnInput");
}
this.waitingLatched = true;
this.clearStallWatchdog();
this.transition("waitingOnInput");
}
/** waitingOnInput → busy (user answered) and idle → busy (output resumed). */
signalBusy(): void {
if (this.state === "busy") {
this.armStallWatchdog();
return;
}
if (this.state !== "waitingOnInput" && this.state !== "idle") {
throw new InvalidCliTransitionError(this.state, "signalBusy");
}
this.armStallWatchdog();
this.transition("busy");
}
/**
* busy → done (POSITIVE completion signal). This is the ONLY path to `done`.
* Idle / output progress never reach here.
*/
signalDone(): void {
/* DELIBERATE-LITERAL — `CliMachineState`, not a board column. See followUp() above. */
if (this.state === "done") return; // idempotent
if (this.state !== "busy" && this.state !== "waitingOnInput") {
throw new InvalidCliTransitionError(this.state, "signalDone");
}
this.doneLatched = true;
this.clearStallWatchdog();
this.transition("done", "completed");
}
/**
* busy/waitingOnInput → dead, then classify. Provide the observed end info;
* the taxonomy is derived by `classifyTermination`. After classification:
* - killed / userExited / authFailed → terminal (userExited/authFailed will be
* surfaced as needsAttention by the caller's escalation, but the recorded
* reason stays precise — `escalateToNeedsAttention` moves the state).
* - crashed / engineDeath → `resuming` (caller drives resume attempts).
* - completed → done.
*
* @returns the classified termination reason.
*/
processEnded(info: CliProcessEndInfo): CliTerminationReason {
// dead is reachable from any active state.
this.clearStallWatchdog();
const reason = classifyTermination({ ...info, hadDone: info.hadDone ?? this.doneLatched });
this.terminationReason = reason;
if (reason === "completed") {
this.transition("done", "completed");
return reason;
}
if (isResumeEligible(reason)) {
this.transition("resuming", reason);
return reason;
}
// killed / userExited / authFailed are recorded on a `dead` landing; the
// diagram's killed → [*] is terminal, while userExited / authFailed escalate
// to needsAttention via escalateToNeedsAttention().
this.transition("dead", reason);
return reason;
}
/**
* Record a resume attempt result.
* - success → busy (a fresh turn).
* - failure → another `resuming` with backoff, until the cap (2) is hit, then
* needsAttention. The third attempt is never made.
*/
recordResumeResult(success: boolean): void {
if (this.state !== "resuming") {
throw new InvalidCliTransitionError(this.state, "recordResumeResult");
}
if (success) {
this.resumeAttempts = 0;
this.beginTurn();
this.transition("busy");
return;
}
this.resumeAttempts += 1;
if (this.resumeAttempts >= this.maxResumeAttempts) {
this.transition("needsAttention");
return;
}
// Stay in resuming with backoff metadata so the coordinator schedules a retry.
const backoff = this.resumeBackoffBaseMs * 2 ** (this.resumeAttempts - 1);
this.persistAndEmit("resuming", this.terminationReason, backoff);
}
/** Backoff (ms) the coordinator should wait before the next resume attempt. */
nextResumeBackoffMs(): number {
return this.resumeBackoffBaseMs * 2 ** this.resumeAttempts;
}
/**
* Escalate the current dead/auth/userExit landing to needsAttention (the
* userExited → needsAttention and authFailed → needsAttention edges). The
* recorded terminationReason is preserved.
*/
escalateToNeedsAttention(): void {
if (
this.state !== "dead" &&
this.state !== "resuming" &&
this.state !== "busy" &&
this.state !== "waitingOnInput"
) {
throw new InvalidCliTransitionError(this.state, "escalateToNeedsAttention");
}
this.clearStallWatchdog();
this.transition("needsAttention");
}
/** Force-dispose: cancel timers and drop listeners. */
dispose(): void {
this.clearStallWatchdog();
if (this.throttleTimer != null) {
this.clearTimer(this.throttleTimer);
this.throttleTimer = null;
}
this.listeners.clear();
}
// ── Per-turn latches / stall watchdog ────────────────────────────────────
private beginTurn(): void {
// Reset per-turn latches and budgets between turns (KTD).
this.doneLatched = false;
this.waitingLatched = false;
this.armStallWatchdog();
}
private armStallWatchdog(): void {
this.clearStallWatchdog();
this.stallTimer = this.setTimer(() => {
this.onStall();
}, this.stallThresholdMs);
}
private clearStallWatchdog(): void {
if (this.stallTimer != null) {
this.clearTimer(this.stallTimer);
this.stallTimer = null;
}
}
/**
* Stall backstop: quiet busy turn past the threshold with no done/waiting
* signal → needsAttention. Never fires from waitingOnInput (cleared) and never
* from a streaming session (re-armed by output progress).
*/
private onStall(): void {
this.stallTimer = null;
if (this.state !== "busy") return;
if (this.doneLatched || this.waitingLatched) return;
this.transition("needsAttention");
}
// ── Persistence + throttled emit ─────────────────────────────────────────
private transition(next: CliMachineState, reason?: CliTerminationReason): void {
this.state = next;
if (reason !== undefined) this.terminationReason = reason;
if (next === "busy" || next === "ready" || next === "idle") {
// Live again (idle persists as busy): clear any stale termination reason.
this.terminationReason = null;
}
this.persistAndEmit(next, this.terminationReason);
}
private persistAndEmit(
next: CliMachineState,
reason: CliTerminationReason | null,
resumeBackoffMs?: number,
): void {
// Persist the U1 store enum (resuming → dead); the machine state and the
// resume-eligible reason carry the recovery intent for surfaces.
this.store.updateSession(this.sessionId, {
agentState: toPersistedState(next),
terminationReason: reason,
resumeAttempts: this.resumeAttempts,
});
const change: CliStateChange = {
sessionId: this.sessionId,
state: next,
terminationReason: reason,
resumeAttempts: this.resumeAttempts,
...(resumeBackoffMs !== undefined ? { resumeBackoffMs } : {}),
at: new Date(this.now()).toISOString(),
};
this.emitThrottled(change);
}
private emitThrottled(change: CliStateChange): void {
if (this.throttleMs <= 0) {
this.deliver(change);
return;
}
// Leading-edge: when no throttle window is open, deliver immediately and
// open a window. Subsequent changes within the window coalesce into a single
// trailing emit of the latest change at the window edge.
if (this.throttleTimer == null) {
this.deliver(change);
this.throttleTimer = this.setTimer(() => {
this.throttleTimer = null;
if (this.pendingEmit) {
const pending = this.pendingEmit;
this.pendingEmit = null;
this.deliver(pending);
}
}, this.throttleMs);
return;
}
// Within an open window → coalesce (keep only the latest).
this.pendingEmit = change;
}
private deliver(change: CliStateChange): void {
this.lastEmitAt = this.now();
for (const listener of this.listeners) {
listener(change);
}
}
}