AGENTS.md@src/websocket/listener · git:20260710.c98c9d1 · 2026-07-10 · sha256 15747bf711f596a5
AGENTS.md@src/websocket/listener git:20260710.c98c9d1A
Immutable. This exact content is served forever at /api/v1/blob/15747bf711f596a5.
# Listener State Machine Guide This directory owns local listener orchestration. Read this file before changing turns, approvals, cancellation, recovery, queue gating, or status projection. ## Canonical Ownership `TurnLifecycle` in `turn-lifecycle.ts` is the only owner of active-turn state. `ConversationRuntime` exposes read-only projections for compatibility: - `isProcessing` - `cancelRequested` - `loopStatus` - `activeWorkingDirectory` - `activeRunId` - `lastStopReason` Never add setters or parallel flags for these values. Add a lifecycle transition when a new state is genuinely required. The lifecycle states are complete and mutually exclusive: - `idle`: no local owner; queue work may start. - `command`: a synchronous command owns the conversation. - `active`: a message or approval-recovery turn owns the conversation. - `cancelling`: the active lease was aborted; UI projects idle, but the queue remains blocked until that lease settles. ## Lease Rule Every active turn has a `TurnLease`. Async state changes and side effects must carry that exact lease. Never look up `currentLease` after an await and assume it still belongs to the caller. Check `isCurrent(lease)` immediately after awaited execution boundaries, before mutating runtime state or emitting tool, protocol, file, or channel events. Capture run IDs and other turn-local context before the await; do not read them from a replacement runtime afterward. A current cancelling lease may emit normalized interrupt results. A stale lease must emit nothing. `clearConversationRuntimeState()` is an authoritative local reset. It aborts and invalidates the current lease, returns the lifecycle to `idle`, and clears conversation-scoped transient state. Code unwinding after reset must treat its lease as stale and must not repopulate state. Explicit `abort_message` is different: it calls `requestCancellation()`, which moves `active -> cancelling`. The turn owner later completes `cancelling -> idle`. Do not clear cancellation because a late approval response arrived. ## Terminal Rule The enclosing turn owner finalizes every lease exactly once through `finishListenerTurn()`. Terminal helpers may prepare an outcome, but they must not silently finalize shared runtime state. Use discriminated results: - Approval branch: `continue | interrupted | terminal | error` - Approval send: `stream | terminal` Do not reintroduce `terminated: boolean`, sentinel `null`, or a result whose caller must guess whether another layer already finalized the turn. `requires_approval` is a continuation boundary, not a terminal turn. A pending approval remains inside the same active lease. ## Queue And Status Queue gating consumes `turnLifecycle.snapshot()`. It must not reconstruct activity from a boolean chain. Protocol device/loop status reads the read-only runtime projections derived from that same lifecycle. Do not add queue self-healing as the primary fix for an impossible state. Find and repair the transition that produced the state. Defensive telemetry is fine after the producer path has a regression test. ## Module Map - `turn-lifecycle.ts`: canonical state, leases, and transitions. - `turn.ts`: one-turn orchestration and the stream stop-reason loop. - `turn-setup.ts`: input normalization, reminders, mod start, and tool context. - `turn-send.ts`: initial/retry send selection and recovered terminal results. - `turn-approval.ts`: live approval execution and continuation branching. - `turn-events.ts`: mod lifecycle events and reflection launch wiring. - `turn-completion.ts`: successful turn-end persistence and reflection work. - `turn-terminal.ts`: exact-once terminal projection. - `turn-status.ts`: loop-status transitions and emission. - `turn-cleanup.ts`: post-terminal persistence and memory sync. - `turn-context.ts`: guarded process-context ownership release. - `turn-transcript.ts`: pure inbound transcript/telemetry helpers. - `recovery.ts`: stale and process-restart approval recovery. - `queue.ts`: queue ingestion and lifecycle-snapshot gating. - `inbound-dispatch.ts`: serialized direct-message ownership handoff. - `inbound-queue.ts`: lossless inbound-message queue registration. ## Investigation Checklist When logs show contradictory state: 1. Separate what logs prove from the proposed event sequence. 2. Trace the owner that acquired the lease and every return/catch/finally that can release it. 3. Search every lifecycle transition and inspect `git blame` plus `git log -S` when adjacent code assumes different contracts. 4. Reproduce the production transition sequence. Do not begin a regression test by manually constructing the impossible aftermath. 5. Verify the test fails on the buggy base and passes because the producer was fixed, without relying on queue self-healing. ## Test Ownership - `turn-lifecycle.test.ts`: pure transition table, exact-once, stale leases. - `turn-lifecycle-integration.test.ts`: cross-module producer regressions. - `approval.test.ts`: approval wait/resolution/cancellation ownership. - `recovery-lease.test.ts`: recovered approval await/lease boundaries. - `send-lease.test.ts`: pre-stream recovery await/queue boundaries. - `message-router.test.ts`: direct-message ownership handoff and queue drain. - `listener-queue-adapter.test.ts`: queue decisions from valid snapshots. - `channel-turn-session.test.ts`: channel progress lifecycle fan-out. Keep new tests next to their owner and below 1,000 lines. Do not grow the legacy protocol/concurrency test monoliths; move focused coverage here instead.