diff --git a/docs/plans/2026-07-08-amplifier-session-durability-plan.md b/docs/plans/2026-07-08-amplifier-session-durability-plan.md new file mode 100644 index 00000000..3058fd1e --- /dev/null +++ b/docs/plans/2026-07-08-amplifier-session-durability-plan.md @@ -0,0 +1,416 @@ +# Amplifier Session Durability — Final Remediation Plan + +**Date:** 2026-07-08 +**Status:** FINAL — approved for implementation (Phase 0 complete) +**Supersedes:** the 2026-07-05 amplifier title/recency fix pass (kept) and the draft remediation plan (revised here) + +--- + +## 1. Context + +Amplifier is the only coding-CLI provider in freshell with **none** of the codebase's three proven durability patterns: + +1. **No provider-native end-of-turn signal.** Claude has the Stop-hook BEL; codex has the app-server protocol; opencode has its event stream. Amplifier's turn lifecycle is *inferred from PTY output timing*: 2s of output silence after Enter = "turn complete" (`server/coding-cli/amplifier-activity-tracker.ts:13`), with a 120s deadman that **fabricates** `turn.complete` events (`amplifier-activity-tracker.ts:8,210-235`). Slow tool calls → false idle; spinners → stuck busy. +2. **No launcher-assigned session ID.** PTY↔session association relies on the cwd + 30s-recency + single-candidate heuristic (`server/session-association-coordinator.ts:58-78`, `ASSOCIATION_MAX_AGE_MS` at `server/index.ts:166`). Two launches in the same cwd = permanent ambiguity — the exact failure class from the OpenCode ownership RCA (`docs/plans/2026-05-09-fix-opencode-ambiguous-ownership.md`), where zero of 21 affected terminals ever recovered. +3. **Assorted mtime/byte-sniffing fragilities** in the indexer path (bounded but heuristic). + +**The core finding from research:** Amplifier already ships a durable signal freshell ignores. Every session writes a **schema-versioned** event log — `~/.amplifier/projects//sessions//events.jsonl`, schema `amplifier.log` ver `1.0.0` — carrying `session:start`, `session:config`, `prompt:submit`, `prompt:complete`, `session:resume`, `session:end`, with `session_id` on every record. This plan replaces the timing heuristics with that contract, reusing freshell's existing cross-provider plumbing wherever it exists (a hard requirement — the DRY survey confirmed the WS broadcast pipe, association coordinator/controller pattern, and watcher stack are all generic and reusable). + +MCP availability is **not** assumed anywhere in this plan; everything reads Amplifier's on-disk contract. `amplifierd` (localhost REST/SSE daemon) exists upstream but is optional, immature, and not required. + +--- + +## 2. Phase 0 findings (settled contract facts) + +Phase 0 (live experiments against `amplifier 2026.07.06-7ec5dcd`, core 1.6.0, driven by a real-PTY harness) is **DONE**. These are now treated as contract facts, not hypotheses. Raw captures live at `/tmp/amp-p0/` and `~/.amplifier/projects/-tmp-amp-p0-*/`; they must be converted into checked-in test fixtures during Phase 1 (`test/fixtures/coding-cli/amplifier/events/`). + +| # | Finding | Consequence for design | +|---|---------|------------------------| +| E1/E8 | **Session dirs are created LAZILY** — dir + `events.jsonl` appear ~37ms after the **first prompt submit**, not at process spawn. An idle REPL has *nothing* on disk. Dir birth order tracks first-prompt order and can invert spawn order. | The locator correlates **PTY Enter-press ↔ new session dir**, not spawn ↔ dir. The watch must persist until first submit (or terminal exit), not any fixed window. An idle never-prompted terminal has no session to bind — **that is correct behavior, not a failure**. | +| E4 | `session:start` carries only `parent_id`. The **`session:config`** record (~10ms later) carries `data.raw.project_dir` / `working_dir` / `project_slug`. `metadata.json` appears only at first `prompt:complete` (~seconds later). | Use `session:config` for cwd confirmation. `metadata.json` demotes to a later consistency check. | +| E2/E3 | **`prompt:complete` is the single turn boundary.** Exactly one `execution:start`/`execution:end` pair per turn; tool loops are `provider:request` iterations *inside* it. But background session-naming `llm:request` / `provider:retry` events fire **after** `prompt:complete`. | Reducer must never interpret post-complete events as a new turn. **Only `prompt:submit` re-enters busy.** | +| E5 | **Ctrl+C writes NO events** and (via scripted PTY) cancels nothing. Mid-turn typing becomes queued steering (`orchestrator:steering_injected`) within the **same** turn — one `prompt:submit`/`prompt:complete` pair. Empty-Enter at idle writes zero events. | Enter-presses during busy must NOT re-arm anything. Keep the submit-grace reversion (provisional busy reverts if no `prompt:submit` record follows). PTY exit remains the authoritative end. | +| E3 (surprise 6) | `events.jsonl` is **not strictly time-ordered near shutdown**. | Reducer is a state machine keyed on **event type**, never on timestamps. | +| E6 | `kill -9` before first `prompt:complete` leaves a dir containing **only `events.jsonl`** — `metadata.json` never appears. | Indexer policy needed for metadata-less dirs (§7). They are not resumable. | +| E7 | PTY hangup lets amplifier finish the turn and write `session:end`. `continue`-attach-then-quit writes `session:end` with **no matching start/resume in that pass**. `resume` appends `session:resume` to the **same file**. | Reducer tolerates unbalanced lifecycle records. Tailer attaches at EOF for resume (confirmed correct design). | +| E9 | **No `--session-id` flag exists** anywhere. `session list --format json` truncates IDs even in JSON mode. | Both refutations are hard facts. Keep the upstream asks (§12); do not design around features that don't exist. | +| E10 | All 208 observed records were schema `amplifier.log` / `1.0.0`. | Schema-version gate: accept major version 1; anything else → degraded lane + single warn. | +| — | Sub-session dirs contain only `events.jsonl`, have `_` in the dir name, and `session:fork`/`parent_id` marks forks. | Locator ignores underscore-named dirs AND applies the same `isSubagent` guard the coordinator uses (`session-association-coordinator.ts:90`). | +| — | Observed volume ~450KB `events.jsonl` per turn, dominated by `content_block:*` / `tool:pre/post` noise; `llm:request` embeds full raw payloads. Append-only; no rotation observed. | Tailer must be offset-based/incremental, filter before parse, and never re-read. | + +--- + +## 3. Goals / Non-Goals + +**Goals** + +1. Turn lifecycle (busy/idle, `turn.complete`) driven by Amplifier's own `events.jsonl` lifecycle records — no fabricated completions. +2. Deterministic PTY↔session association for fresh amplifier sessions via first-prompt correlation, terminating in the shared bind/broadcast path. +3. Keep the current timing heuristic **verbatim** as a degraded lane (pre-first-prompt, tailer failure, schema mismatch). No new `unknown` phase — the public phase model stays `'idle' | 'busy'`. +4. Zero churn in downstream consumers: `amplifier-activity-wiring.ts`, `ws-handler.ts`, `amplifierActivitySlice`, `pane-activity.ts` untouched. No new WS message family. +5. Consolidate the byte-for-byte quadruplicated turn-completion machinery across all four trackers. + +**Non-Goals** + +- No dependency on `amplifierd`, MCP, or any network endpoint. +- No parsing of `content_block:*` / `tool:pre/post` noise for streaming/rendering (out of scope). +- No changes to codex/opencode/claude association behavior (only mechanical ledger extraction in Phase 4). +- No attempt to pre-assign session IDs (E9: impossible today; upstream ask filed). + +--- + +## 4. Target architecture + +| Concern | Today (heuristic) | Target (durable) | Reused infrastructure | +|---|---|---|---| +| Turn end | 2s PTY output-silence debounce; 120s deadman **fabricates** completion (`amplifier-activity-tracker.ts:150-235`) | `prompt:complete` record from `events.jsonl` (events lane); timing heuristic verbatim as degraded lane; deadman repurposed as missed-signal force-read failsafe | Existing tracker public surface → `amplifier-activity-wiring.ts` → `index.ts:554-562` → `wsHandler.broadcastTerminalTurnComplete` (`ws-handler.ts:3814`, schema `shared/ws-protocol.ts:189`) — all unchanged | +| Turn start | PTY Enter (`noteInput` + `isSubmitInput`) | `prompt:submit` record; PTY Enter kept as provisional busy with grace reversion | Same | +| PTY↔session bind (fresh) | cwd + 30s recency + single-candidate (`session-association-coordinator.ts:58-78`) | Enter-press ↔ new-dir correlation + `session:config` cwd confirm, via a controller into `registry.bindSession(...)` + `broadcastTerminalSessionAssociation(...)` | `OpencodeSessionController` pattern (`opencode-session-controller.ts`), `session-association-broadcast.ts`, coordinator slow-path kept as fallback | +| PTY↔session bind (resume) | `resumeSessionId` at spawn (works) | Unchanged; tailer attaches at EOF of existing `events.jsonl` | `terminal.session.bound` event (already handled in wiring:47-55) | +| Fast path | Claude-only (`index.ts:916`) | Accept `'amplifier'` too (~5 lines) — free latency win and safety net | `associationCoordinator.noteSession/associateSingleSession` already handles amplifier correctly (watermarks, ambiguity, `isSubagent` guards) | +| Events file location | n/a (ignored) | `getLiveEventsPath?(filePath)` provider capability, next to `getActivityMtimeMs` (`provider.ts:30`, `providers/amplifier.ts:183` already encodes the sidecar layout) | `CodingCliProvider` optional-capability pattern | +| Reducer testability | n/a | Pure reducer, fixture-driven | Imitates `opencode-ownership-reducer.ts` | +| Watcher hygiene | n/a | One shared chokidar watcher for the locator | `session-indexer.ts` conventions: `ignoreInitial: true` (:501), nearest-existing-ancestor for missing dirs, unref'd timers, `close().catch(() => {})` (:537) | + +**New modules (the only genuinely new code):** + +``` +server/coding-cli/amplifier-events-reducer.ts # PURE: (state, record) -> (state, effects[]) +server/coding-cli/amplifier-events-tailer.ts # offset-based incremental reader, injected fsImpl +server/coding-cli/amplifier-session-locator.ts # shared watcher + Enter↔dir correlation +server/coding-cli/amplifier-session-controller.ts # thin bind gatekeeper (imitates OpencodeSessionController) +server/coding-cli/amplifier-activity-integration.ts # composition root (imitates opencode-activity-integration.ts) +server/coding-cli/turn-completion-ledger.ts # Phase 4 consolidation +``` + +--- + +## 5. Association protocol (fresh sessions) + +**Invariant:** all bindings flow through `registry.bindSession(terminalId, 'amplifier', sessionId, 'association')` and `broadcastTerminalSessionAssociation({ source: 'amplifier_locator' })`. Never hand-roll binding or broadcasts. (`AssociationBroadcastSource` union at `session-association-broadcast.ts:9` gains `'amplifier_locator'` and `'amplifier_new_session'`.) + +**Protocol** (one shared `AmplifierSessionLocator` service; its single chokidar watcher runs only while ≥1 unbound amplifier terminal exists): + +1. **Arm at spawn.** When an amplifier-mode terminal starts *without* `resumeSessionId`, register it with the locator: `{ terminalId, cwd, spawnedAt }`. Take a **pre-spawn snapshot** of existing top-level session dirs (no `_` in name) under `/projects/` to define "new". Watch `projects/` (nearest existing ancestor if absent — the sessions root itself is created lazily, E1). +2. **Watch until first submit or exit.** The 60s window from the draft is **wrong** (E1): an unprompted REPL writes nothing, ever. The watch persists until the terminal's first submit produces a binding, or the terminal exits. Cost: one watcher total, entries per unbound terminal. **An idle never-prompted terminal simply has no session to bind — correct behavior.** +3. **Correlate on Enter.** On PTY submit (`isSubmitInput`) for a registered terminal at time *t*, open a correlation window `[t, t + AMPLIFIER_DIR_APPEAR_WINDOW_MS]` (constant = 2_000ms; observed latency 37ms, E1). A **candidate** is a new top-level dir, not in the snapshot, no `_` in name, whose `events.jsonl` opens with `session:start` **without** `parent_id` (isSubagent guard, matching `session-association-coordinator.ts:90`). +4. **Confirm cwd via `session:config`** (E4): read the record's `data.raw.project_dir`/`working_dir` and require match with the terminal's cwd (realpath-normalized). Do **not** wait for `metadata.json`; do **not** guess project slugs. `metadata.json`, when it appears, is a consistency check only (mismatch → log warn, keep binding). +5. **Resolve.** Exactly one candidate matching cwd → hand to `AmplifierSessionController`, which validates the terminal (exists, `mode === 'amplifier'`, `status === 'running'` — mirroring `opencode-session-controller.ts:85-101`), then binds + broadcasts. Multiple candidates matching the same terminal's cwd within the window → **refuse** (log `amplifier_locator_ambiguous`), leave it to the coordinator slow-path. Zero candidates → keep watching (the submit may have been empty-Enter, which writes nothing, E5). +6. **Post-bind:** locator unregisters the terminal, tracker `bindSession` fires via the existing `terminal.session.bound` path, and the tailer attaches to `/events.jsonl` at offset 0 (fresh) — events lane activates. +7. **Resume path:** unchanged; the integration attaches the tailer at **EOF** of the existing `events.jsonl` (E7 confirms `session:resume` appends to the same file) using `provider.getLiveEventsPath(metadataPath)`. +8. **Fast path extension:** `index.ts:916` changes from `if (session.provider !== 'claude') return` to accept `'amplifier'` as well (broadcast `source: 'amplifier_new_session'`). This is a safety net that fires when `metadata.json` lands (first `prompt:complete`) if the locator somehow missed; the coordinator's watermark/ambiguity/`isSubagent` guards already handle amplifier correctly. + +--- + +## 6. Tracker state machine + +> **2026-07-08:** feature flag and degraded timing lane removed by maintainer +> decision — single code path; sessions without events.jsonl get no busy/turn +> signal. This supersedes §3 goal 3 and every "lane"/flag mention elsewhere in +> this plan (kept below only as historical record of the original design). + +`AmplifierActivityTracker`'s **public surface is frozen**: `list` / `getActivity` / `listLatestCompletions` / `trackTerminal` / `bindSession` / `noteInput` / `noteOutput` / `noteExit` / `expire` / `dispose` + `'changed'` / `'turn.complete'` / `'events.force-read'` events, public phase `'idle' | 'busy'`. The integration module feeds it via `applyLifecycle(terminalId, reducedEffect)` and `noteEventsSignalLost(terminalId)`. Downstream (`amplifier-activity-wiring.ts`, `ws-handler`, `amplifierActivitySlice`, `pane-activity.ts`) is untouched; no new WS message family. + +### Per-terminal state + +``` +phase: 'idle' | 'busy' // public +submitGrace: timer | undefined // provisional busy awaiting prompt:submit +busyConfirmed: boolean // prompt:submit record confirmed the busy phase +lastObservedAt // feeds the deadman force-read failsafe +``` + +### Single events-driven path (no lanes) + +There is exactly one state machine. `prompt:submit` / `prompt:complete` / +`session:end` records from `events.jsonl` are the only turn boundaries. PTY +Enter is only a provisional busy (submit-grace with one force-read retry, then +a silent revert); PTY output only refreshes liveness; PTY exit removes state. + +**Signal-loss policy:** when the tailer degrades (schema mismatch — the +`amplifier.log` major-version-1 gate, E10 — file reset, persistent read +errors, attach failure) or detaches, there is **no fallback to timing +heuristics**. The terminal's phase reverts to idle silently (no +`turn.complete`), the single structured `amplifier_events_lane_degraded` warn +is logged, and tracking stops: from then on the terminal only ever shows the +2s provisional-busy pulses from submit-grace. The same holds for terminals +whose session never produces an `events.jsonl` (bundle without the +hooks-logging module): they simply never get confirmed busy or +`turn.complete` — acceptable, documented behavior. + +### Events (inputs) and transitions + +| Current | Input | Next | Effects / notes | +|---|---|---|---| +| idle | PTY submit (`noteInput`) | busy (provisional) | Arm `submitGrace` (= `AMPLIFIER_SUBMIT_GRACE_MS`, 2_000ms). Empty-Enter writes zero events (E5), so: | +| busy (provisional) | `submitGrace` expiry, no `prompt:submit` seen | idle | **Silent reversion** — no `turn.complete`. Validated by E5; keep. | +| any | `prompt:submit` record | busy (confirmed) | Clear `submitGrace`. **The only input that (re)enters busy.** | +| busy | `prompt:complete` record | idle | Emit exactly one `turn.complete` (via ledger). **The single turn boundary** (E2/E3). | +| idle | post-complete `llm:request` / `provider:retry` / any non-`prompt:submit` record | idle | Ignored — background session-naming fires after completion (E2). Never a new turn. | +| busy | PTY submit | busy | **No re-arm of anything** — mid-turn typing is queued steering within the same turn (E5). `orchestrator:steering_injected` is informational only. | +| busy | PTY output (`noteOutput`) | busy | Updates `lastObservedAt` only. Idle-debounce timer is **never armed** in events lane. | +| busy | `session:end` record | idle | Emit `turn.complete` (turn ended by quit/hangup; E7 shows amplifier finishes the turn on PTY hangup). Tolerate `session:end` with no matching start/resume (E7 continue-attach case). | +| any | `session:resume` record | idle (no change) | Resume does not imply busy. Same-file append; tailer already at EOF. | +| any | PTY Ctrl+C | (no change) | Writes no events, cancels nothing (E5). Not a lifecycle input. | +| any | `noteExit` (PTY exit) | state removed | **PTY exit is the authoritative end** — unconditional, both lanes. Tailer for that terminal is closed by the integration. | +| busy | deadman sweep (`expire`, silent ≥120s in file growth AND PTY output) | (see effect) | **Missed-signal failsafe ONLY:** trigger a **force-read** of the tail (stat + manual incremental read — the WSL2 inotify backstop; we run on WSL2 where inotify on 9p/drvfs paths can silently drop). If the read surfaces `prompt:complete`/`session:end` → process normally. If not → **stay busy** (genuine long turn) and log once. **Never fabricates a completion.** | +| any | tailer error / schema mismatch / file reset (size < offset) / detach | idle (silent) | Signal loss (`noteEventsSignalLost`): single warn (`amplifier_events_lane_degraded`, with reason), phase → idle with **no** `turn.complete`, tracking stops. No timing fallback. | + +**Out-of-order tolerance (E3):** the reducer keys transitions on event **type** only; timestamps are carried through for `updatedAt`/`at` fields but never used to order or gate transitions. Unbalanced records (orphan `session:end`, missing `session:start` on continue-attach) are legal inputs. + +**Reducer purity:** `amplifier-events-reducer.ts` is a pure function `(ReducerState, ParsedRecord) → { state, effects }` with effects like `{ kind: 'turn.began' } | { kind: 'turn.completed' } | { kind: 'session.identified', sessionId, cwd } | { kind: 'lane.degrade', reason }` — imitating `opencode-ownership-reducer.ts` so the entire E1–E10 catalog becomes a fixture-driven unit test suite. + +**Tailer contract** (`amplifier-events-tailer.ts`): remembers a byte offset per file; on watcher change (or force-read), reads only appended bytes; buffers a partial trailing line until completed; cheap substring pre-filter (`'"event":"session:'`, `'"event":"prompt:'`, `'"event":"execution:'`, `'"event":"orchestrator:steering'`) before `JSON.parse` so ~450KB/turn of `content_block:*`/`tool:*` noise (Phase 0 observation) is skipped without parsing; validates the schema header once per file; injected `fsImpl` for tests (à la `codex-app-server/durability-proof.ts`); `size < offset` ⇒ treat as file reset → `lane.degrade` (rotation was never observed, but we refuse to guess). + +--- + +## 7. Provider capability and indexer policy + +**Provider capability** — add to `CodingCliProvider` (`server/coding-cli/provider.ts`, next to `getActivityMtimeMs?` at :30): + +```ts +/** Absolute path of the live lifecycle event log sibling to the given canonical + * session file, if this provider maintains one. Enables event-driven activity + * tracking without hardcoding sidecar layouts outside the provider. */ +getLiveEventsPath?(filePath: string): string | undefined +``` + +`providers/amplifier.ts` implements it as `path.join(path.dirname(filePath), 'events.jsonl')` — the same sidecar knowledge `getActivityMtimeMs` (:183-201) already encodes. The tracker/integration never hardcode paths; the locator (which discovers dirs before `metadata.json` exists) constructs the path from the discovered dir, which is inherently amplifier-layout-aware and lives in the amplifier-specific locator module — acceptable. + +**Indexer policy for metadata-less dirs (E6):** dirs containing only `events.jsonl` (kill -9 before first `prompt:complete`) never gain `metadata.json` and are **not resumable**. Policy: **the indexer skips them** — which is already the emergent behavior, since discovery keys on `metadata.json` (`providers/amplifier.ts:168-170,203-210`). We now make that explicit and intentional (comment in the provider + test). Live visibility while such a session is running comes from the *activity* pipeline instead: the locator binds the sessionId to the live terminal, so busy/idle and turn-complete work from first submit; the sidebar entry appears naturally when `metadata.json` lands at first `prompt:complete`. If the process dies before that, the dir is dead weight and correctly invisible. + +**Feature flag:** removed 2026-07-08 (maintainer decision — see §6 note). The events tracking, the locator/controller, and the fast-path extension are unconditional; there is no `FRESHELL_AMPLIFIER_EVENTS_TRACKING` environment variable and no degraded timing lane to revert to. + +--- + +## 8. File-level change list + +**New** + +| File | Contents | +|---|---| +| `server/coding-cli/amplifier-events-reducer.ts` | Pure reducer: state machine of §6, schema gate, effect emission | +| `server/coding-cli/amplifier-events-tailer.ts` | Offset-based incremental reader; injected `fsImpl`; pre-filter; force-read entry point | +| `server/coding-cli/amplifier-session-locator.ts` | Shared chokidar watcher (session-indexer hygiene), pre-spawn snapshots, Enter↔dir correlation, `session:config` cwd confirm, underscore/`parent_id` guards | +| `server/coding-cli/amplifier-session-controller.ts` | Validates terminal (mode/status), calls `registry.bindSession`, emits `associated`; imitates `opencode-session-controller.ts` | +| `server/coding-cli/amplifier-activity-integration.ts` | Composition: tracker + tailer + locator + controller lifecycles; attaches tailer on bind (fresh: offset 0 with catch-up state-sync; resume: EOF); per-terminal attach serialization; closes on exit | +| `server/coding-cli/turn-completion-ledger.ts` | Phase 4: extracted `completionSeqByTerminalId` + `latestCompletions` + `recordTurnCompletion` + `listLatestCompletions` | +| `server/coding-cli/activity-wiring-factory.ts` | Phase 4 wiring unification: shared `wirePtyActivityTracker` (registry→tracker PTY-signal plumbing), parameterized by mode, sweep interval, and tracker disposal | + +**Modified** + +| File | Change | +|---|---| +| `server/coding-cli/amplifier-activity-tracker.ts` | Single events-driven state machine (`applyLifecycle`/`noteEventsSignalLost`); deadman = force-read request only, never fabricates; timing-heuristic code deleted (2026-07-08); public surface frozen | +| `server/coding-cli/provider.ts` | `getLiveEventsPath?` capability (:30 vicinity) | +| `server/coding-cli/providers/amplifier.ts` | Implement `getLiveEventsPath`; explicit metadata-less-dir policy comment | +| `server/session-association-broadcast.ts` | `AssociationBroadcastSource` (:9) += `'amplifier_locator' \| 'amplifier_new_session'` | +| `server/index.ts` | Fast path (:916) accepts `'amplifier'` (~5 lines, source `'amplifier_new_session'`); construct/wire the integration next to `opencodeActivity` wiring (:533-577 vicinity); flag plumb; `associated` → `broadcastTerminalSessionAssociation` (mirror of :563-577) | +| `server/coding-cli/{claude,codex,opencode,amplifier}-activity-tracker.ts` | Phase 4 only: adopt `TurnCompletionLedger` (claude :169-185, codex :463-479, opencode :654-670, amplifier :188-204 — byte-for-byte duplicates today) | +| `server/coding-cli/claude-activity-wiring.ts` | Phase 4 wiring unification: delegates to `wirePtyActivityTracker` (behavior-preserving) | +| `server/coding-cli/amplifier-activity-wiring.ts` | Phase 4 wiring unification: delegates to `wirePtyActivityTracker` (behavior-preserving; tracker disposal clears per-terminal timers) | +| `server/session-observability.ts` | Type-only change: `session_association_broadcast.source` uses the exported `AssociationBroadcastSource` union from `session-association-broadcast.ts` (dedupe) | + +**Untouched (by design):** `server/ws-handler.ts`, `shared/ws-protocol.ts`, client `amplifierActivitySlice` / `pane-activity.ts`, `session-association-coordinator.ts`. (`amplifier-activity-wiring.ts` was originally in this list, but was ultimately unified in Phase 4 -- see the Modified table above, which supersedes the earlier "untouched" intent.) + +**Deferred (noted, not scheduled):** unifying the near-identical claude/amplifier wiring modules — do it opportunistically in Phase 4 *only if* it falls out trivially from the ledger adoption; otherwise leave for a future mechanical-consolidation pass. + +--- + +## 9. Phases + +### Phase 0 — Empirical contract validation ✅ DONE +Findings settled in §2. Deliverable remaining: convert `/tmp/amp-p0/` captures and `~/.amplifier/projects/-tmp-amp-p0-*/` trees into `test/fixtures/coding-cli/amplifier/events/` during Phase 1 (they include: a full normal turn, steering-injection turn, kill -9 orphan, PTY-hangup completion, continue-attach `session:end` orphan, resume append, out-of-order shutdown tail). + +### Phase 1 — Events contract core (pure, no wiring) +Reducer + tailer + fixtures. No behavior change in the running app. + +**Success criteria** +- Reducer fixture suite covers every §6 transition, including: post-complete naming events (no re-busy), steering injection (single submit/complete pair), orphan `session:end`, out-of-order tail, schema-mismatch degrade, empty-file, `session:fork`/`parent_id` records ignored. +- Tailer tests (injected `fsImpl`): partial trailing line buffering, EOF-attach for resume, appended-bytes-only reads, pre-filter skips noise records without parsing, `size < offset` → degrade, force-read path. +- `npm test` green; zero changes under `server/` wiring. + +### Phase 2 — Events-driven tracker + integration +Tracker becomes the single events-driven state machine; integration composes tailer→reducer→tracker; `index.ts` wires it unconditionally. (Originally shipped as a flagged two-lane design; flag and degraded lane removed 2026-07-08 — see §6 note.) + +**Success criteria** +- Tracker/wiring tests cover the events-driven semantics (the old timing-heuristic tests were deleted with the behavior). +- Extended `test/server/ws-amplifier-activity.test.ts` (existing harness): busy on `prompt:submit`; exactly one `terminal.turn.complete` (correct `completionSeq`) on `prompt:complete`; a simulated 10-minute silent tool call produces **no** fabricated completion; post-complete naming events do not re-busy; submit-grace reversion on empty-Enter; tailer error mid-turn → phase reverts to idle with **no** `turn.complete` + single warn. +- Deadman force-read test: suppress watcher events (WSL2 simulation), confirm completion is recovered by force-read, and confirm a genuinely-busy session stays busy. + +### Phase 3 — Locator + association +Locator, controller, broadcast-source extension, fast-path extension. + +**Success criteria** +- Unit (mkdtemp + `AMPLIFIER_HOME` + `utimes`, per `amplifier-provider.test.ts` pattern): two same-cwd terminals, prompted in inverted order vs. spawn order → both bind correctly by Enter-correlation (E8 inversion case); never-prompted terminal → never bound, watcher entry cleaned on exit, no errors; ambiguous double-first-prompt within 2s window → refused + logged, slow-path coordinator still eligible; underscore dirs and `parent_id` starts ignored; cwd mismatch via `session:config` → candidate rejected. +- Resume terminals: locator never arms; tailer attaches at EOF; binding via existing `terminal.session.bound`. +- Fast path: amplifier `metadata.json` discovery binds a still-unbound terminal (locator-missed simulation) through `associateSingleSession`, broadcast source `'amplifier_new_session'`. +- Chokidar hygiene verified: `ignoreInitial: true`, nearest-existing-ancestor watch when `projects/` absent, unref'd timers, `close().catch(() => {})` on dispose. + +### Phase 4 — Cross-provider consolidation (mechanical, behavior-preserving) +Extract `TurnCompletionLedger`; adopt in all four trackers. + +**Success criteria** +- Snapshot tests: `listLatestCompletions()` output and `completionSeq` sequences byte-identical before/after for scripted turn sequences on each tracker. +- Diff review confirms deletion of the four duplicate blocks; no public-surface change; all existing tracker suites green. +- Claude/amplifier wiring unification attempted; merged only if the diff is trivially reviewable, else a `TODO` note referencing this plan. + +### Phase 5 — Rollout & cleanup +- 2026-07-08: the feature flag AND the degraded timing lane were removed ahead of soak by maintainer decision — single code path, no revert lever. Signal loss ⇒ idle-and-stop (§6 signal-loss policy); sessions without events.jsonl get no busy/turn signal. +- Soak monitoring stays: watch `amplifier_events_lane_degraded`, `amplifier_locator_ambiguous`, `amplifier_events_lane_suspect`, and deadman-force-read log rates. +- `docs/plans/ACTIVITY_TRACKING_SPEC.md` updated with the single-path amplifier design; file/refresh the upstream issues (§12). + +### Adversarial review round (post-build hardening) + +Three adversarial agents (concurrency red-team with reproduced probes, spec +auditor, design reviewer) pressure-tested the implementation. Findings fixed: + +- **A** Locator could recursively watch `$HOME` when `/projects/` + was absent (nearest-existing-ancestor walk) → projects/ is now pre-created + (`mkdir -p`) and watched directly at fixed depth; a persistent watcher error + self-disables the locator with a single warn. +- **B** Concurrent `attachTailer` calls in one bind cascade leaked the first + watcher and double-pumped the file → attach/detach are serialized per + terminal (promise chain). +- **C** Offset-0 attach on `'association'` binds replayed finished history as + live turns → catch-up state-sync: the initial drain adopts the reducer's + final phase once and suppresses `turn.complete` emissions; backlogs over + `AMPLIFIER_CATCHUP_MAX_BYTES` skip catch-up and attach at EOF. +- **D** Silent permanent-idle when inotify drops the `prompt:submit` change + event → the first submit-grace expiry issues a force-read and extends the + grace once; only the second expiry reverts. Three consecutive reversions log + `amplifier_events_lane_suspect` once (soak signal; never auto-degrades). +- **E** Tailer memory unbounded → partial-line buffer capped + (`AMPLIFIER_TAILER_PARTIAL_MAX_BYTES`, oversized lines dropped to the next + newline without degrading); reads batched + (`AMPLIFIER_TAILER_READ_BATCH_MAX_BYTES`). +- **F** Correlation accepted dirs created up to `windowMs` before the Enter → + lower bound tightened to `AMPLIFIER_DIR_PRE_EPSILON_MS` (jitter allowance + only), restoring the §5 `[t, t+2000]` intent. +- **G** Lane-degrade racing an in-flight read dropped an already-read + `turn.completed` → the tracker honors exactly one transitional completion + after `disableEventsLane` on a busy terminal (cleared by any new + degraded-lane submit). +- **H** Locator now arms running unbound amplifier terminals that existed + before construction (registry catch-up sweep, mirroring the integration). +- **I** `probe_timeout` rejections re-probe on later `events.jsonl` changes + (permanent rejection only for definitive classifications). +- **J** Zero-candidates-at-window-close performs a one-shot readdir + snapshot-diff of `projects/*/sessions/*` (chokidar initial-scan blind spot). +- **K–O** `AssociationBroadcastSource` deduped (exported from + `session-association-broadcast.ts`); stale comments/dead fields removed; + locator test flake hardening (deterministic waits); this plan updated; the + synthesized `pty-hangup-completes.jsonl` fixture + reducer test added. + +Re-verification round (probe-driven) found three residual items plus one +quirk; all fixed: + +- **N1** (P2, probe-reproduced) The finding-J zero-candidate rescan anchored + unseen dirs at `window.openedAt`, auto-satisfying the finding-F eligibility + bounds — with an inert watcher, a foreign same-cwd session dir created + before the Enter press got bound (the exact wrong-binding class this + feature eliminates) → rescanned dirs are now anchored at their + `fs.stat` birthtime (mtime fallback when birthtime is 0/unavailable); + dirs whose stat time predates `openedAt − AMPLIFIER_DIR_PRE_EPSILON_MS` + are rejected, and a stat failure rejects the dir (refuse-to-guess; the + coordinator slow-path remains). +- **N2** (P3) A synchronously-throwing `watchImpl` (e.g. chokidar ENOSPC) + inside `doAttach` rejected the serialized chain promise, escaping the + `void attachTailer(...)` call sites as an unhandled rejection (process + crash on Node ≥15) → `doAttach` wraps its body in try/catch: partial + state is cleaned up, the lane degrades via + `disableEventsLane(terminalId, 'attach_error')` with a single structured + warn, and the next attach for the same terminal works normally. +- **N3** (P3, cosmetic) The per-terminal `attachChains` serialization map + never shrank (one settled-promise entry per terminalId forever) → + mirror-delete when the settled chain tail is still the stored tail; + `getAttachChainCount()` exposed for leak assertions (bind→exit cycles + return the map to 0 entries). +- **Q1** (quirk) Catch-up adoption of an in-flight turn used the historical + `prompt:submit` record ts for liveness, so a >120s-old adopted turn + tripped an instant deadman force-read right after bind → the adoption + `turn.began` `at` is clamped to max(recordTs, attachTime). Liveness + bookkeeping only; turn-boundary/completion-ledger semantics unchanged + (only `turn.completed` reaches the ledger). + +Deferred (noted, not scheduled): sticky-degrade retry for the `read_error` +class (a transient stat failure currently degrades the lane for the rest of +the turn-set); `metadata.json` late consistency warn (§5 step 4 mismatch +check); real-CLI smoke extension (bind + busy + single turn-complete against +the live binary); converting the raw E7 PTY-hangup capture to replace the +synthesized fixture. + +--- + +## 10. Risks + +| Risk | Likelihood | Impact | Mitigation | +|---|---|---|---| +| Upstream schema change (`amplifier.log` ver ≠ 1.x) — README warns "breaking changes happen" | Medium | Events tracking unusable | Version gate → signal-loss policy (§6): idle-and-stop + single warn; worst case = no busy/turn signal for that terminal | +| WSL2 inotify drops events on the sessions tree | Medium (we run on WSL2) | Stuck busy | Deadman repurposed as force-read (stat + manual incremental read every sweep past 120s silence); recovers missed `prompt:complete` without fabricating | +| Two same-cwd terminals first-prompted within the 2s window | Low | Wrong/no binding | cwd confirm via `session:config` first; residual ambiguity → refuse + coordinator slow-path (watermarked, single-candidate) — never guess | +| `events.jsonl` volume (~450KB/turn, raw LLM payloads inside) | High (normal) | CPU/IO cost | Offset-based appended-bytes reads; substring pre-filter before parse; one tailer per *bound live terminal* only, closed on exit | +| Log truncation/rotation appears upstream later | Low (never observed) | Tailer confusion | `size < offset` ⇒ signal loss (idle-and-stop) + warn; no guessing | +| Locator watcher lifetime bugs (leaks) | Low | fd leaks | Single shared watcher, active only while ≥1 unbound terminal; unregister on bind/exit; disposal test asserts closed | +| Sessions without events.jsonl (bundle lacks the hooks-logging module) | Low | No busy/turn signal for those terminals | Accepted by design (2026-07-08 decision): submit-grace pulses only; documented in §6 signal-loss policy | +| Fast-path extension destabilizes claude path | Very low | Association regressions | Change is a provider-set widening only; coordinator guards unchanged; covered by existing coordinator tests + new amplifier fast-path test | +| Metadata-less orphan dirs accumulate | Certain (E6) | Cosmetic disk noise | Explicitly out of scope to clean; documented policy (§7); not indexed, not resumable | + +--- + +## 11. Test plan + +**Unit (vitest, `test/unit/server/coding-cli/`)** +- `amplifier-events-reducer.test.ts` — fixture-driven, one fixture per Phase 0 capture (E1–E10 shapes) plus synthetic: out-of-order tail, orphan `session:end`, schema-mismatch, fork/underscore records, empty-Enter no-op, steering-injected same-turn. +- `amplifier-events-tailer.test.ts` — injected `fsImpl` (pattern: `codex-app-server/durability-proof.ts`): partial lines, EOF attach, append-only reads, pre-filter, reset detection, force-read. +- `amplifier-session-locator.test.ts` — mkdtemp + `AMPLIFIER_HOME` + `utimes` (pattern: `amplifier-provider.test.ts`): snapshot semantics, correlation window, cwd confirm, ambiguity refusal, subagent guards, watcher lifetime, spawn-order inversion (E8). +- `amplifier-session-controller.test.ts` — mode/status validation, bind result handling, reject paths (pattern: opencode controller tests). +- `amplifier-activity-tracker.test.ts` — events-driven semantics: submit-grace reversion (with the one-time force-read retry), lifecycle-record turn boundaries, deadman force-read request, PTY-exit authority, signal-loss idle-and-stop. (The pre-existing timing-heuristic cases were deleted with the behavior, 2026-07-08.) +- `turn-completion-ledger.test.ts` + before/after snapshot tests on all four trackers (Phase 4). + +**Integration (`test/server/`)** +- `ws-amplifier-activity.test.ts` extended (existing WS harness): full events-driven turn over the wire (`terminal.turn.complete` with provider-scoped `completionSeq` per `shared/ws-protocol.ts:189`); tailer failure mid-turn → idle with no `turn.complete` + single warn; no new WS message types asserted. +- Association end-to-end: spawn-fake → Enter → dir appears (fixture writer) → `terminal.session.associated` broadcast with `source: 'amplifier_locator'`; fast-path variant with `metadata.json` drop-in. + +**Real-CLI smoke (`test/integration/real/`, gated on amplifier binary presence)** +- Extend `amplifier-launch-smoke.test.ts`: launch, prompt, assert bind + busy + single turn-complete against the real CLI; resume a session and assert EOF-attach (no historical replay of completions). + +**Manual acceptance (WSL2)** +- Two panes, same cwd, prompt second-spawned first → both bind correctly. +- Long tool call (>2 min silent) → stays busy, completes exactly once. +- Ctrl+C mid-turn → no state flap; PTY kill → pane cleans up. +- Idle pane left unprompted for an hour → no binding, no watcher errors, no log spam. + +--- + +## 12. Upstream asks (microsoft/amplifier-app-cli) + +Now grounded in hard refutations (E9) — file as issues, link back here: + +1. `--session-id ` (or env var) on `amplifier` / `amplifier run` for launcher-assigned session identity — would delete the locator entirely. +2. `session list --format json` should emit **full** session IDs (JSON output currently truncates them). +3. Document `events.jsonl` (`amplifier.log` schema) as a stable, versioned integration surface; commit to major-version discipline. +4. Emit a lifecycle event on user interrupt (Ctrl+C currently writes nothing, E5). +5. (Nice-to-have) Write `metadata.json` (or a minimal identity stub) at session creation rather than first `prompt:complete`, eliminating the metadata-less orphan class (E6). + +--- + +## Appendix A — Constants + +| Constant | Value | Basis | +|---|---|---| +| `AMPLIFIER_DIR_APPEAR_WINDOW_MS` | 2_000 | Observed 37ms (E1); 50× margin | +| `AMPLIFIER_DIR_PRE_EPSILON_MS` | 250 | Clock-jitter/event-reorder allowance only — dirs meaningfully older than the Enter are foreign sessions (adversarial finding F) | +| `AMPLIFIER_SUBMIT_GRACE_MS` | 2_000 | Empty-Enter writes nothing (E5); provisional busy must revert. First expiry force-reads + extends once (finding D); second expiry reverts | +| `AMPLIFIER_GRACE_REVERSION_SUSPECT_THRESHOLD` | 3 | Consecutive silent reversions before the single `amplifier_events_lane_suspect` warn (finding D; soak signal, never auto-degrades) | +| `AMPLIFIER_BUSY_DEADMAN_MS` | 120_000 (existing) | Force-read trigger ONLY (missed-signal failsafe); never fabricates a completion | +| `AMPLIFIER_CATCHUP_MAX_BYTES` | 4 MiB | Offset-0 attach backlog cap (finding C): larger backlogs attach at EOF; observed events files reach hundreds of MB | +| `AMPLIFIER_TAILER_PARTIAL_MAX_BYTES` | 8 MiB | Partial-line buffer cap (finding E): oversized `llm:request` lines are dropped to the next newline, never degrading | +| `AMPLIFIER_TAILER_READ_BATCH_MAX_BYTES` | 16 MiB | Single read-batch cap (finding E): no `Buffer.concat` scales with file size | +| Schema gate | `amplifier.log`, major ver 1 | 208/208 observed records (E10) | + +## Appendix B — Phase 0 artifact locations + +- PTY harness + timelines: `/tmp/amp-p0/` (incl. `ampdrv.py`) +- Live capture trees: `~/.amplifier/projects/-tmp-amp-p0-*/sessions/*/` +- Convert to: `test/fixtures/coding-cli/amplifier/events/` in Phase 1 (scrub any raw LLM payloads from `llm:request` records before check-in). diff --git a/docs/plans/ACTIVITY_TRACKING_SPEC.md b/docs/plans/ACTIVITY_TRACKING_SPEC.md index 958f7543..27256bc8 100644 --- a/docs/plans/ACTIVITY_TRACKING_SPEC.md +++ b/docs/plans/ACTIVITY_TRACKING_SPEC.md @@ -1,5 +1,75 @@ # Terminal Activity Tracking Feature Specification +> **Note (2026-07):** Sections below under "Historical: client-side activity +> indicators" describe a client-side feature from Jan 2026 that was rolled +> back. Current activity tracking is **server-authoritative**, per provider — +> see [Server-side coding-CLI activity tracking](#server-side-coding-cli-activity-tracking-2026-07) +> for the current design, including Amplifier's events-driven tracker. + +## Server-side coding-CLI activity tracking (2026-07) + +Each coding-CLI provider has a server-side activity tracker keyed by +`terminalId` (`server/coding-cli/*-activity-tracker.ts`) that derives turn +lifecycle (busy/idle + `turn.complete` events) from provider-native signals: + +| Provider | Turn-start signal | Turn-end signal | +|---|---|---| +| claude | PTY submit (Enter) | Stop-hook BEL in PTY output | +| codex | PTY submit / app-server `onTurnStarted` | app-server `onTurnCompleted` / BEL / JSONL reconcile (deduped per turn) | +| opencode | SSE `session.status: busy` | SSE `session.idle` | +| amplifier | see events-driven design below | see events-driven design below | + +All four trackers share a `TurnCompletionLedger` +(`server/coding-cli/turn-completion-ledger.ts`): a per-terminal monotonic +`completionSeq` plus the latest `TerminalTurnCompletionSnapshot` +(`shared/ws-protocol.ts`), surfaced via each tracker's +`listLatestCompletions()`. Ledger state is intentionally never cleared on +terminal removal, so the sequence stays monotonic across re-tracks and +late-attaching clients still receive the last completion. + +### Amplifier events-driven design (single path) + +Implemented per `docs/plans/2026-07-08-amplifier-session-durability-plan.md` +(§6). 2026-07-08: the feature flag (`FRESHELL_AMPLIFIER_EVENTS_TRACKING`) and +the degraded timing lane were removed by maintainer decision — single code +path; sessions without `events.jsonl` get no busy/turn signal. + +Amplifier writes a schema-versioned event log per session +(`~/.amplifier/projects//sessions//events.jsonl`, schema +`amplifier.log` ver 1.x) carrying `prompt:submit` / `prompt:complete` / +`session:end` lifecycle records. The tracker +(`server/coding-cli/amplifier-activity-tracker.ts`) runs one state machine per +terminal: + +- `prompt:submit` is the only input that (re)enters busy; `prompt:complete` is + the single turn boundary (exactly one `turn.complete` via the + `TurnCompletionLedger`); `session:end` also ends a busy turn. PTY Enter is + only *provisionally* busy with a 2s grace reversion (one force-read retry, + then a silent revert — empty-Enter writes no events); PTY output only + refreshes liveness. The 120s deadman **never fabricates a completion** — it + requests a force-read of the events tail (WSL2 inotify backstop) and stays + busy. PTY exit removes state unconditionally. +- **Signal loss** (tailer error, schema mismatch, file reset, attach failure, + detach): no timing fallback. The phase reverts to idle silently (no + `turn.complete`), a single `amplifier_events_lane_degraded` warn is logged, + and tracking stops — the terminal then only shows the 2s provisional-busy + pulses from submit-grace. Terminals whose session never produces an + `events.jsonl` (bundle without the hooks-logging module) behave the same: + never confirmed busy, never `turn.complete` — acceptable, documented + behavior. + +Composition: `amplifier-events-tailer` → `amplifier-events-reducer` (pure) → +`tracker.applyLifecycle()`, assembled by `amplifier-activity-integration.ts` +(which only attaches/detaches tailers). Fresh-session PTY↔session association +is handled by `amplifier-session-locator.ts` (first-prompt ↔ new-dir +correlation) + `amplifier-session-controller.ts`, with the coordinator slow +path and the indexer fast path (`source: 'amplifier_new_session'`) as +fallbacks. + +--- + +## Historical: client-side activity indicators (Jan 2026, rolled back) + This document captures the implementation of terminal activity tracking that was added between commits `f910fbf` and `9d9f9cc` (12 commits total, implemented Jan 31 2026). ## Feature Overview diff --git a/server/coding-cli/activity-wiring-factory.ts b/server/coding-cli/activity-wiring-factory.ts new file mode 100644 index 00000000..a5497bc3 --- /dev/null +++ b/server/coding-cli/activity-wiring-factory.ts @@ -0,0 +1,120 @@ +import type { + TerminalInputRawEvent, + TerminalOutputRawEvent, + TerminalSessionBoundEvent, +} from '../terminal-stream/registry-events.js' + +/** + * Shared registry→tracker wiring for PTY-signal-driven activity trackers + * (docs/plans/2026-07-08-amplifier-session-durability-plan.md §9 Phase 4: + * claude/amplifier wiring unification — the two modules were byte-identical + * except for the mode string, the sweep interval, and tracker disposal). + * + * Parameterized by: + * - `mode`: the terminal mode AND the `terminal.session.bound` provider string + * (identical for both current users: 'claude', 'amplifier'). + * - `sweepIntervalMs`: the tracker's expire() sweep cadence. + * - `disposeTracker`: optional per-tracker teardown run on dispose() (the + * amplifier tracker clears its per-terminal debounce/grace timers; the + * claude tracker has no timers to clear). + */ + +export type ActivityWiringTerminalSnapshot = { + terminalId: string + mode: string + status: string +} + +export type ActivityWiringRegistry = { + list: () => Array<{ terminalId: string }> + get: (terminalId: string) => ActivityWiringTerminalSnapshot | undefined | null + on: (event: string, handler: (...args: any[]) => void) => void + off: (event: string, handler: (...args: any[]) => void) => void +} + +export type PtyActivityTracker = { + trackTerminal(input: { terminalId: string; sessionId?: string; at: number }): void + bindSession(input: { terminalId: string; sessionId: string; at: number }): void + noteInput(input: { terminalId: string; data: string; at: number }): void + noteOutput(input: { terminalId: string; data: string; at: number }): void + noteExit(input: { terminalId: string }): void + expire(at: number): void +} + +export function wirePtyActivityTracker(input: { + mode: string + tracker: T + sweepIntervalMs: number + registry: ActivityWiringRegistry + now?: () => number + setIntervalFn?: typeof setInterval + clearIntervalFn?: typeof clearInterval + disposeTracker?: (tracker: T) => void +}): { tracker: T; dispose(): void } { + const { + mode, + tracker, + sweepIntervalMs, + registry, + now = () => Date.now(), + setIntervalFn = setInterval, + clearIntervalFn = clearInterval, + disposeTracker, + } = input + + const startTracking = (record: ActivityWiringTerminalSnapshot) => { + if (record.mode !== mode || record.status !== 'running') return + tracker.trackTerminal({ terminalId: record.terminalId, at: now() }) + } + + const onCreated = (record: ActivityWiringTerminalSnapshot) => { + startTracking(record) + } + const onBound = (event: TerminalSessionBoundEvent) => { + if (event.provider !== mode) return + // Production emits 'terminal.session.bound' BEFORE 'terminal.created', so a plain + // bindSession would be a no-op (no record yet) and drop the sessionId. Ensure the + // record exists with its sessionId first (trackTerminal is idempotent and updates + // the sessionId on an existing record); a later 'terminal.created' won't clobber it. + tracker.trackTerminal({ terminalId: event.terminalId, sessionId: event.sessionId, at: now() }) + tracker.bindSession({ terminalId: event.terminalId, sessionId: event.sessionId, at: now() }) + } + const onInput = (event: TerminalInputRawEvent) => { + tracker.noteInput({ terminalId: event.terminalId, data: event.data, at: event.at }) + } + const onOutput = (event: TerminalOutputRawEvent) => { + tracker.noteOutput({ terminalId: event.terminalId, data: event.data, at: event.at }) + } + const onExit = (event: { terminalId?: string }) => { + if (!event.terminalId) return + tracker.noteExit({ terminalId: event.terminalId }) + } + + registry.on('terminal.created', onCreated) + registry.on('terminal.session.bound', onBound) + registry.on('terminal.input.raw', onInput) + registry.on('terminal.output.raw', onOutput) + registry.on('terminal.exit', onExit) + + for (const listed of registry.list()) { + const record = registry.get(listed.terminalId) + if (record) startTracking(record) + } + + const sweepTimer = setIntervalFn(() => { + tracker.expire(now()) + }, sweepIntervalMs) + + return { + tracker, + dispose(): void { + registry.off('terminal.created', onCreated) + registry.off('terminal.session.bound', onBound) + registry.off('terminal.input.raw', onInput) + registry.off('terminal.output.raw', onOutput) + registry.off('terminal.exit', onExit) + clearIntervalFn(sweepTimer) + disposeTracker?.(tracker) + }, + } +} diff --git a/server/coding-cli/amplifier-activity-integration.ts b/server/coding-cli/amplifier-activity-integration.ts new file mode 100644 index 00000000..162f88c7 --- /dev/null +++ b/server/coding-cli/amplifier-activity-integration.ts @@ -0,0 +1,460 @@ +/** + * Composition root for Amplifier's events-driven activity tracking (plan + * docs/plans/2026-07-08-amplifier-session-durability-plan.md §6/§9 Phase 2). + * + * Composes, per bound amplifier terminal: an events.jsonl tailer (offset-based, + * Phase 1) → the pure reducer (Phase 1) → the tracker (applyLifecycle / + * noteEventsSignalLost). Imitates the composition style of + * `opencode-activity-integration.ts` but LAYERS on top of the frozen + * `amplifier-activity-wiring.ts` (which keeps feeding the tracker PTY signals) + * instead of replacing it. The integration attaches/detaches tailers — nothing + * else: when the tailer degrades (schema mismatch, file reset, persistent read + * errors, attach failure) the terminal's tracking reverts to idle-and-stop + * (tracker.noteEventsSignalLost) with a single 'amplifier_events_lane_degraded' + * warn. There is no timing-heuristic fallback (removed 2026-07-08). + * + * Reads are caller-driven (the tailer owns no watchers): a chokidar watch on the + * session dir (session-indexer hygiene: ignoreInitial, close().catch(() => {}); + * the dir is watched — not the file — because events.jsonl may not exist yet at + * attach time while the session dir does) plus the tracker's deadman force-read + * requests (WSL2 inotify backstop). + * + * Path discovery stays OUTSIDE this module: callers hand attachTailer() an + * explicit events path (Phase 3's locator) or provide resolveEventsPath (Phase 2: + * indexer file path + provider.getLiveEventsPath). + */ + +import path from 'node:path' +import fsp from 'node:fs/promises' +import chokidar from 'chokidar' +import { + createAmplifierReducerState, + reduceAmplifierEvent, + type AmplifierReducerState, +} from './amplifier-events-reducer.js' +import { + createAmplifierEventsTailer, + type AmplifierEventsTailer, + type AmplifierTailerFs, +} from './amplifier-events-tailer.js' +import type { AmplifierEventsForceReadRequest } from './amplifier-activity-tracker.js' +import type { AmplifierReducerEffect } from './amplifier-events-reducer.js' +import type { TerminalSessionBoundEvent } from '../terminal-stream/registry-events.js' + +/** + * Catch-up cap (adversarial finding C): an offset-0 attach whose backlog + * exceeds this many bytes skips the catch-up drain entirely and attaches at + * EOF instead (state stays idle; live records take over). Real events files + * reach hundreds of MB — replaying them at attach is never acceptable. + */ +export const AMPLIFIER_CATCHUP_MAX_BYTES = 4 * 1024 * 1024 + +export type AmplifierEventsWatcher = { + on(event: string, handler: (...args: any[]) => void): unknown + close(): Promise +} + +export type AmplifierEventsWatchFactory = ( + watchPath: string, + options: { ignoreInitial: boolean }, +) => AmplifierEventsWatcher + +type IntegrationLogger = { + warn: (payload: object, message?: string) => void +} + +type IntegrationTracker = { + trackTerminal(input: { terminalId: string; sessionId?: string; at: number }): void + noteEventsSignalLost(terminalId: string): void + applyLifecycle(terminalId: string, effect: AmplifierReducerEffect): void + on(event: string, handler: (...args: any[]) => void): unknown + off(event: string, handler: (...args: any[]) => void): unknown +} + +type IntegrationTerminalSnapshot = { + terminalId: string + mode: string + status: string + resumeSessionId?: string +} + +type IntegrationRegistry = { + list: () => Array<{ terminalId: string }> + get: (terminalId: string) => IntegrationTerminalSnapshot | undefined | null + on: (event: string, handler: (...args: any[]) => void) => void + off: (event: string, handler: (...args: any[]) => void) => void +} + +export type AmplifierActivityIntegrationInput = { + registry: IntegrationRegistry + tracker: IntegrationTracker + /** + * Resolve the live events.jsonl path for a bound session. Phase 2 wiring: + * indexer.getFilePathForSession(sessionId, 'amplifier') → + * provider.getLiveEventsPath(metadataPath). Returns undefined when the session + * is not indexed yet (fresh, metadata.json not yet written) — Phase 3's locator + * hands such sessions to attachTailer() with an explicit path instead. + */ + resolveEventsPath: (sessionId: string) => string | undefined + log?: IntegrationLogger + now?: () => number + /** Injected for tests; defaults to chokidar.watch. */ + watchImpl?: AmplifierEventsWatchFactory + /** Injected for tests; passed through to the tailer. */ + fsImpl?: AmplifierTailerFs +} + +export type AmplifierActivityIntegration = { + /** + * Attach (or re-attach) the events tailer for a terminal. Fresh sessions attach + * at offset 0; resume attaches at EOF (plan §5 steps 6-7). Path discovery is the + * caller's job — this keeps the integration layout-agnostic. + */ + attachTailer( + terminalId: string, + sessionId: string, + eventsPath: string, + attachAt: 'start' | 'eof', + ): Promise + dispose(): Promise + /** Diagnostics/tests (N3): live per-terminal serialization chain entries. */ + getAttachChainCount(): number +} + +type Attachment = { + terminalId: string + sessionId: string + eventsPath: string + tailer: AmplifierEventsTailer + watcher?: AmplifierEventsWatcher + reducerState: AmplifierReducerState + degraded: boolean + closed: boolean + // Finding C: true until the first drain after an offset-0 attach completes. + // Catch-up records adopt state through the reducer but must NOT re-emit + // turn.complete for turns that finished before the bind. + catchingUp: boolean + // `at` of the last suppressed turn.began, used to adopt a busy end-state once. + catchUpBeganAt?: string + // Wall-clock attach time: liveness floor for adopted catch-up state (an + // in-flight turn whose prompt:submit ts is >120s old must not trigger an + // instant deadman force-read right after bind — re-verification quirk). + attachedAt: number +} + +export function createAmplifierActivityIntegration( + input: AmplifierActivityIntegrationInput, +): AmplifierActivityIntegration { + const { + registry, + tracker, + resolveEventsPath, + log, + now = () => Date.now(), + watchImpl = (watchPath, options) => chokidar.watch(watchPath, options), + fsImpl, + } = input + + const attachments = new Map() + // Per-terminal attach/detach serialization (adversarial finding B): binds can + // cascade two attachTailer calls in the same tick (registry.bindSession → + // onBound, then controller 'associated' → index.ts). Without serialization + // both pass the `await detach()` gap and the second orphans the first's + // watcher, double-pumping the file. Imitates the tailer's own serialize(). + const attachChains = new Map>() + let disposed = false + const runSerialized = (terminalId: string, task: () => Promise): Promise => { + const prev = attachChains.get(terminalId) ?? Promise.resolve() + const next = prev.then(task, task) + const tail = next.then(() => {}, () => {}) + attachChains.set(terminalId, tail) + // N3: mirror-delete once the chain settles — otherwise the map grows one + // settled-promise entry per terminalId forever. A newer queued task will + // have replaced the stored tail, in which case this delete is skipped. + void tail.then(() => { + if (attachChains.get(terminalId) === tail) attachChains.delete(terminalId) + }) + return next + } + + // Existence/size probe only (never reads): fresh sessions create events.jsonl + // lazily (E1), so the initial catch-up read must not run against a missing + // file — the tailer would degrade on the stat error. Uses the injected fs in + // tests. Returns undefined when the file does not exist (yet). + const statSize = async (p: string): Promise => { + try { + const stat = await (fsImpl ? fsImpl.stat(p) : fsp.stat(p)) + return stat.size + } catch { + return undefined + } + } + + const degrade = (attachment: Attachment, reason: string, message?: string): void => { + if (attachment.degraded) return + attachment.degraded = true + // Signal-loss policy (plan §6, 2026-07-08): no timing fallback. The + // terminal reverts to idle silently and stays events-less from here on. + tracker.noteEventsSignalLost(attachment.terminalId) + log?.warn({ + component: 'amplifier-activity-integration', + event: 'amplifier_events_lane_degraded', + terminalId: attachment.terminalId, + sessionId: attachment.sessionId, + reason, + ...(message ? { message } : {}), + }, 'Amplifier events tracking degraded; terminal reverted to idle (no busy/turn signals until re-attach).') + const watcher = attachment.watcher + attachment.watcher = undefined + void watcher?.close().catch(() => {}) + } + + const pump = async (attachment: Attachment, kind: 'read' | 'force'): Promise => { + if (attachment.degraded || attachment.closed) return + const result = kind === 'force' + ? await attachment.tailer.forceRead() + : await attachment.tailer.read() + if (attachment.closed) return + if (!result.ok) { + degrade(attachment, result.reason, result.message) + return + } + // Catch-up state-sync (finding C): the FIRST drain after an offset-0 + // attach covers records already on disk — turns that finished before the + // bind. Their phase transitions are adopted through the reducer, but + // turn.began/turn.completed emissions are suppressed; the final phase is + // adopted exactly once below. Records read after catch-up are live. + const catchUp = attachment.catchingUp + for (const record of result.records) { + const reduced = reduceAmplifierEvent(attachment.reducerState, record) + attachment.reducerState = reduced.state + for (const effect of reduced.effects) { + if (effect.kind === 'lane.degrade') { + degrade(attachment, effect.reason) + return + } + if (catchUp && (effect.kind === 'turn.began' || effect.kind === 'turn.completed')) { + if (effect.kind === 'turn.began') attachment.catchUpBeganAt = effect.at + continue + } + tracker.applyLifecycle(attachment.terminalId, effect) + } + } + if (catchUp) { + attachment.catchingUp = false + if (attachment.reducerState.phase === 'busy') { + // Locator-fresh bind mid-turn: adopt the in-flight busy once; the live + // prompt:complete emits the single completion for this turn. The `at` + // is clamped to max(recordTs, attachTime) — liveness bookkeeping only + // (updatedAt/lastObservedAt feed the deadman): a stale in-flight turn + // must not look >120s silent the moment it is adopted. Completion- + // ledger `at` semantics are untouched (only turn.completed reaches it). + const recordAtMs = attachment.catchUpBeganAt ? Date.parse(attachment.catchUpBeganAt) : Number.NaN + const clampedAtMs = Math.max( + Number.isFinite(recordAtMs) ? recordAtMs : 0, + attachment.attachedAt, + ) + tracker.applyLifecycle(attachment.terminalId, { + kind: 'turn.began', + at: new Date(clampedAtMs).toISOString(), + }) + } + } + } + + const detach = async (terminalId: string): Promise => { + const attachment = attachments.get(terminalId) + if (!attachment) return + attachment.closed = true + attachments.delete(terminalId) + const watcher = attachment.watcher + attachment.watcher = undefined + await watcher?.close().catch(() => {}) + } + + const doAttach = async ( + terminalId: string, + sessionId: string, + eventsPath: string, + requestedAttachAt: 'start' | 'eof', + ): Promise => { + if (disposed) return + await detach(terminalId) + try { + await doAttachInner(terminalId, sessionId, eventsPath, requestedAttachAt) + } catch (error) { + // N2: a synchronous throw during setup (e.g. chokidar ENOSPC) must never + // escape the serialized chain as an unhandled rejection. Clean up any + // partial state and degrade this terminal (single warn via degrade()). + const message = error instanceof Error ? error.message : String(error) + const attachment = attachments.get(terminalId) + if (attachment) { + degrade(attachment, 'attach_error', message) + } else { + tracker.noteEventsSignalLost(terminalId) + log?.warn({ + component: 'amplifier-activity-integration', + event: 'amplifier_events_lane_degraded', + terminalId, + sessionId, + reason: 'attach_error', + message, + }, 'Amplifier events tracking degraded; terminal reverted to idle (no busy/turn signals until re-attach).') + } + } + } + + const doAttachInner = async ( + terminalId: string, + sessionId: string, + eventsPath: string, + requestedAttachAt: 'start' | 'eof', + ): Promise => { + let attachAt = requestedAttachAt + let fileExists = requestedAttachAt !== 'start' + if (requestedAttachAt === 'start') { + const size = await statSize(eventsPath) + fileExists = size !== undefined + if (size !== undefined && size > AMPLIFIER_CATCHUP_MAX_BYTES) { + // Finding C cap: never replay a huge backlog — attach at EOF instead. + attachAt = 'eof' + log?.warn({ + component: 'amplifier-activity-integration', + event: 'amplifier_events_catchup_skipped', + terminalId, + sessionId, + sizeBytes: size, + capBytes: AMPLIFIER_CATCHUP_MAX_BYTES, + }, 'Amplifier events backlog exceeds the catch-up cap; attaching at EOF (live records take over).') + } + } + + const attachment: Attachment = { + terminalId, + sessionId, + eventsPath, + tailer: createAmplifierEventsTailer({ + filePath: eventsPath, + attachAt, + ...(fsImpl ? { fsImpl } : {}), + }), + reducerState: createAmplifierReducerState(), + degraded: false, + closed: false, + // Only an offset-0 attach over an existing file has history to suppress; + // a not-yet-created file has zero records on disk, so its first read is + // live by definition. + catchingUp: attachAt === 'start' && fileExists, + attachedAt: now(), + } + attachments.set(terminalId, attachment) + + // Watch the session DIR (exists once the session exists — E1), not the file, + // so a not-yet-created events.jsonl is picked up on 'add'. ignoreInitial per + // session-indexer hygiene; the initial pump below covers pre-existing content. + const resolvedEventsPath = path.resolve(eventsPath) + const watcher = watchImpl(path.dirname(eventsPath), { ignoreInitial: true }) + const onFileEvent = (changedPath: unknown) => { + if (typeof changedPath !== 'string') return + if (path.resolve(changedPath) !== resolvedEventsPath) return + void pump(attachment, 'read') + } + watcher.on('add', onFileEvent) + watcher.on('change', onFileEvent) + watcher.on('error', (error: unknown) => { + degrade(attachment, 'watch_error', error instanceof Error ? error.message : String(error)) + }) + attachment.watcher = watcher + + const attached = await attachment.tailer.attach() + if (attachment.closed) return + if (!attached.ok) { + degrade(attachment, attached.reason, attached.message) + return + } + + // Idempotent: production emits 'terminal.session.bound' before + // 'terminal.created' (see amplifier-activity-wiring.ts), so ensure the + // tracker record exists before lifecycle effects arrive. + tracker.trackTerminal({ terminalId, sessionId, at: now() }) + + if (requestedAttachAt === 'start' && !fileExists) { + // events.jsonl may not exist yet (lazy creation, E1): leave the first read + // to the dir watcher's 'add' event instead of degrading on a stat error. + return + } + if (attachment.closed) return + + // Initial drain: offset-0 (fresh) runs the catch-up state-sync over existing + // records (finding C); EOF (resume / capped backlog) is a no-op read. + await pump(attachment, 'read') + } + + const attachTailer = ( + terminalId: string, + sessionId: string, + eventsPath: string, + attachAt: 'start' | 'eof', + ): Promise => runSerialized( + terminalId, + () => doAttach(terminalId, sessionId, eventsPath, attachAt), + ) + + const onBound = (event: TerminalSessionBoundEvent) => { + if (event.provider !== 'amplifier') return + const eventsPath = resolveEventsPath(event.sessionId) + if (!eventsPath) return + // Resume binds attach at EOF (E7: session:resume appends to the same file); + // everything else is a fresh session → offset 0 (plan §5 steps 6-7). + const attachAt = event.reason === 'resume' ? 'eof' : 'start' + void attachTailer(event.terminalId, event.sessionId, eventsPath, attachAt) + } + + const onExit = (event: { terminalId?: string }) => { + if (!event.terminalId) return + const terminalId = event.terminalId + // Serialized with attaches (finding B): an exit racing a queued attach must + // close whatever that attach creates, not just the current attachment. + void runSerialized(terminalId, () => detach(terminalId)) + } + + const onForceRead = (request: AmplifierEventsForceReadRequest) => { + const attachment = attachments.get(request.terminalId) + if (!attachment) return + void pump(attachment, 'force') + } + + registry.on('terminal.session.bound', onBound) + registry.on('terminal.exit', onExit) + tracker.on('events.force-read', onForceRead) + + // Construction-order catch-up: amplifier terminals that were created and + // session-bound BEFORE this integration subscribed (wiring-order races during + // startup) would otherwise never get a tailer. PTYs do not survive a server + // restart, so this is purely about same-process ordering. EOF attach: their + // events file pre-exists and history must not be replayed as live turns. + for (const listed of registry.list()) { + const record = registry.get(listed.terminalId) + if (!record || record.mode !== 'amplifier' || record.status !== 'running') continue + if (!record.resumeSessionId) continue + const eventsPath = resolveEventsPath(record.resumeSessionId) + if (!eventsPath) continue + void attachTailer(record.terminalId, record.resumeSessionId, eventsPath, 'eof') + } + + return { + attachTailer, + getAttachChainCount: () => attachChains.size, + async dispose() { + disposed = true + registry.off('terminal.session.bound', onBound) + registry.off('terminal.exit', onExit) + tracker.off('events.force-read', onForceRead) + // Serialize behind any in-flight/queued attach so the last watcher of a + // same-tick attach cascade is closed too (finding B). + const terminalIds = Array.from(new Set([...attachments.keys(), ...attachChains.keys()])) + await Promise.all(terminalIds.map((terminalId) => runSerialized(terminalId, () => detach(terminalId)))) + attachChains.clear() + }, + } +} diff --git a/server/coding-cli/amplifier-activity-tracker.ts b/server/coding-cli/amplifier-activity-tracker.ts index 91b7fe0b..af0c3aea 100644 --- a/server/coding-cli/amplifier-activity-tracker.ts +++ b/server/coding-cli/amplifier-activity-tracker.ts @@ -1,16 +1,28 @@ import { EventEmitter } from 'events' import { isSubmitInput } from '../../shared/turn-complete-signal.js' import type { TerminalTurnCompletionSnapshot } from '../../shared/ws-protocol.js' +import type { AmplifierReducerEffect } from './amplifier-events-reducer.js' +import { TurnCompletionLedger } from './turn-completion-ledger.js' -// Failsafe: a busy terminal silent this long self-heals to idle (and completes the -// turn). Mirrors the Claude lane's deadman, but Amplifier's deadman ALSO emits a -// turn.complete because Amplifier has no other end-of-turn signal. +// Deadman: a busy terminal silent this long triggers the missed-signal failsafe +// (docs/plans/2026-07-08-amplifier-session-durability-plan.md §6): request a +// force-read of events.jsonl (WSL2 inotify backstop) and STAY busy — never +// fabricate a completion; `prompt:complete` / `session:end` records are the +// only turn ends. export const AMPLIFIER_BUSY_DEADMAN_MS = 120_000 export const AMPLIFIER_ACTIVITY_SWEEP_MS = 5_000 -// Output-idle window that marks a turn complete. Amplifier has no turn-complete BEL, -// so once the first post-submit output has arrived, this much output-silence is -// treated as the end of the turn. -export const AMPLIFIER_IDLE_DEBOUNCE_MS = 2_000 +// A PTY Enter is only PROVISIONALLY busy. Empty-Enter writes zero events +// (plan §2 E5), so if no `prompt:submit` record confirms the turn within this +// window the tracker silently reverts to idle (no turn.complete). Because a +// silently-dead watcher (WSL2 inotify) would also look like "no record", the +// FIRST expiry requests a force-read of the events tail and extends the grace +// once; only the second expiry reverts (adversarial finding D). +export const AMPLIFIER_SUBMIT_GRACE_MS = 2_000 +// After this many CONSECUTIVE grace reversions on one terminal a single +// 'amplifier_events_lane_suspect' warn is logged (soak monitoring for dead +// watchers). Empty-Enters are legitimate reversions, so this is a log signal +// only — it never changes behavior. +export const AMPLIFIER_GRACE_REVERSION_SUSPECT_THRESHOLD = 3 export type AmplifierActivityPhase = 'idle' | 'busy' @@ -33,6 +45,18 @@ export type AmplifierActivityChange = { remove: string[] } +/** + * Emitted (event name 'events.force-read') when a busy terminal has been + * silent past the deadman: the integration must force-read the events tail + * (WSL2 inotify backstop). The tracker stays busy — it never fabricates a + * completion. + */ +export type AmplifierEventsForceReadRequest = { + terminalId: string + sessionId?: string + at: number +} + type TrackerLogger = { warn: (payload: object, message?: string) => void } @@ -43,31 +67,48 @@ type AmplifierTerminalActivity = { phase: AmplifierActivityPhase updatedAt: number lastObservedAt: number - lastSubmitAt?: number - // True after a submit until the first output arrives. While true the idle-debounce - // timer is deliberately NOT armed so pre-first-token latency cannot look like idle. - awaitingFirstOutput: boolean - idleTimer?: ReturnType + // True once a `prompt:submit` record confirmed the current busy phase; + // false while busy is provisional (PTY Enter, awaiting the record). + busyConfirmed: boolean + submitGraceTimer?: ReturnType + // True once the current provisional busy already spent its one force-read + // retry (finding D): the next grace expiry reverts. + submitGraceRetried: boolean + // Consecutive silent grace reversions; reset by any confirmed turn.began. + graceReversionCount: number + // Deadman force-read warn is logged once per stuck-busy period. + forceReadLogged: boolean } /** * Server-authoritative Amplifier turn lifecycle, keyed by terminalId. * - * - A submit (whole-payload newline) marks busy, (re)arms the deadman and sets - * awaitingFirstOutput. It does NOT arm the idle-debounce timer yet. - * - The first output after a submit clears awaitingFirstOutput; every output while - * busy (re)starts the idle-debounce timer. When that timer elapses with no further - * output the turn ends: phase → idle and one turn.complete is emitted. - * - A busy terminal silent past the deadman self-heals to idle and also emits a - * turn.complete (Amplifier has no BEL, so the deadman is the only failsafe end). + * Single events-driven state machine + * (docs/plans/2026-07-08-amplifier-session-durability-plan.md §6; the former + * feature flag and degraded PTY-timing lane were removed 2026-07-08): + * + * - PTY Enter (`noteInput` + `isSubmitInput`) is only a PROVISIONAL busy with a + * submit-grace reversion (one force-read retry, then a silent revert — no + * turn.complete). A `prompt:submit` record (reducer `turn.began` effect via + * applyLifecycle()) confirms busy; `prompt:complete` / `session:end` + * (`turn.completed`) is the single turn boundary and emits exactly one + * turn.complete via the TurnCompletionLedger. + * - PTY output only refreshes liveness (feeds the deadman). The deadman never + * fabricates a completion: it requests a force-read of the events tail and + * stays busy. PTY exit (noteExit) removes state unconditionally. + * - Signal loss (tailer degraded/detached — see noteEventsSignalLost): the + * phase reverts to idle silently and the terminal keeps only the + * grace-bounded provisional-busy pulses from PTY submits. Sessions that + * never produce an events.jsonl behave the same way by construction: no + * confirmed busy, no turn.complete — documented, acceptable behavior. * - * Unlike the Claude tracker this lane detects turn-END via OUTPUT-IDLE, not the - * Stop-hook BEL parser. + * The public surface (list/getActivity/listLatestCompletions/trackTerminal/ + * bindSession/noteInput/noteOutput/noteExit/expire/dispose + 'changed'/'turn.complete'/ + * 'events.force-read' events, phase 'idle' | 'busy') is frozen. */ export class AmplifierActivityTracker extends EventEmitter { private readonly states = new Map() - private readonly completionSeqByTerminalId = new Map() - private readonly latestCompletions = new Map() + private readonly completionLedger = new TurnCompletionLedger() private readonly log?: TrackerLogger constructor(input: { log?: TrackerLogger } = {}) { @@ -85,7 +126,7 @@ export class AmplifierActivityTracker extends EventEmitter { } listLatestCompletions(): TerminalTurnCompletionSnapshot[] { - return Array.from(this.latestCompletions.values()) + return this.completionLedger.listLatestCompletions() } trackTerminal(input: { terminalId: string; sessionId?: string; at: number }): void { @@ -104,7 +145,10 @@ export class AmplifierActivityTracker extends EventEmitter { phase: 'idle', updatedAt: input.at, lastObservedAt: input.at, - awaitingFirstOutput: false, + busyConfirmed: false, + submitGraceRetried: false, + graceReversionCount: 0, + forceReadLogged: false, } this.commitState(state, undefined) } @@ -118,21 +162,92 @@ export class AmplifierActivityTracker extends EventEmitter { this.commitState(state, previous) } + /** + * The events signal for this terminal is gone (tailer degraded: schema + * mismatch, file reset, persistent read errors, attach failure — or the + * integration detached without replacement). Policy (plan §6, 2026-07-08): + * NO fallback to timing heuristics. A busy phase reverts to idle silently — + * the phase flip is publicly visible via 'changed', but no turn.complete is + * ever fabricated. From here on the terminal only ever shows the + * grace-bounded provisional-busy pulses from PTY submits (which always + * revert, since no `prompt:submit` record can arrive). The single + * 'amplifier_events_lane_degraded' warn is the integration's responsibility. + */ + noteEventsSignalLost(terminalId: string): void { + const state = this.states.get(terminalId) + if (!state) return + this.clearSubmitGrace(state) + state.busyConfirmed = false + state.forceReadLogged = false + if (state.phase !== 'busy') return + const previous = this.toRecord(state) + const at = Date.now() + state.phase = 'idle' + state.updatedAt = at + state.lastObservedAt = at + this.commitState(state, previous) + } + + /** + * Consume a reducer effect (plan §6 transition table). `lane.degrade` + * (schema-gate failure surfaced by the reducer) is treated as signal loss. + */ + applyLifecycle(terminalId: string, effect: AmplifierReducerEffect): void { + const state = this.states.get(terminalId) + if (!state) return + switch (effect.kind) { + case 'lane.degrade': { + this.noteEventsSignalLost(terminalId) + return + } + case 'turn.began': { + // The only input that (re)enters busy (E2/E5). Confirms a provisional busy. + const at = parseEffectAt(effect.at) + this.clearSubmitGrace(state) + state.busyConfirmed = true + state.forceReadLogged = false + state.graceReversionCount = 0 + state.lastObservedAt = at + if (state.phase !== 'busy') { + const previous = this.toRecord(state) + state.phase = 'busy' + state.updatedAt = at + this.commitState(state, previous) + } + return + } + case 'turn.completed': { + // The single turn boundary (E2/E3): exactly one turn.complete per turn. + this.completeTurn(state, parseEffectAt(effect.at)) + return + } + case 'session.identified': { + if (!effect.sessionId || state.sessionId === effect.sessionId) return + const previous = this.toRecord(state) + state.sessionId = effect.sessionId + this.commitState(state, previous) + return + } + default: + return + } + } + noteInput(input: { terminalId: string; data: string; at: number }): void { const state = this.states.get(input.terminalId) if (!state) return if (!isSubmitInput(input.data)) return - const previous = this.toRecord(state) - state.lastSubmitAt = input.at + // Idle + PTY submit → provisional busy with a grace timer (empty-Enter + // writes zero events, E5). Submit during busy re-arms NOTHING — mid-turn + // typing is queued steering within the same turn (E5). state.lastObservedAt = input.at - // Turn onset: go busy and (re)arm the deadman via lastObservedAt. Wait for the - // first output before arming the idle-debounce timer. - state.awaitingFirstOutput = true - this.clearIdleTimer(state) - if (state.phase !== 'busy') { - state.phase = 'busy' - state.updatedAt = input.at - } + if (state.phase === 'busy') return + const previous = this.toRecord(state) + state.phase = 'busy' + state.busyConfirmed = false + state.submitGraceRetried = false + state.updatedAt = input.at + this.armSubmitGrace(state, input.at) this.commitState(state, previous) } @@ -140,43 +255,83 @@ export class AmplifierActivityTracker extends EventEmitter { const state = this.states.get(input.terminalId) if (!state) return if (state.phase !== 'busy') return + // Output only refreshes liveness (feeds the deadman). It never ends a + // turn — `prompt:complete` is the only turn boundary. state.lastObservedAt = input.at - // First output after submit clears the awaiting flag; every output (re)starts the - // idle-debounce timer so the turn ends only after output-silence. - state.awaitingFirstOutput = false - this.armIdleTimer(state) + state.forceReadLogged = false } - private armIdleTimer(state: AmplifierTerminalActivity): void { - this.clearIdleTimer(state) + private armSubmitGrace(state: AmplifierTerminalActivity, at: number): void { + this.clearSubmitGrace(state) const terminalId = state.terminalId - const at = state.lastObservedAt + AMPLIFIER_IDLE_DEBOUNCE_MS + const expiryAt = at + AMPLIFIER_SUBMIT_GRACE_MS const timer = setTimeout(() => { - this.handleIdleTimeout(terminalId, at) - }, AMPLIFIER_IDLE_DEBOUNCE_MS) - // Do not keep the event loop alive solely for a debounce timer. + this.handleSubmitGraceTimeout(terminalId, expiryAt) + }, AMPLIFIER_SUBMIT_GRACE_MS) + // Do not keep the event loop alive solely for a grace timer. ;(timer as unknown as { unref?: () => void }).unref?.() - state.idleTimer = timer + state.submitGraceTimer = timer } - private clearIdleTimer(state: AmplifierTerminalActivity): void { - if (state.idleTimer !== undefined) { - clearTimeout(state.idleTimer) - state.idleTimer = undefined + private clearSubmitGrace(state: AmplifierTerminalActivity): void { + if (state.submitGraceTimer !== undefined) { + clearTimeout(state.submitGraceTimer) + state.submitGraceTimer = undefined } } - private handleIdleTimeout(terminalId: string, at: number): void { + private handleSubmitGraceTimeout(terminalId: string, at: number): void { const state = this.states.get(terminalId) if (!state) return - state.idleTimer = undefined + state.submitGraceTimer = undefined + if (state.phase !== 'busy' || state.busyConfirmed) return + if (!state.submitGraceRetried) { + // Backstop for silently-dead watchers (WSL2 inotify; adversarial finding + // D): "no prompt:submit seen" may just mean "no change event delivered". + // Request a force-read of the events tail and extend the grace ONCE — a + // drained prompt:submit confirms busy via the normal turn.began path. + // Never reverts-then-corrects, so clients see no idle→busy flap. + state.submitGraceRetried = true + this.emit('events.force-read', { + terminalId: state.terminalId, + ...(state.sessionId ? { sessionId: state.sessionId } : {}), + at, + } satisfies AmplifierEventsForceReadRequest) + this.armSubmitGrace(state, at) + return + } + // Silent reversion (plan §6, validated by E5): no `prompt:submit` record + // followed the Enter (nor the force-read), so the turn never started. + // NO turn.complete. + const previous = this.toRecord(state) + state.phase = 'idle' + state.updatedAt = at + state.lastObservedAt = at + state.graceReversionCount += 1 + if (state.graceReversionCount === AMPLIFIER_GRACE_REVERSION_SUSPECT_THRESHOLD) { + // Soak signal only (Phase 5): repeated reversions can mean a dead watcher + // OR repeated legitimate empty-Enters — never changes behavior. + this.log?.warn({ + component: 'amplifier-activity-tracker', + event: 'amplifier_events_lane_suspect', + terminalId: state.terminalId, + reversions: state.graceReversionCount, + }, 'Amplifier tracker saw repeated submit-grace reversions; the events watcher may be dead.') + } + this.commitState(state, previous) + } + + /** Turn-completion path (reducer `turn.completed` effect). */ + private completeTurn(state: AmplifierTerminalActivity, at: number): void { if (state.phase !== 'busy') return const previous = this.toRecord(state) + this.clearSubmitGrace(state) state.phase = 'idle' - state.awaitingFirstOutput = false + state.busyConfirmed = false + state.forceReadLogged = false state.updatedAt = at state.lastObservedAt = at - const completion = this.recordTurnCompletion({ + const completion = this.completionLedger.recordTurnCompletion({ terminalId: state.terminalId, ...(state.sessionId ? { sessionId: state.sessionId } : {}), at, @@ -185,25 +340,8 @@ export class AmplifierActivityTracker extends EventEmitter { this.emit('turn.complete', completion) } - private recordTurnCompletion(input: { - terminalId: string - sessionId?: string - at: number - }): AmplifierTurnCompleteEvent { - const completionSeq = (this.completionSeqByTerminalId.get(input.terminalId) ?? 0) + 1 - this.completionSeqByTerminalId.set(input.terminalId, completionSeq) - this.latestCompletions.set(input.terminalId, { - terminalId: input.terminalId, - at: input.at, - completionSeq, - }) - return { - ...input, - completionSeq, - } - } - noteExit(input: { terminalId: string }): void { + // PTY exit is the authoritative end — unconditional (plan §6). this.removeState(input.terminalId) } @@ -212,32 +350,30 @@ export class AmplifierActivityTracker extends EventEmitter { if (state.phase !== 'busy') continue const idleAgeMs = at - state.lastObservedAt if (idleAgeMs <= AMPLIFIER_BUSY_DEADMAN_MS) continue - const previous = this.toRecord(state) - this.clearIdleTimer(state) - state.phase = 'idle' - state.awaitingFirstOutput = false - state.updatedAt = at - state.lastObservedAt = at - this.log?.warn({ - component: 'amplifier-activity-tracker', - event: 'amplifier_activity_deadman', - terminalId: state.terminalId, - ageMs: idleAgeMs, - }, 'Amplifier terminal stuck busy past deadman; clearing to idle.') - const completion = this.recordTurnCompletion({ + // Missed-signal failsafe ONLY (plan §6): never fabricate a completion. + // Request a force-read of the tail (WSL2 inotify backstop); if nothing + // surfaces the terminal stays busy (genuine long turn). + if (!state.forceReadLogged) { + state.forceReadLogged = true + this.log?.warn({ + component: 'amplifier-activity-tracker', + event: 'amplifier_activity_deadman_force_read', + terminalId: state.terminalId, + ageMs: idleAgeMs, + }, 'Amplifier terminal silent past deadman; requesting force-read (staying busy).') + } + this.emit('events.force-read', { terminalId: state.terminalId, ...(state.sessionId ? { sessionId: state.sessionId } : {}), at, - }) - this.commitState(state, previous) - this.emit('turn.complete', completion) + } satisfies AmplifierEventsForceReadRequest) } } - /** Clear every per-terminal debounce timer (called on wiring dispose). */ + /** Clear every per-terminal grace timer (called on wiring dispose). */ dispose(): void { for (const state of this.states.values()) { - this.clearIdleTimer(state) + this.clearSubmitGrace(state) } } @@ -251,7 +387,7 @@ export class AmplifierActivityTracker extends EventEmitter { private removeState(terminalId: string): void { const state = this.states.get(terminalId) if (!state) return - this.clearIdleTimer(state) + this.clearSubmitGrace(state) this.states.delete(terminalId) this.emit('changed', { upsert: [], remove: [terminalId] } satisfies AmplifierActivityChange) } @@ -270,3 +406,13 @@ export class AmplifierActivityTracker extends EventEmitter { return previous.phase !== next.phase || previous.sessionId !== next.sessionId } } + +/** Effect timestamps are ISO strings carried through from the log (plan §6: + * never used to gate transitions, only for `updatedAt`/`at` fields). */ +function parseEffectAt(at: string | undefined): number { + if (at) { + const parsed = Date.parse(at) + if (Number.isFinite(parsed)) return parsed + } + return Date.now() +} diff --git a/server/coding-cli/amplifier-activity-wiring.ts b/server/coding-cli/amplifier-activity-wiring.ts index 32f2fdc5..639b74ae 100644 --- a/server/coding-cli/amplifier-activity-wiring.ts +++ b/server/coding-cli/amplifier-activity-wiring.ts @@ -1,94 +1,32 @@ +import { logger } from '../logger.js' import { AMPLIFIER_ACTIVITY_SWEEP_MS, AmplifierActivityTracker, } from './amplifier-activity-tracker.js' -import type { - TerminalInputRawEvent, - TerminalOutputRawEvent, - TerminalSessionBoundEvent, -} from '../terminal-stream/registry-events.js' +import { wirePtyActivityTracker, type ActivityWiringRegistry } from './activity-wiring-factory.js' -type AmplifierTerminalSnapshot = { - terminalId: string - mode: string - status: string -} - -type AmplifierActivityRegistry = { - list: () => Array<{ terminalId: string }> - get: (terminalId: string) => AmplifierTerminalSnapshot | undefined | null - on: (event: string, handler: (...args: any[]) => void) => void - off: (event: string, handler: (...args: any[]) => void) => void -} +type AmplifierActivityRegistry = ActivityWiringRegistry +/** + * Registry→tracker wiring for Amplifier's PTY signals (submit/output/exit). + * Thin wrapper over the shared wirePtyActivityTracker factory (Phase 4 + * consolidation with the claude wiring); the public surface is unchanged. + * The events.jsonl lifecycle layers on top via amplifier-activity-integration.ts. + */ export function wireAmplifierActivityTracker(input: { registry: AmplifierActivityRegistry now?: () => number setIntervalFn?: typeof setInterval clearIntervalFn?: typeof clearInterval }) { - const { - registry, - now = () => Date.now(), - setIntervalFn = setInterval, - clearIntervalFn = clearInterval, - } = input - - const tracker = new AmplifierActivityTracker() - - const startTracking = (record: AmplifierTerminalSnapshot) => { - if (record.mode !== 'amplifier' || record.status !== 'running') return - tracker.trackTerminal({ terminalId: record.terminalId, at: now() }) - } - - const onCreated = (record: AmplifierTerminalSnapshot) => { - startTracking(record) - } - const onBound = (event: TerminalSessionBoundEvent) => { - if (event.provider !== 'amplifier') return - // Production emits 'terminal.session.bound' BEFORE 'terminal.created', so a plain - // bindSession would be a no-op (no record yet) and drop the sessionId. Ensure the - // record exists with its sessionId first (trackTerminal is idempotent and updates - // the sessionId on an existing record); a later 'terminal.created' won't clobber it. - tracker.trackTerminal({ terminalId: event.terminalId, sessionId: event.sessionId, at: now() }) - tracker.bindSession({ terminalId: event.terminalId, sessionId: event.sessionId, at: now() }) - } - const onInput = (event: TerminalInputRawEvent) => { - tracker.noteInput({ terminalId: event.terminalId, data: event.data, at: event.at }) - } - const onOutput = (event: TerminalOutputRawEvent) => { - tracker.noteOutput({ terminalId: event.terminalId, data: event.data, at: event.at }) - } - const onExit = (event: { terminalId?: string }) => { - if (!event.terminalId) return - tracker.noteExit({ terminalId: event.terminalId }) - } - - registry.on('terminal.created', onCreated) - registry.on('terminal.session.bound', onBound) - registry.on('terminal.input.raw', onInput) - registry.on('terminal.output.raw', onOutput) - registry.on('terminal.exit', onExit) - - for (const listed of registry.list()) { - const record = registry.get(listed.terminalId) - if (record) startTracking(record) - } - - const sweepTimer = setIntervalFn(() => { - tracker.expire(now()) - }, AMPLIFIER_ACTIVITY_SWEEP_MS) - - return { - tracker, - dispose(): void { - registry.off('terminal.created', onCreated) - registry.off('terminal.session.bound', onBound) - registry.off('terminal.input.raw', onInput) - registry.off('terminal.output.raw', onOutput) - registry.off('terminal.exit', onExit) - clearIntervalFn(sweepTimer) - tracker.dispose() - }, - } + return wirePtyActivityTracker({ + mode: 'amplifier', + // The tracker's own warns (deadman force-read, events-lane-suspect) must + // reach the production log for Phase-5 soak monitoring. + tracker: new AmplifierActivityTracker({ log: logger.child({ component: 'amplifier-activity-tracker' }) }), + sweepIntervalMs: AMPLIFIER_ACTIVITY_SWEEP_MS, + // The amplifier tracker owns per-terminal debounce/grace timers. + disposeTracker: (tracker) => tracker.dispose(), + ...input, + }) } diff --git a/server/coding-cli/amplifier-events-reducer.ts b/server/coding-cli/amplifier-events-reducer.ts new file mode 100644 index 00000000..9a27c79c --- /dev/null +++ b/server/coding-cli/amplifier-events-reducer.ts @@ -0,0 +1,179 @@ +/** + * Pure reducer for Amplifier's `events.jsonl` lifecycle records. + * + * Implements the events-lane transition table of + * docs/plans/2026-07-08-amplifier-session-durability-plan.md §6, restricted to + * record inputs (PTY submit/output/exit and the submit-grace timer live in the + * tracker; Phase 2). Imitates `opencode-ownership-reducer.ts`: no I/O, no + * timers — `(state, record) -> { state, effects }`. + * + * Contract facts this encodes (plan §2): + * - `prompt:submit` is the ONLY input that (re)enters busy (E2/E5). + * - `prompt:complete` is the single turn boundary (E2/E3). + * - `session:end` while busy ends the turn (E7); while idle it is ignored, + * which also makes orphan/duplicate `session:end` records legal (E7/E3). + * - `session:resume` never implies a phase change (E7). + * - Transitions key on event TYPE only; timestamps are carried through for + * `at` fields but never used to order or gate transitions (E3). + * - Schema gate: `amplifier.log`, major version 1 (E10); anything else + * degrades the lane once and the reducer goes inert. + * - `session:fork` / `session:start` with a `parent_id` are subagent + * indicators (plan §2 last rows) — observed in state, never effects. + */ + +export type AmplifierLifecyclePhase = 'idle' | 'busy' + +export type AmplifierParsedRecord = { + ts?: string + lvl?: string + schema?: { name?: string; ver?: string } + event: string + session_id?: string + data?: { + parent_id?: string | null + raw?: Record + [key: string]: unknown + } | null + [key: string]: unknown +} + +export type AmplifierReducerState = { + phase: AmplifierLifecyclePhase + /** Sticky: set by a schema-gate failure; the reducer ignores all further records. */ + degraded: boolean + /** True once a subagent indicator was seen (`session:fork`, or `session:start` with `parent_id`). */ + subagent: boolean + /** First `session_id` observed on any record. */ + sessionId?: string +} + +export type AmplifierReducerEffect = + | { kind: 'turn.began'; at?: string } + | { kind: 'turn.completed'; at?: string } + | { kind: 'session.identified'; sessionId?: string; cwd: string } + | { kind: 'lane.degrade'; reason: AmplifierSchemaGateFailure } + +export type AmplifierSchemaGateFailure = + | 'schema_missing' + | 'schema_name_mismatch' + | 'schema_version_unsupported' + +export type AmplifierReducerResult = { + state: AmplifierReducerState + effects: AmplifierReducerEffect[] +} + +export const AMPLIFIER_LOG_SCHEMA_NAME = 'amplifier.log' +export const AMPLIFIER_LOG_SCHEMA_MAJOR = 1 + +export function createAmplifierReducerState(): AmplifierReducerState { + return { phase: 'idle', degraded: false, subagent: false } +} + +/** + * Schema gate (plan §6, E10): accept `amplifier.log` major version 1; + * anything else is a lane-degrade reason. + */ +export function checkAmplifierRecordSchema( + record: AmplifierParsedRecord, +): AmplifierSchemaGateFailure | undefined { + const schema = record.schema + if (!schema || typeof schema !== 'object') return 'schema_missing' + if (schema.name !== AMPLIFIER_LOG_SCHEMA_NAME) return 'schema_name_mismatch' + const major = Number.parseInt(String(schema.ver ?? '').split('.')[0] ?? '', 10) + if (!Number.isInteger(major) || major !== AMPLIFIER_LOG_SCHEMA_MAJOR) { + return 'schema_version_unsupported' + } + return undefined +} + +function isSubagentIndicator(record: AmplifierParsedRecord): boolean { + if (record.event === 'session:fork') return true + if (record.event === 'session:start') { + const parentId = record.data?.parent_id + return typeof parentId === 'string' && parentId.length > 0 + } + return false +} + +function sessionConfigCwd(record: AmplifierParsedRecord): string | undefined { + const raw = record.data?.raw + if (!raw || typeof raw !== 'object') return undefined + const workingDir = raw.working_dir + if (typeof workingDir === 'string' && workingDir.length > 0) return workingDir + const projectDir = raw.project_dir + if (typeof projectDir === 'string' && projectDir.length > 0) return projectDir + return undefined +} + +export function reduceAmplifierEvent( + state: AmplifierReducerState, + record: AmplifierParsedRecord, +): AmplifierReducerResult { + if (state.degraded) { + return { state, effects: [] } + } + + const schemaFailure = checkAmplifierRecordSchema(record) + if (schemaFailure) { + return { + state: { ...state, degraded: true }, + effects: [{ kind: 'lane.degrade', reason: schemaFailure }], + } + } + + let next = state + if (!next.sessionId && typeof record.session_id === 'string' && record.session_id.length > 0) { + next = { ...next, sessionId: record.session_id } + } + if (isSubagentIndicator(record) && !next.subagent) { + next = { ...next, subagent: true } + } + + switch (record.event) { + case 'prompt:submit': { + // The only input that (re)enters busy (E2/E5). busy -> busy is a + // confirm, not a new turn: no duplicate turn.began. + if (next.phase === 'busy') return { state: next, effects: [] } + return { + state: { ...next, phase: 'busy' }, + effects: [{ kind: 'turn.began', at: record.ts }], + } + } + case 'prompt:complete': { + // The single turn boundary (E2/E3). At idle it is just another + // non-prompt:submit record: ignored. + if (next.phase !== 'busy') return { state: next, effects: [] } + return { + state: { ...next, phase: 'idle' }, + effects: [{ kind: 'turn.completed', at: record.ts }], + } + } + case 'session:end': { + // Turn ended by quit/hangup (E7). Orphan/duplicate session:end at idle + // is legal and ignored (E7 continue-attach, E3 out-of-order tail). + if (next.phase !== 'busy') return { state: next, effects: [] } + return { + state: { ...next, phase: 'idle' }, + effects: [{ kind: 'turn.completed', at: record.ts }], + } + } + case 'session:config': { + const cwd = sessionConfigCwd(record) + if (!cwd) return { state: next, effects: [] } + return { + state: next, + effects: [{ kind: 'session.identified', sessionId: record.session_id, cwd }], + } + } + case 'session:resume': + // Resume does not imply busy; no phase change (E7). + return { state: next, effects: [] } + default: + // Everything else (session:start, execution:*, provider:*, llm:*, + // tool:*, content_block:*, orchestrator:*, cleanup:*, ...) never + // changes phase. Post-complete background naming events are covered + // here (E2): never a new turn. + return { state: next, effects: [] } + } +} diff --git a/server/coding-cli/amplifier-events-tailer.ts b/server/coding-cli/amplifier-events-tailer.ts new file mode 100644 index 00000000..0bacdef0 --- /dev/null +++ b/server/coding-cli/amplifier-events-tailer.ts @@ -0,0 +1,322 @@ +/** + * Offset-based incremental reader for Amplifier's `events.jsonl`. + * + * Tailer contract (docs/plans/2026-07-08-amplifier-session-durability-plan.md + * §6): remembers a byte offset per file; on caller-driven reads (watcher + * change or force-read — this module owns NO watchers), reads only appended + * bytes via positional fd reads; buffers a partial trailing line until it is + * completed; applies a cheap substring pre-filter before `JSON.parse` so the + * ~450KB/turn of `content_block:*`/`tool:*` noise is skipped without parsing; + * validates the schema once per file; `size < offset` means file reset — + * degrade, never guess. Injected `fsImpl` for tests, imitating + * `codex-app-server/durability-proof.ts`. + */ + +import fsp from 'node:fs/promises' +import { + checkAmplifierRecordSchema, + type AmplifierParsedRecord, +} from './amplifier-events-reducer.js' + +const READ_CHUNK_BYTES = 64 * 1024 +const NEWLINE = 0x0a + +/** + * Cap on the buffered partial-line remainder. Multi-MB `llm:request` lines are + * NORMAL (plan §2 last row: full raw payloads are embedded), so an oversized + * line never degrades the lane: the buffered bytes are dropped and the tailer + * skips to the next newline, counting one skipped line (adversarial finding E). + */ +export const AMPLIFIER_TAILER_PARTIAL_MAX_BYTES = 8 * 1024 * 1024 + +/** + * Cap on a single positional read batch so no `Buffer.concat` scales with the + * file size (events files up to hundreds of MB exist). `readAppended` loops + * over batches until the stat'd size is consumed. + */ +export const AMPLIFIER_TAILER_READ_BATCH_MAX_BYTES = 16 * 1024 * 1024 + +/** + * Lifecycle event-name prefixes the reducer cares about. Lines are checked + * with plain substring scans (both `"event":"x` and `"event": "x` spellings — + * the live CLI writes a space after the colon) so noise never reaches + * JSON.parse. + */ +const EVENT_PREFIXES = ['session:', 'prompt:', 'execution:', 'orchestrator:steering'] as const + +const PREFILTER_NEEDLES: string[] = EVENT_PREFIXES.flatMap((prefix) => [ + `"event":"${prefix}`, + `"event": "${prefix}`, +]) + +export type AmplifierTailerFileHandle = { + read( + buffer: Buffer, + offset: number, + length: number, + position: number, + ): Promise<{ bytesRead: number }> + close(): Promise +} + +export type AmplifierTailerFs = { + open(path: string, flags: string): Promise + stat(path: string): Promise<{ size: number }> +} + +export type AmplifierTailerDegradeReason = 'file_reset' | 'schema_mismatch' | 'read_error' + +export type AmplifierTailerReadResult = + | { + ok: true + records: AmplifierParsedRecord[] + /** Complete lines dropped by the pre-filter (or unparseable). */ + skippedLines: number + /** Bytes consumed from the file by this read (including buffered partial-line bytes). */ + bytesConsumed: number + offset: number + } + | { + ok: false + reason: AmplifierTailerDegradeReason + message: string + } + +export type AmplifierTailerAttachResult = + | { ok: true; offset: number } + | { ok: false; reason: AmplifierTailerDegradeReason; message: string } + +export type AmplifierEventsTailer = { + attach(): Promise + /** Incremental read of appended bytes. Driven by callers (watcher change in Phase 2). */ + read(): Promise + /** Missed-signal failsafe entry point (deadman force-read): stat + manual incremental read. */ + forceRead(): Promise + getOffset(): number + /** Bytes currently held in the partial-line buffer (diagnostics/tests). */ + getBufferedBytes(): number +} + +export function createAmplifierEventsTailer(input: { + filePath: string + /** 'start' = fresh session (offset 0); 'eof' = resume attach (E7). */ + attachAt: 'start' | 'eof' + fsImpl?: AmplifierTailerFs + log?: { debug?: (payload: object, message?: string) => void } +}): AmplifierEventsTailer { + const fsImpl: AmplifierTailerFs = input.fsImpl ?? defaultFs() + const filePath = input.filePath + + let offset = 0 + let partial: Buffer = Buffer.alloc(0) + // True while discarding the tail of an oversized line (partial cap overflow): + // bytes are dropped until the next newline, then parsing resumes. + let skippingOversizedLine = false + let oversizedLineLogged = false + let schemaValidated = false + let degraded: { reason: AmplifierTailerDegradeReason; message: string } | undefined + let chain: Promise = Promise.resolve() + + const degrade = ( + reason: AmplifierTailerDegradeReason, + message: string, + ): { ok: false; reason: AmplifierTailerDegradeReason; message: string } => { + degraded = { reason, message } + return { ok: false, reason, message } + } + + async function readAppended(): Promise { + if (degraded) return { ok: false, ...degraded } + + let size: number + try { + size = (await fsImpl.stat(filePath)).size + } catch (error) { + return degrade('read_error', `Could not stat amplifier events file: ${errorMessage(error)}`) + } + + if (size < offset) { + return degrade( + 'file_reset', + `Amplifier events file shrank (size ${size} < offset ${offset}); refusing to guess.`, + ) + } + if (size === offset) { + return { ok: true, records: [], skippedLines: 0, bytesConsumed: 0, offset } + } + + const records: AmplifierParsedRecord[] = [] + let skippedLines = 0 + let bytesConsumed = 0 + + // Bounded batches: no single Buffer.concat scales with the backlog size + // (adversarial finding E — events files of hundreds of MB exist). + while (offset < size) { + const batchLength = Math.min(size - offset, AMPLIFIER_TAILER_READ_BATCH_MAX_BYTES) + let appended: Buffer + try { + appended = await readRange(fsImpl, filePath, offset, batchLength) + } catch (error) { + return degrade('read_error', `Could not read amplifier events file: ${errorMessage(error)}`) + } + if (appended.length === 0) break + bytesConsumed += appended.length + offset += appended.length + + let chunk = appended + if (skippingOversizedLine) { + const newlineIndex = chunk.indexOf(NEWLINE) + if (newlineIndex === -1) continue // still inside the oversized line: drop bytes + skippingOversizedLine = false + skippedLines += 1 // the dropped oversized line finally ended + chunk = chunk.subarray(newlineIndex + 1) + } + + const combined = partial.length > 0 ? Buffer.concat([partial, chunk]) : chunk + const { lines, remainder } = splitCompleteLines(combined) + if (remainder.length > AMPLIFIER_TAILER_PARTIAL_MAX_BYTES) { + // Oversized line (multi-MB llm:request payloads are normal): drop the + // buffered bytes and skip to the next newline. Never degrade the lane. + partial = Buffer.alloc(0) + skippingOversizedLine = true + if (!oversizedLineLogged) { + oversizedLineLogged = true + input.log?.debug?.({ + component: 'amplifier-events-tailer', + event: 'amplifier_tailer_oversized_line_dropped', + filePath, + bufferedBytes: remainder.length, + }, 'Amplifier events line exceeded the partial-buffer cap; dropping to next newline.') + } + } else { + partial = remainder + } + + for (const lineBuffer of lines) { + const line = lineBuffer.toString('utf8').replace(/\r$/, '') + if (line.trim().length === 0) continue + if (!matchesPrefilter(line)) { + skippedLines += 1 + continue + } + let record: AmplifierParsedRecord + try { + record = JSON.parse(line) as AmplifierParsedRecord + } catch { + skippedLines += 1 + continue + } + if (!schemaValidated) { + const failure = checkAmplifierRecordSchema(record) + if (failure) { + return degrade( + 'schema_mismatch', + `Amplifier events schema gate failed (${failure}); expected amplifier.log major version 1.`, + ) + } + schemaValidated = true + } + records.push(record) + } + } + + return { ok: true, records, skippedLines, bytesConsumed, offset } + } + + function serialize(task: () => Promise): Promise { + const next = chain.then(task, task) + chain = next.catch(() => {}) + return next + } + + return { + async attach() { + return serialize(async (): Promise => { + if (degraded) return { ok: false, ...degraded } + if (input.attachAt === 'eof') { + try { + offset = (await fsImpl.stat(filePath)).size + } catch (error) { + return degrade( + 'read_error', + `Could not stat amplifier events file for EOF attach: ${errorMessage(error)}`, + ) + } + } else { + offset = 0 + } + partial = Buffer.alloc(0) + skippingOversizedLine = false + return { ok: true, offset } + }) + }, + read() { + return serialize(readAppended) + }, + forceRead() { + // Same stat + manual incremental read; a distinct entry point so the + // Phase 2 deadman failsafe (WSL2 inotify backstop) reads the tail + // without a watcher event. + return serialize(readAppended) + }, + getOffset() { + return offset + }, + getBufferedBytes() { + return partial.length + }, + } +} + +function matchesPrefilter(line: string): boolean { + for (const needle of PREFILTER_NEEDLES) { + if (line.includes(needle)) return true + } + return false +} + +function splitCompleteLines(buffer: Buffer): { lines: Buffer[]; remainder: Buffer } { + const lines: Buffer[] = [] + let start = 0 + while (start < buffer.length) { + const newlineIndex = buffer.indexOf(NEWLINE, start) + if (newlineIndex === -1) break + lines.push(buffer.subarray(start, newlineIndex)) + start = newlineIndex + 1 + } + return { lines, remainder: buffer.subarray(start) } +} + +async function readRange( + fsImpl: AmplifierTailerFs, + filePath: string, + position: number, + length: number, +): Promise { + const handle = await fsImpl.open(filePath, 'r') + const chunks: Buffer[] = [] + let bytesSeen = 0 + try { + while (bytesSeen < length) { + const chunk = Buffer.alloc(Math.min(READ_CHUNK_BYTES, length - bytesSeen)) + const { bytesRead } = await handle.read(chunk, 0, chunk.length, position + bytesSeen) + if (bytesRead === 0) break + chunks.push(chunk.subarray(0, bytesRead)) + bytesSeen += bytesRead + } + } finally { + await handle.close() + } + return Buffer.concat(chunks) +} + +function defaultFs(): AmplifierTailerFs { + return { + open: (path, flags) => fsp.open(path, flags), + stat: (path) => fsp.stat(path), + } +} + +function errorMessage(error: unknown): string { + return error instanceof Error ? error.message : String(error) +} diff --git a/server/coding-cli/amplifier-session-controller.ts b/server/coding-cli/amplifier-session-controller.ts new file mode 100644 index 00000000..a88cf972 --- /dev/null +++ b/server/coding-cli/amplifier-session-controller.ts @@ -0,0 +1,123 @@ +/** + * AmplifierSessionController — thin bind gatekeeper for locator-discovered + * fresh amplifier sessions (docs/plans/2026-07-08-amplifier-session-durability-plan.md + * §5 step 5, Phase 3). Imitates OpencodeSessionController: validates the + * terminal (exists, mode === 'amplifier', status === 'running', not already + * session-bound), then binds through the shared + * registry.bindSession(terminalId, 'amplifier', sessionId, 'association') path + * and emits 'associated' so index.ts can broadcast via + * broadcastTerminalSessionAssociation({ source: 'amplifier_locator' }) and the + * activity integration can attach the events tailer at offset 0. + * + * Never hand-rolls binding or broadcasts; every reject path is logged. + */ + +import { EventEmitter } from 'events' +import { logger } from '../logger.js' +import type { SessionBindingReason } from '../terminal-stream/registry-events.js' +import type { BindSessionResult } from '../terminal-registry.js' +import type { AmplifierSessionLocatedEvent } from './amplifier-session-locator.js' + +type AmplifierLocatorLike = { + on: (event: 'session.located', handler: (payload: AmplifierSessionLocatedEvent) => void) => unknown + off: (event: 'session.located', handler: (payload: AmplifierSessionLocatedEvent) => void) => unknown +} + +type AmplifierTerminalSnapshot = { + terminalId: string + mode: string + status: string + resumeSessionId?: string +} + +type AmplifierSessionRegistry = { + get: (terminalId: string) => AmplifierTerminalSnapshot | undefined | null + bindSession: ( + terminalId: string, + provider: 'amplifier', + sessionId: string, + reason?: SessionBindingReason, + ) => BindSessionResult +} + +type ControllerLogger = { + warn: (payload: object, message?: string) => void +} + +export type AmplifierSessionAssociatedEvent = { + terminalId: string + sessionId: string + eventsPath: string +} + +export class AmplifierSessionController extends EventEmitter { + private readonly locator: AmplifierLocatorLike + private readonly registry: AmplifierSessionRegistry + private readonly log: ControllerLogger + + private readonly handleLocated = (payload: AmplifierSessionLocatedEvent) => { + this.promote(payload) + } + + constructor(input: { + locator: AmplifierLocatorLike + registry: AmplifierSessionRegistry + log?: ControllerLogger + }) { + super() + this.locator = input.locator + this.registry = input.registry + this.log = input.log ?? logger.child({ component: 'amplifier-session-controller' }) + + this.locator.on('session.located', this.handleLocated) + } + + dispose(): void { + this.locator.off('session.located', this.handleLocated) + } + + private reject( + request: AmplifierSessionLocatedEvent, + reason: string, + extra: Record = {}, + ): void { + this.log.warn({ + terminalId: request.terminalId, + sessionId: request.sessionId, + reason, + ...extra, + }, 'Rejected Amplifier session association') + } + + private promote(request: AmplifierSessionLocatedEvent): void { + const terminal = this.registry.get(request.terminalId) + if (!terminal) { + this.reject(request, 'terminal_missing_or_not_running') + return + } + if (terminal.mode !== 'amplifier') { + this.reject(request, 'terminal_not_amplifier', { mode: terminal.mode }) + return + } + if (terminal.status !== 'running') { + this.reject(request, 'terminal_missing_or_not_running', { status: terminal.status }) + return + } + if (terminal.resumeSessionId) { + this.reject(request, 'terminal_already_bound', { previousSessionId: terminal.resumeSessionId }) + return + } + + const result = this.registry.bindSession(request.terminalId, 'amplifier', request.sessionId, 'association') + if (!result.ok) { + this.reject(request, result.reason, 'owner' in result ? { ownerTerminalId: result.owner } : {}) + return + } + + this.emit('associated', { + terminalId: request.terminalId, + sessionId: request.sessionId, + eventsPath: request.eventsPath, + } satisfies AmplifierSessionAssociatedEvent) + } +} diff --git a/server/coding-cli/amplifier-session-locator.ts b/server/coding-cli/amplifier-session-locator.ts new file mode 100644 index 00000000..9b71ce1f --- /dev/null +++ b/server/coding-cli/amplifier-session-locator.ts @@ -0,0 +1,746 @@ +/** + * AmplifierSessionLocator — deterministic PTY↔session association for FRESH + * amplifier sessions (docs/plans/2026-07-08-amplifier-session-durability-plan.md + * §5, Phase 3). + * + * Amplifier has no launcher-assigned session ID (E9) and session dirs are + * created LAZILY at the first prompt submit (E1), so the locator correlates + * PTY Enter-press ↔ new session dir instead of spawn ↔ dir: + * + * 1. Arm at spawn: amplifier-mode terminals starting WITHOUT a resumeSessionId + * register {terminalId, cwd, spawnedAt} plus a pre-spawn snapshot of the + * existing top-level session dirs (no '_' in name) under + * /projects//sessions/. + * 2. One shared chokidar watcher on projects/ — pre-created with mkdir -p when + * absent (amplifier mkdir -p's it itself, so this is harmless) and NEVER an + * ancestor: walking up (worst case to $HOME) with a deep recursive watch + * exhausts inotify watches. Runs ONLY while ≥1 armed terminal exists. + * Session-indexer hygiene: ignoreInitial, unref'd timers, + * close().catch(() => {}). A persistent watcher error disables the locator + * (single warn); the coordinator slow-path remains. + * 3. On PTY submit (isSubmitInput) at time t, open the correlation window + * [t - AMPLIFIER_DIR_PRE_EPSILON_MS, t + AMPLIFIER_DIR_APPEAR_WINDOW_MS]. + * The pre-epsilon is a clock-jitter/event-reorder allowance ONLY (a dir can + * be observed marginally before the submit event is delivered) — it must + * stay small so dirs created BEFORE the Enter press (foreign sessions) are + * never candidates. A candidate is a new top-level dir (not in the + * snapshot, no '_'), whose events.jsonl opens with session:start WITHOUT + * parent_id (the coordinator's isSubagent guard). + * 4. Confirm cwd via the session:config record's working_dir/project_dir + * (realpath-normalized). Never wait for metadata.json; never guess slugs. + * 5. Resolve at window close: exactly one cwd-confirmed candidate → emit + * 'session.located' (handled by AmplifierSessionController, which binds + * through registry.bindSession). Multiple → refuse and log + * 'amplifier_locator_ambiguous' (coordinator slow-path stays eligible). + * Zero → keep watching (empty-Enter writes nothing, E5). + * + * Dirs may be observed slightly before the submit event and events.jsonl may + * lag the dir (E1/E4): candidacy is time-window based on the observed + * appearance and probes poll briefly until the lifecycle records land. + * + * The locator never binds or broadcasts itself, and it does NOT bypass the + * coordinator slow-path — that fallback stays untouched. + */ + +import { EventEmitter } from 'events' +import fs from 'node:fs' +import fsp from 'node:fs/promises' +import path from 'node:path' +import chokidar from 'chokidar' +import { isSubmitInput } from '../../shared/turn-complete-signal.js' +import { logger } from '../logger.js' +import { + createAmplifierReducerState, + reduceAmplifierEvent, +} from './amplifier-events-reducer.js' +import { + createAmplifierEventsTailer, + type AmplifierTailerFs, +} from './amplifier-events-tailer.js' +import type { + TerminalInputRawEvent, + TerminalSessionBoundEvent, +} from '../terminal-stream/registry-events.js' + +/** Plan Appendix A: observed dir-appearance latency is ~37ms (E1); 50× margin. */ +export const AMPLIFIER_DIR_APPEAR_WINDOW_MS = 2_000 + +/** + * How far BEFORE the Enter press an observed dir may still correlate. Strictly + * a clock-jitter / event-reordering allowance (chokidar may report the dir a + * beat before the registry delivers the submit event) — plan §5 defines the + * window as [t, t+2000], so anything meaningfully older than the Enter is a + * foreign session and must never be a candidate (adversarial finding F). + */ +export const AMPLIFIER_DIR_PRE_EPSILON_MS = 250 + +/** Bounded probe read: session:start + session:config land in the first bytes (E4). */ +const PROBE_MAX_READ_BYTES = 64 * 1024 +const DEFAULT_PROBE_POLL_MS = 100 +/** Discoveries older than this can no longer match any window; safe to prune. */ +const DISCOVERY_RETENTION_WINDOWS = 5 + +export type AmplifierSessionLocatedEvent = { + terminalId: string + sessionId: string + eventsPath: string + sessionDir: string +} + +type LocatorLogger = { + warn: (payload: object, message?: string) => void + info?: (payload: object, message?: string) => void + debug?: (payload: object, message?: string) => void +} + +type LocatorTerminalSnapshot = { + terminalId: string + mode: string + status: string + cwd?: string + resumeSessionId?: string + pendingResumeName?: string +} + +type LocatorRegistry = { + on: (event: string, handler: (...args: any[]) => void) => void + off: (event: string, handler: (...args: any[]) => void) => void + /** Present on the real TerminalRegistry; used for the construction-order catch-up sweep. */ + list?: () => Array<{ terminalId: string }> + get?: (terminalId: string) => LocatorTerminalSnapshot | undefined | null +} + +export type AmplifierLocatorWatcher = { + on(event: string, handler: (...args: any[]) => void): unknown + close(): Promise +} + +export type AmplifierLocatorWatchFactory = ( + watchPath: string, + options: { ignoreInitial: boolean; depth: number }, +) => AmplifierLocatorWatcher + +type ArmedTerminal = { + terminalId: string + cwd: string + cwdNormalized: string + spawnedAt: number + /** Session dirs that existed at arm time — never candidates for this terminal. */ + snapshot: Set + /** Resolves once the realpath'd cwd and the pre-spawn snapshot are captured. */ + ready: Promise + window?: CorrelationWindow +} + +type CorrelationWindow = { + openedAt: number + timer: NodeJS.Timeout + closed: boolean + resolved: boolean + // Finding J: one-shot readdir fallback already performed for this window. + rescanned: boolean +} + +type DiscoveryState = 'pending' | 'confirmed' | 'rejected' + +type Discovery = { + dir: string + name: string + appearedAt: number + state: DiscoveryState + claimed: boolean + sessionId?: string + cwdNormalized?: string + rejectReason?: string + deadlineAt: number + probing: boolean + retryTimer?: NodeJS.Timeout +} + +function normalizeLexicalCwd(input: string): string { + const normalized = input.replace(/\\/g, '/').replace(/\/+$/, '') + return process.platform === 'win32' ? normalized.toLowerCase() : normalized +} + +async function normalizeRealCwd(input: string): Promise { + let resolved = input + try { + resolved = await fsp.realpath(input) + } catch { + // Missing/virtual paths still participate via lexical normalization, + // matching the registry's normalizeSessionCwd behavior. + } + return normalizeLexicalCwd(resolved) +} + +/** Bounded stat so probe reads never chase a fast-growing events.jsonl tail. */ +function cappedProbeFs(maxBytes: number): AmplifierTailerFs { + return { + open: (filePath, flags) => fsp.open(filePath, flags), + stat: async (filePath) => { + const stat = await fsp.stat(filePath) + return { size: Math.min(stat.size, maxBytes) } + }, + } +} + +export class AmplifierSessionLocator extends EventEmitter { + private readonly registry: LocatorRegistry + private readonly projectsDir: string + private readonly log: LocatorLogger + private readonly now: () => number + private readonly windowMs: number + private readonly probeTimeoutMs: number + private readonly probePollMs: number + private readonly watchImpl: AmplifierLocatorWatchFactory + + private readonly armed = new Map() + private readonly discoveries = new Map() + private watcher?: AmplifierLocatorWatcher + private watcherReady: Promise = Promise.resolve() + private resolveChain: Promise = Promise.resolve() + private disposed = false + // Sticky after a watcher error (finding A): the locator self-disables rather + // than log per-event or churn broken watchers. + private watcherFailed = false + + private readonly handleCreated = (record: LocatorTerminalSnapshot) => { + this.arm(record) + } + + private readonly handleInput = (event: TerminalInputRawEvent) => { + this.noteInput(event) + } + + private readonly handleBound = (event: TerminalSessionBoundEvent) => { + if (!event?.terminalId) return + this.disarm(event.terminalId) + } + + private readonly handleExit = (event: { terminalId?: string }) => { + if (!event?.terminalId) return + this.disarm(event.terminalId) + } + + constructor(input: { + registry: LocatorRegistry + amplifierHome: string + log?: LocatorLogger + now?: () => number + windowMs?: number + probeTimeoutMs?: number + probePollMs?: number + watchImpl?: AmplifierLocatorWatchFactory + }) { + super() + this.registry = input.registry + this.projectsDir = path.resolve(path.join(input.amplifierHome, 'projects')) + this.log = input.log ?? logger.child({ component: 'amplifier-session-locator' }) + this.now = input.now ?? (() => Date.now()) + this.windowMs = input.windowMs ?? AMPLIFIER_DIR_APPEAR_WINDOW_MS + this.probeTimeoutMs = input.probeTimeoutMs ?? this.windowMs * 2 + this.probePollMs = input.probePollMs ?? DEFAULT_PROBE_POLL_MS + this.watchImpl = input.watchImpl + ?? ((watchPath, options) => chokidar.watch(watchPath, options)) + + this.registry.on('terminal.created', this.handleCreated) + this.registry.on('terminal.input.raw', this.handleInput) + this.registry.on('terminal.session.bound', this.handleBound) + this.registry.on('terminal.exit', this.handleExit) + + // Construction-order catch-up sweep (finding H, mirroring the activity + // integration): amplifier terminals created BEFORE the locator existed + // would otherwise never arm. `arm()` re-applies the mode/status/resume + // guards, so this is a plain replay of 'terminal.created'. + if (typeof this.registry.list === 'function' && typeof this.registry.get === 'function') { + for (const listed of this.registry.list()) { + const record = this.registry.get(listed.terminalId) + if (record) this.arm(record) + } + } + } + + armedCount(): number { + return this.armed.size + } + + isWatching(): boolean { + return this.watcher !== undefined + } + + /** Test/diagnostic hook: resolves once the watcher and all snapshots are ready. */ + async whenReady(): Promise { + await this.watcherReady + await Promise.all(Array.from(this.armed.values(), (armed) => armed.ready)) + } + + async dispose(): Promise { + if (this.disposed) return + this.disposed = true + this.registry.off('terminal.created', this.handleCreated) + this.registry.off('terminal.input.raw', this.handleInput) + this.registry.off('terminal.session.bound', this.handleBound) + this.registry.off('terminal.exit', this.handleExit) + for (const armed of this.armed.values()) { + if (armed.window) clearTimeout(armed.window.timer) + } + this.armed.clear() + this.clearDiscoveries() + const watcher = this.watcher + this.watcher = undefined + await watcher?.close().catch(() => {}) + } + + // --- Arming ------------------------------------------------------------- + + private arm(record: LocatorTerminalSnapshot): void { + if (this.disposed) return + if (!record || record.mode !== 'amplifier' || record.status !== 'running') return + // Resume terminals (bound at create, or awaiting a named-resume association) + // never arm — the locator only serves fresh sessions (plan §5 step 1). + if (record.resumeSessionId || record.pendingResumeName) return + if (!record.cwd) return + if (this.armed.has(record.terminalId)) return + + const armed: ArmedTerminal = { + terminalId: record.terminalId, + cwd: record.cwd, + cwdNormalized: normalizeLexicalCwd(record.cwd), + spawnedAt: this.now(), + snapshot: new Set(), + ready: Promise.resolve(), + } + armed.ready = (async () => { + armed.cwdNormalized = await normalizeRealCwd(record.cwd!) + armed.snapshot = await this.snapshotSessionDirs() + })().catch(() => {}) + this.armed.set(record.terminalId, armed) + this.ensureWatcher() + } + + private disarm(terminalId: string): void { + const armed = this.armed.get(terminalId) + if (!armed) return + if (armed.window) clearTimeout(armed.window.timer) + this.armed.delete(terminalId) + if (this.armed.size === 0) { + this.stopWatcher() + } + } + + /** + * Pre-spawn snapshot (plan §5 step 1): every existing top-level session dir + * (projects//sessions/, no '_' in the id) — never candidates for + * the terminal being armed. No slug guessing: the whole projects/ tree is + * enumerated at the sessions level. + */ + private async snapshotSessionDirs(): Promise> { + const snapshot = new Set() + let slugs + try { + slugs = await fsp.readdir(this.projectsDir, { withFileTypes: true }) + } catch { + return snapshot + } + for (const slug of slugs) { + if (!slug.isDirectory()) continue + const sessionsDir = path.join(this.projectsDir, slug.name, 'sessions') + let ids + try { + ids = await fsp.readdir(sessionsDir, { withFileTypes: true }) + } catch { + continue + } + for (const id of ids) { + if (!id.isDirectory()) continue + if (id.name.includes('_')) continue + snapshot.add(path.resolve(path.join(sessionsDir, id.name))) + } + } + return snapshot + } + + // --- Watcher ------------------------------------------------------------ + + private ensureWatcher(): void { + if (this.watcher || this.disposed || this.watcherFailed) return + // projects/ is created lazily (E1). Pre-create it (amplifier mkdir -p's it + // itself, so this is harmless) and ALWAYS watch projectsDir at a fixed + // depth — never an ancestor. An ancestor walk can escape amplifierHome + // (worst case $HOME) and a deep recursive watch over that tree exhausts + // inotify watches (adversarial finding A). + try { + fs.mkdirSync(this.projectsDir, { recursive: true }) + } catch (error) { + // Still watch projectsDir only: chokidar tolerates a missing path and + // picks it up if amplifier creates it later. Never watch outside + // amplifierHome. + this.log.warn({ + component: 'amplifier-session-locator', + event: 'amplifier_locator_projects_mkdir_failed', + projectsDir: this.projectsDir, + error: error instanceof Error ? error.message : String(error), + }, 'Could not pre-create the amplifier projects dir for watching') + } + // projects(root)/(+1)/sessions(+2)/(+3) and the files inside the + // session dir at (+4) — chokidar depth counts sublevels. + const depth = 4 + const watcher = this.watchImpl(this.projectsDir, { ignoreInitial: true, depth }) + this.watcher = watcher + this.watcherReady = new Promise((resolve) => { + watcher.on('ready', () => resolve()) + }) + watcher.on('addDir', (dirPath: unknown) => { + if (typeof dirPath !== 'string') return + this.noteDirAppeared(dirPath) + }) + const onFileEvent = (filePath: unknown) => { + if (typeof filePath !== 'string') return + if (path.basename(filePath) !== 'events.jsonl') return + const discovery = this.discoveries.get(path.resolve(path.dirname(filePath))) + if (!discovery) return + if (discovery.state === 'rejected' && discovery.rejectReason === 'probe_timeout') { + // probe_timeout is NOT definitive (finding I): the session's config + // record may simply be slow. New bytes in events.jsonl warrant a + // re-probe. Definitive classifications (subagent / schema / + // unexpected shape) stay rejected. + discovery.state = 'pending' + discovery.rejectReason = undefined + discovery.deadlineAt = this.now() + this.probeTimeoutMs + this.probe(discovery) + return + } + if (discovery.state !== 'pending') return + this.probe(discovery) + } + watcher.on('add', onFileEvent) + watcher.on('change', onFileEvent) + watcher.on('error', (error: unknown) => { + this.handleWatcherError(error) + }) + } + + /** + * Persistent watcher errors self-disable the locator (finding A): a single + * warn, watcher closed, no re-arm. Fresh-session binding falls back to the + * coordinator slow-path, which stays untouched. + */ + private handleWatcherError(error: unknown): void { + if (this.watcherFailed || this.disposed) return + this.watcherFailed = true + this.log.warn({ + component: 'amplifier-session-locator', + event: 'amplifier_locator_watch_error', + error: error instanceof Error ? error.message : String(error), + }, 'Amplifier session locator watcher error; disabling the locator (coordinator slow-path remains)') + this.stopWatcher() + } + + private stopWatcher(): void { + const watcher = this.watcher + this.watcher = undefined + this.watcherReady = Promise.resolve() + this.clearDiscoveries() + void watcher?.close().catch(() => {}) + } + + private clearDiscoveries(): void { + for (const discovery of this.discoveries.values()) { + if (discovery.retryTimer) clearTimeout(discovery.retryTimer) + } + this.discoveries.clear() + } + + private isSessionDirPath(dirPath: string): boolean { + const relative = path.relative(this.projectsDir, dirPath) + if (!relative || relative.startsWith('..') || path.isAbsolute(relative)) return false + const parts = relative.split(path.sep).filter(Boolean) + return parts.length === 3 && parts[1] === 'sessions' + } + + private noteDirAppeared(rawPath: string, appearedAtOverride?: number): void { + if (this.disposed) return + const dir = path.resolve(rawPath) + if (!this.isSessionDirPath(dir)) return + const name = path.basename(dir) + // Underscore-named dirs are sub-session dirs (plan §2): never candidates. + if (name.includes('_')) return + if (this.discoveries.has(dir)) return + + const appearedAt = appearedAtOverride ?? this.now() + this.pruneDiscoveries(appearedAt) + const discovery: Discovery = { + dir, + name, + appearedAt, + state: 'pending', + claimed: false, + deadlineAt: appearedAt + this.probeTimeoutMs, + probing: false, + } + this.discoveries.set(dir, discovery) + this.probe(discovery) + } + + private pruneDiscoveries(now: number): void { + const cutoff = now - this.windowMs * DISCOVERY_RETENTION_WINDOWS + for (const [key, discovery] of this.discoveries) { + if (discovery.appearedAt >= cutoff) continue + if (discovery.retryTimer) clearTimeout(discovery.retryTimer) + this.discoveries.delete(key) + } + } + + // --- Candidate probing ---------------------------------------------------- + + private probe(discovery: Discovery): void { + if (this.disposed || discovery.state !== 'pending' || discovery.probing) return + if (discovery.retryTimer) { + clearTimeout(discovery.retryTimer) + discovery.retryTimer = undefined + } + discovery.probing = true + void this.runProbe(discovery) + .catch(() => { + this.scheduleProbeRetry(discovery) + }) + .finally(() => { + discovery.probing = false + }) + } + + /** + * Reads the first records of /events.jsonl (bounded, via the shared + * tailer + reducer — no duplicated JSONL parsing) and classifies the dir: + * confirmed {sessionId, cwd}, rejected (subagent / schema / unexpected + * shape), or still pending (file/records not there yet — E1/E4 lag). + */ + private async runProbe(discovery: Discovery): Promise { + const eventsPath = path.join(discovery.dir, 'events.jsonl') + try { + await fsp.stat(eventsPath) + } catch { + // events.jsonl appears ~37ms after the dir (E1) — retry until deadline. + this.scheduleProbeRetry(discovery) + return + } + + const tailer = createAmplifierEventsTailer({ + filePath: eventsPath, + attachAt: 'start', + fsImpl: cappedProbeFs(PROBE_MAX_READ_BYTES), + }) + const result = await tailer.read() + if (discovery.state !== 'pending') return + if (!result.ok) { + if (result.reason === 'read_error') { + this.scheduleProbeRetry(discovery) + return + } + this.rejectDiscovery(discovery, result.reason) + return + } + if (result.records.length === 0) { + this.scheduleProbeRetry(discovery) + return + } + // A fresh session's log opens with session:start (plan §5 step 3); + // anything else is not a fresh top-level session. + if (result.records[0]?.event !== 'session:start') { + this.rejectDiscovery(discovery, 'unexpected_first_record') + return + } + + let state = createAmplifierReducerState() + let identified: { sessionId?: string; cwd: string } | undefined + for (const record of result.records) { + const reduced = reduceAmplifierEvent(state, record) + state = reduced.state + if (state.subagent) { + // session:start with parent_id / session:fork — the coordinator's + // isSubagent guard, applied at the source (plan §5 step 3). + this.rejectDiscovery(discovery, 'subagent') + return + } + for (const effect of reduced.effects) { + if (effect.kind === 'lane.degrade') { + this.rejectDiscovery(discovery, effect.reason) + return + } + if (effect.kind === 'session.identified' && !identified) { + identified = { sessionId: effect.sessionId, cwd: effect.cwd } + } + } + } + + if (!identified) { + // session:config lags session:start by ~10ms (E4) — keep polling. + this.scheduleProbeRetry(discovery) + return + } + + discovery.state = 'confirmed' + discovery.sessionId = identified.sessionId ?? state.sessionId ?? discovery.name + discovery.cwdNormalized = await normalizeRealCwd(identified.cwd) + this.scheduleResolve() + } + + private scheduleProbeRetry(discovery: Discovery): void { + if (this.disposed || discovery.state !== 'pending') return + if (this.now() >= discovery.deadlineAt) { + this.rejectDiscovery(discovery, 'probe_timeout') + return + } + if (discovery.retryTimer) return + const timer = setTimeout(() => { + discovery.retryTimer = undefined + this.probe(discovery) + }, this.probePollMs) + timer.unref?.() + discovery.retryTimer = timer + } + + private rejectDiscovery(discovery: Discovery, reason: string): void { + if (discovery.state !== 'pending') return + discovery.state = 'rejected' + discovery.rejectReason = reason + if (discovery.retryTimer) { + clearTimeout(discovery.retryTimer) + discovery.retryTimer = undefined + } + this.log.debug?.({ + component: 'amplifier-session-locator', + dir: discovery.dir, + reason, + }, 'Amplifier session dir excluded from Enter-correlation') + // Windows deferred on this probe can now settle. + this.scheduleResolve() + } + + // --- Correlation windows -------------------------------------------------- + + private noteInput(event: TerminalInputRawEvent): void { + if (this.disposed) return + const armed = this.armed.get(event.terminalId) + if (!armed) return + if (!isSubmitInput(event.data)) return + // One window at a time: mid-turn Enters never re-arm anything (E5). A new + // window may open after the previous one resolved without a binding. + if (armed.window && !armed.window.resolved) return + + const openedAt = event.at ?? this.now() + const window: CorrelationWindow = { + openedAt, + closed: false, + resolved: false, + rescanned: false, + timer: setTimeout(() => { + window.closed = true + this.scheduleResolve() + }, this.windowMs), + } + window.timer.unref?.() + armed.window = window + } + + private scheduleResolve(): void { + this.resolveChain = this.resolveChain + .then(() => this.resolveAllWindows()) + .catch(() => {}) + } + + private async resolveAllWindows(): Promise { + for (const armed of Array.from(this.armed.values())) { + await this.tryResolveWindow(armed) + } + } + + private async tryResolveWindow(armed: ArmedTerminal): Promise { + const window = armed.window + if (!window || !window.closed || window.resolved || this.disposed) return + await armed.ready + + // Pre-epsilon (finding F): a dir may be OBSERVED marginally before the + // submit event is delivered (clock jitter / event reordering), but a dir + // that meaningfully predates the Enter is a foreign session — never bind it. + const lowerBound = window.openedAt - AMPLIFIER_DIR_PRE_EPSILON_MS + const upperBound = window.openedAt + this.windowMs + const eligible = Array.from(this.discoveries.values()).filter((discovery) => ( + !discovery.claimed + && discovery.state !== 'rejected' + && !armed.snapshot.has(discovery.dir) + && discovery.appearedAt >= lowerBound + && discovery.appearedAt <= upperBound + )) + + // Metadata can arrive late (E4): defer until every eligible probe settles. + // Probes self-terminate at their deadline, so this deferral is bounded. + if (eligible.some((discovery) => discovery.state === 'pending')) return + + const matches = eligible.filter((discovery) => ( + discovery.state === 'confirmed' + && discovery.cwdNormalized === armed.cwdNormalized + )) + + if (matches.length === 0 && !window.rescanned) { + // Blind spot (finding J): a dir created DURING chokidar's initial scan is + // swallowed by ignoreInitial and never announced. Before deciding "zero + // candidates", take a one-shot readdir snapshot of projects/*/sessions/* + // and feed unseen dirs through the normal discovery path; their probes + // re-schedule this resolution. + window.rescanned = true + const dirs = await this.snapshotSessionDirs() + let fedAny = false + for (const dir of dirs) { + if (this.discoveries.has(dir)) continue + if (armed.snapshot.has(dir)) continue + // N1 (re-verification): anchoring at window.openedAt would auto-satisfy + // the finding-F eligibility bounds and bind alien pre-Enter dirs. Use + // the dir's fs birth/mtime as appearedAt instead, and reject dirs that + // provably predate the Enter (pre-epsilon). Stat failure ⇒ refuse to + // guess: skip the dir (the coordinator slow-path remains). + let bornAt: number + try { + const stat = await fsp.stat(dir) + const birthMs = stat.birthtimeMs + bornAt = Number.isFinite(birthMs) && birthMs > 0 ? birthMs : stat.mtimeMs + } catch { + continue + } + if (bornAt < window.openedAt - AMPLIFIER_DIR_PRE_EPSILON_MS) continue + this.noteDirAppeared(dir, bornAt) + fedAny = true + } + if (fedAny) return + } + + window.resolved = true + clearTimeout(window.timer) + armed.window = undefined + + if (matches.length === 0) { + // Empty-Enter writes nothing (E5) — keep watching. + return + } + if (matches.length > 1) { + // Never guess: refuse and leave it to the coordinator slow-path. + this.log.warn({ + component: 'amplifier-session-locator', + event: 'amplifier_locator_ambiguous', + terminalId: armed.terminalId, + cwd: armed.cwd, + candidates: matches.map((match) => match.name), + }, 'Multiple cwd-confirmed amplifier session dirs within the correlation window; refusing to bind') + return + } + + const match = matches[0] + match.claimed = true + this.emit('session.located', { + terminalId: armed.terminalId, + sessionId: match.sessionId ?? match.name, + eventsPath: path.join(match.dir, 'events.jsonl'), + sessionDir: match.dir, + } satisfies AmplifierSessionLocatedEvent) + } +} diff --git a/server/coding-cli/claude-activity-tracker.ts b/server/coding-cli/claude-activity-tracker.ts index 56d8942b..459858aa 100644 --- a/server/coding-cli/claude-activity-tracker.ts +++ b/server/coding-cli/claude-activity-tracker.ts @@ -7,6 +7,7 @@ import { type TurnCompleteSignalParserState, } from '../../shared/turn-complete-signal.js' import type { TerminalTurnCompletionSnapshot } from '../../shared/ws-protocol.js' +import { TurnCompletionLedger } from './turn-completion-ledger.js' export const CLAUDE_BUSY_DEADMAN_MS = 120_000 export const CLAUDE_ACTIVITY_SWEEP_MS = 5_000 @@ -59,8 +60,7 @@ type ClaudeTerminalActivity = { */ export class ClaudeActivityTracker extends EventEmitter { private readonly states = new Map() - private readonly completionSeqByTerminalId = new Map() - private readonly latestCompletions = new Map() + private readonly completionLedger = new TurnCompletionLedger() private readonly log?: TrackerLogger constructor(input: { log?: TrackerLogger } = {}) { @@ -78,7 +78,7 @@ export class ClaudeActivityTracker extends EventEmitter { } listLatestCompletions(): TerminalTurnCompletionSnapshot[] { - return Array.from(this.latestCompletions.values()) + return this.completionLedger.listLatestCompletions() } trackTerminal(input: { terminalId: string; sessionId?: string; at: number }): void { @@ -149,7 +149,7 @@ export class ClaudeActivityTracker extends EventEmitter { for (let i = 0; i < clearCount; i += 1) { if (state.inFlight <= 0) break state.inFlight -= 1 - completions.push(this.recordTurnCompletion({ + completions.push(this.completionLedger.recordTurnCompletion({ terminalId: state.terminalId, ...(state.sessionId ? { sessionId: state.sessionId } : {}), at: input.at, @@ -166,24 +166,6 @@ export class ClaudeActivityTracker extends EventEmitter { } } - private recordTurnCompletion(input: { - terminalId: string - sessionId?: string - at: number - }): ClaudeTurnCompleteEvent { - const completionSeq = (this.completionSeqByTerminalId.get(input.terminalId) ?? 0) + 1 - this.completionSeqByTerminalId.set(input.terminalId, completionSeq) - this.latestCompletions.set(input.terminalId, { - terminalId: input.terminalId, - at: input.at, - completionSeq, - }) - return { - ...input, - completionSeq, - } - } - noteExit(input: { terminalId: string }): void { this.removeState(input.terminalId) } diff --git a/server/coding-cli/claude-activity-wiring.ts b/server/coding-cli/claude-activity-wiring.ts index 213fc849..6bd496ad 100644 --- a/server/coding-cli/claude-activity-wiring.ts +++ b/server/coding-cli/claude-activity-wiring.ts @@ -2,92 +2,25 @@ import { CLAUDE_ACTIVITY_SWEEP_MS, ClaudeActivityTracker, } from './claude-activity-tracker.js' -import type { - TerminalInputRawEvent, - TerminalOutputRawEvent, - TerminalSessionBoundEvent, -} from '../terminal-stream/registry-events.js' +import { wirePtyActivityTracker, type ActivityWiringRegistry } from './activity-wiring-factory.js' -type ClaudeTerminalSnapshot = { - terminalId: string - mode: string - status: string -} - -type ClaudeActivityRegistry = { - list: () => Array<{ terminalId: string }> - get: (terminalId: string) => ClaudeTerminalSnapshot | undefined | null - on: (event: string, handler: (...args: any[]) => void) => void - off: (event: string, handler: (...args: any[]) => void) => void -} +type ClaudeActivityRegistry = ActivityWiringRegistry +/** + * Registry→tracker wiring for Claude. Thin wrapper over the shared + * wirePtyActivityTracker factory (Phase 4 consolidation with the amplifier + * wiring); the public surface is unchanged. + */ export function wireClaudeActivityTracker(input: { registry: ClaudeActivityRegistry now?: () => number setIntervalFn?: typeof setInterval clearIntervalFn?: typeof clearInterval }) { - const { - registry, - now = () => Date.now(), - setIntervalFn = setInterval, - clearIntervalFn = clearInterval, - } = input - - const tracker = new ClaudeActivityTracker() - - const startTracking = (record: ClaudeTerminalSnapshot) => { - if (record.mode !== 'claude' || record.status !== 'running') return - tracker.trackTerminal({ terminalId: record.terminalId, at: now() }) - } - - const onCreated = (record: ClaudeTerminalSnapshot) => { - startTracking(record) - } - const onBound = (event: TerminalSessionBoundEvent) => { - if (event.provider !== 'claude') return - // Production emits 'terminal.session.bound' BEFORE 'terminal.created', so a plain - // bindSession would be a no-op (no record yet) and drop the sessionId. Ensure the - // record exists with its sessionId first (trackTerminal is idempotent and updates - // the sessionId on an existing record); a later 'terminal.created' won't clobber it. - tracker.trackTerminal({ terminalId: event.terminalId, sessionId: event.sessionId, at: now() }) - tracker.bindSession({ terminalId: event.terminalId, sessionId: event.sessionId, at: now() }) - } - const onInput = (event: TerminalInputRawEvent) => { - tracker.noteInput({ terminalId: event.terminalId, data: event.data, at: event.at }) - } - const onOutput = (event: TerminalOutputRawEvent) => { - tracker.noteOutput({ terminalId: event.terminalId, data: event.data, at: event.at }) - } - const onExit = (event: { terminalId?: string }) => { - if (!event.terminalId) return - tracker.noteExit({ terminalId: event.terminalId }) - } - - registry.on('terminal.created', onCreated) - registry.on('terminal.session.bound', onBound) - registry.on('terminal.input.raw', onInput) - registry.on('terminal.output.raw', onOutput) - registry.on('terminal.exit', onExit) - - for (const listed of registry.list()) { - const record = registry.get(listed.terminalId) - if (record) startTracking(record) - } - - const sweepTimer = setIntervalFn(() => { - tracker.expire(now()) - }, CLAUDE_ACTIVITY_SWEEP_MS) - - return { - tracker, - dispose(): void { - registry.off('terminal.created', onCreated) - registry.off('terminal.session.bound', onBound) - registry.off('terminal.input.raw', onInput) - registry.off('terminal.output.raw', onOutput) - registry.off('terminal.exit', onExit) - clearIntervalFn(sweepTimer) - }, - } + return wirePtyActivityTracker({ + mode: 'claude', + tracker: new ClaudeActivityTracker(), + sweepIntervalMs: CLAUDE_ACTIVITY_SWEEP_MS, + ...input, + }) } diff --git a/server/coding-cli/codex-activity-tracker.ts b/server/coding-cli/codex-activity-tracker.ts index bcbc8527..525de109 100644 --- a/server/coding-cli/codex-activity-tracker.ts +++ b/server/coding-cli/codex-activity-tracker.ts @@ -13,6 +13,7 @@ import type { SessionBindingReason, } from '../terminal-stream/registry-events.js' import type { CodingCliSession, ProjectGroup } from './types.js' +import { TurnCompletionLedger } from './turn-completion-ledger.js' export const PENDING_SUBMIT_GATE_MS = 6000 export const PENDING_SNAPSHOT_GRACE_MS = 15000 @@ -95,8 +96,7 @@ function buildProjectIndex(projects: ProjectGroup[]): Map() - private readonly completionSeqByTerminalId = new Map() - private readonly latestCompletions = new Map() + private readonly completionLedger = new TurnCompletionLedger() private pendingCompletions: CodexTurnCompleteEvent[] = [] list(): CodexActivityRecord[] { @@ -108,7 +108,7 @@ export class CodexActivityTracker extends EventEmitter { } listLatestCompletions(): TerminalTurnCompletionSnapshot[] { - return Array.from(this.latestCompletions.values()) + return this.completionLedger.listLatestCompletions() } isPromptBlocked(terminalId: string, at?: number): boolean { @@ -444,7 +444,7 @@ export class CodexActivityTracker extends EventEmitter { if (state.phase !== 'idle') return if (state.lastEmittedTurnKey === turnKey) return state.lastEmittedTurnKey = turnKey - this.pendingCompletions.push(this.recordTurnCompletion({ + this.pendingCompletions.push(this.completionLedger.recordTurnCompletion({ terminalId: state.terminalId, ...(state.sessionId ? { sessionId: state.sessionId } : {}), at, @@ -460,24 +460,6 @@ export class CodexActivityTracker extends EventEmitter { } } - private recordTurnCompletion(input: { - terminalId: string - sessionId?: string - at: number - }): CodexTurnCompleteEvent { - const completionSeq = (this.completionSeqByTerminalId.get(input.terminalId) ?? 0) + 1 - this.completionSeqByTerminalId.set(input.terminalId, completionSeq) - this.latestCompletions.set(input.terminalId, { - terminalId: input.terminalId, - at: input.at, - completionSeq, - }) - return { - ...input, - completionSeq, - } - } - private refreshExistingBinding( state: CodexTerminalActivity, input: { diff --git a/server/coding-cli/opencode-activity-tracker.ts b/server/coding-cli/opencode-activity-tracker.ts index 4266c1f7..6e935e08 100644 --- a/server/coding-cli/opencode-activity-tracker.ts +++ b/server/coding-cli/opencode-activity-tracker.ts @@ -13,6 +13,7 @@ import { type OpencodeOwnershipAction, type OpencodeOwnershipState, } from './opencode-ownership-reducer.js' +import { TurnCompletionLedger } from './turn-completion-ledger.js' export const OPENCODE_HEALTH_POLL_MS = 200 // Applies per health-wait cycle; connection failures restart the cycle after backoff. @@ -204,8 +205,7 @@ const defaultResolveOpencodeSessionRoots = async ( export class OpencodeActivityTracker extends EventEmitter { private readonly records = new Map() - private readonly completionSeqByTerminalId = new Map() - private readonly latestCompletions = new Map() + private readonly completionLedger = new TurnCompletionLedger() private readonly monitors = new Map() private readonly childSessionIds = new Map>() private readonly sessionRootsByTerminal = new Map>() @@ -255,7 +255,7 @@ export class OpencodeActivityTracker extends EventEmitter { } listLatestCompletions(): TerminalTurnCompletionSnapshot[] { - return Array.from(this.latestCompletions.values()) + return this.completionLedger.listLatestCompletions() } expire(at = this.now()): void { @@ -635,7 +635,7 @@ export class OpencodeActivityTracker extends EventEmitter { continue } if (action.kind === 'turnComplete') { - this.emit('turn.complete', this.recordTurnCompletion({ + this.emit('turn.complete', this.completionLedger.recordTurnCompletion({ terminalId, sessionId: action.sessionId, at: action.at, @@ -651,24 +651,6 @@ export class OpencodeActivityTracker extends EventEmitter { } } - private recordTurnCompletion(input: { - terminalId: string - sessionId: string - at: number - }): OpencodeTurnCompleteEvent { - const completionSeq = (this.completionSeqByTerminalId.get(input.terminalId) ?? 0) + 1 - this.completionSeqByTerminalId.set(input.terminalId, completionSeq) - this.latestCompletions.set(input.terminalId, { - terminalId: input.terminalId, - at: input.at, - completionSeq, - }) - return { - ...input, - completionSeq, - } - } - private async sleepWithBackoff(monitor: MonitorState): Promise { const baseDelay = monitor.reconnectDelayMs const jitter = Math.floor(baseDelay * 0.1 * this.random()) diff --git a/server/coding-cli/provider.ts b/server/coding-cli/provider.ts index 7a5e6460..8985b8bd 100644 --- a/server/coding-cli/provider.ts +++ b/server/coding-cli/provider.ts @@ -28,6 +28,10 @@ export interface CodingCliProvider { * grows even though the primary session file is unchanged. */ getActivityMtimeMs?(filePath: string): Promise + /** Absolute path of the live lifecycle event log sibling to the given canonical + * session file, if this provider maintains one. Enables event-driven activity + * tracking without hardcoding sidecar layouts outside the provider. */ + getLiveEventsPath?(filePath: string): string | undefined getSessionWatchBases?(): string[] listSessionFiles(): Promise parseSessionFile(content: string, filePath: string): Promise diff --git a/server/coding-cli/providers/amplifier.ts b/server/coding-cli/providers/amplifier.ts index 7867c070..c72b7911 100644 --- a/server/coding-cli/providers/amplifier.ts +++ b/server/coding-cli/providers/amplifier.ts @@ -200,6 +200,20 @@ export const amplifierProvider: CodingCliProvider = { return maxDefined(...mtimes) }, + getLiveEventsPath(filePath: string): string | undefined { + // Same sidecar-layout knowledge as getActivityMtimeMs: a session dir holds + // metadata.json + transcript.jsonl + events.jsonl side by side. + return path.join(path.dirname(filePath), 'events.jsonl') + }, + + // INDEXER POLICY for metadata-less dirs (plan §7, Phase 0 finding E6): a session + // dir containing only events.jsonl (process killed before its first + // prompt:complete) never gains metadata.json and is NOT resumable. Discovery + // deliberately keys on metadata.json, so such dirs are skipped — this is + // explicit, intentional behavior, not an accident. Live visibility while such a + // session is running comes from the activity pipeline (events.jsonl tailer), + // never from the index; the sidebar entry appears naturally when metadata.json + // lands at the first prompt:complete. async listSessionFiles() { const projectsDir = path.join(this.homeDir, 'projects') const files = await walkMetadataFiles(projectsDir) diff --git a/server/coding-cli/turn-completion-ledger.ts b/server/coding-cli/turn-completion-ledger.ts new file mode 100644 index 00000000..4df71ede --- /dev/null +++ b/server/coding-cli/turn-completion-ledger.ts @@ -0,0 +1,47 @@ +import type { TerminalTurnCompletionSnapshot } from '../../shared/ws-protocol.js' + +/** + * Provider-scoped turn-completion ledger, keyed by terminalId (plan + * docs/plans/2026-07-08-amplifier-session-durability-plan.md §9 Phase 4). + * + * Extracted byte-for-byte from the machinery that was quadruplicated across the + * claude/codex/opencode/amplifier activity trackers (`completionSeqByTerminalId` + * + `latestCompletions` + `recordTurnCompletion` + `listLatestCompletions`). + * Each tracker owns one ledger instance, so `completionSeq` stays scoped per + * provider per terminal — exactly the contract of + * `TerminalTurnCompletionSnapshot` (`shared/ws-protocol.ts`). + * + * Deliberately NO per-terminal cleanup on terminal removal (none of the four + * trackers ever cleared these maps on noteExit/untrackTerminal): the sequence + * stays monotonic if the same terminalId completes again after a re-track, and + * the latest snapshot outlives the activity record so late-attaching clients + * still receive it. + */ +export class TurnCompletionLedger { + private readonly completionSeqByTerminalId = new Map() + private readonly latestCompletions = new Map() + + /** + * Assign the next monotonic completionSeq for the terminal, remember the + * latest snapshot, and return the caller's event payload with the seq + * appended — `{ ...input, completionSeq }`, key order preserved. + */ + recordTurnCompletion(input: T): T & { completionSeq: number } { + const completionSeq = (this.completionSeqByTerminalId.get(input.terminalId) ?? 0) + 1 + this.completionSeqByTerminalId.set(input.terminalId, completionSeq) + this.latestCompletions.set(input.terminalId, { + terminalId: input.terminalId, + at: input.at, + completionSeq, + }) + return { + ...input, + completionSeq, + } + } + + /** Latest completion snapshot per terminal, in first-completion order. */ + listLatestCompletions(): TerminalTurnCompletionSnapshot[] { + return Array.from(this.latestCompletions.values()) + } +} diff --git a/server/index.ts b/server/index.ts index 75790976..779c60a0 100644 --- a/server/index.ts +++ b/server/index.ts @@ -24,6 +24,9 @@ import { CodingCliSessionManager } from './coding-cli/session-manager.js' import { wireCodexActivityTracker } from './coding-cli/codex-activity-wiring.js' import { wireClaudeActivityTracker } from './coding-cli/claude-activity-wiring.js' import { wireAmplifierActivityTracker } from './coding-cli/amplifier-activity-wiring.js' +import { createAmplifierActivityIntegration } from './coding-cli/amplifier-activity-integration.js' +import { AmplifierSessionLocator } from './coding-cli/amplifier-session-locator.js' +import { AmplifierSessionController } from './coding-cli/amplifier-session-controller.js' import { createOpencodeActivityIntegration } from './coding-cli/opencode-activity-integration.js' import { claudeProvider } from './coding-cli/providers/claude.js' import { codexProvider } from './coding-cli/providers/codex.js' @@ -252,6 +255,27 @@ async function main() { const codexActivity = wireCodexActivityTracker({ registry, codingCliIndexer }) const claudeActivity = wireClaudeActivityTracker({ registry }) const amplifierActivity = wireAmplifierActivityTracker({ registry }) + // Events layer (plan 2026-07-08 §6/§9 Phase 2): tailer→reducer→tracker on + // top of the PTY wiring above. Composition only — path discovery goes + // through the indexer + the amplifier provider capability. + const amplifierEventsIntegration = createAmplifierActivityIntegration({ + registry, + tracker: amplifierActivity.tracker, + resolveEventsPath: (sessionId) => { + const sessionFilePath = codingCliIndexer.getFilePathForSession(sessionId, 'amplifier') + if (!sessionFilePath) return undefined + return amplifierProvider.getLiveEventsPath?.(sessionFilePath) + }, + log: logger, + }) + // Phase 3 (plan 2026-07-08 §5): deterministic PTY↔session association for + // fresh amplifier sessions via first-prompt correlation; the coordinator + // slow-path below stays untouched as fallback. + const amplifierSessionLocator = new AmplifierSessionLocator({ + registry, + amplifierHome: amplifierProvider.homeDir, + }) + const amplifierSessionController = new AmplifierSessionController({ registry, locator: amplifierSessionLocator }) const opencodeActivity = createOpencodeActivityIntegration({ registry, opencodeProvider }) const sessionRepairService = getSessionRepairService({ skipDiscovery: true }) @@ -575,6 +599,25 @@ async function main() { log.warn({ err, terminalId, sessionId }, 'Failed to broadcast OpenCode session association') } }) + amplifierSessionController.on('associated', ({ terminalId, sessionId, eventsPath }) => { + try { + broadcastTerminalSessionAssociation({ + wsHandler, + terminalMetadata, + broadcastTerminalMetaUpserts, + provider: 'amplifier', + terminalId, + sessionId, + source: 'amplifier_locator', + }) + } catch (err) { + log.warn({ err, terminalId, sessionId }, 'Failed to broadcast Amplifier session association') + } + // Fresh session: attach the events tailer at offset 0 (plan §5 step 6). The + // indexer cannot resolve this path yet — metadata.json only lands at the + // first prompt:complete — so the locator-discovered path is passed through. + void amplifierEventsIntegration.attachTailer(terminalId, sessionId, eventsPath, 'start') + }) const broadcastTerminalMetaUpserts = (upsert: ReturnType) => { if (upsert.length === 0) return @@ -907,16 +950,19 @@ async function main() { }) }) - // Fast-path session association for newly discovered Claude sessions. - // Most providers now associate from onUpdate, but onNewSession still reduces the - // delay before a freshly discovered Claude session binds to a matching terminal. + // Fast-path session association for newly discovered Claude and Amplifier + // sessions. Most providers now associate from onUpdate, but onNewSession still + // reduces the delay before a freshly discovered session binds to a matching + // terminal. For amplifier this is the plan 2026-07-08 §5 step 8 safety net + // (fires when metadata.json lands if the locator missed). // // Broadcast message type: { type: 'terminal.session.associated', terminalId: string, sessionId: string } codingCliIndexer.onNewSession((session) => { - if (session.provider !== 'claude') return + if (session.provider !== 'claude' && session.provider !== 'amplifier') return if (!session.cwd) return + const provider = session.provider const shouldAssociate = associationCoordinator.noteSession({ - provider: 'claude', + provider, sessionId: session.sessionId, projectPath: session.projectPath, lastActivityAt: session.lastActivityAt, @@ -924,7 +970,7 @@ async function main() { }) if (!shouldAssociate) return const result = associationCoordinator.associateSingleSession({ - provider: 'claude', + provider, sessionId: session.sessionId, projectPath: session.projectPath, lastActivityAt: session.lastActivityAt, @@ -934,7 +980,7 @@ async function main() { const terminalId = result.terminalId log.info({ event: 'session_bind_applied', - provider: 'claude', + provider, terminalId, sessionId: session.sessionId, }, 'session_bind_applied') @@ -943,24 +989,24 @@ async function main() { wsHandler, terminalMetadata, broadcastTerminalMetaUpserts, - provider: 'claude', + provider, terminalId, sessionId: session.sessionId, - source: 'claude_new_session', + source: provider === 'claude' ? 'claude_new_session' : 'amplifier_new_session', }) } catch (err) { log.warn({ err, terminalId, sessionId: session.sessionId }, 'Failed to broadcast session association') } void (async () => { - const latestClaudeSession = findCodingCliSession('claude', session.sessionId) - if (!latestClaudeSession) return - const upsert = await terminalMetadata.applySessionMetadata(terminalId, latestClaudeSession) + const latestSession = findCodingCliSession(provider, session.sessionId) + if (!latestSession) return + const upsert = await terminalMetadata.applySessionMetadata(terminalId, latestSession) if (upsert) { broadcastTerminalMetaUpserts([upsert]) } })().catch((err) => { - log.warn({ err, terminalId, sessionId: session.sessionId }, 'Failed to apply Claude terminal metadata after association') + log.warn({ err, terminalId, sessionId: session.sessionId }, 'Failed to apply terminal metadata after association') }) }) @@ -1184,6 +1230,9 @@ async function main() { codexActivity.dispose() claudeActivity.dispose() amplifierActivity.dispose() + await amplifierEventsIntegration.dispose() + amplifierSessionController.dispose() + await amplifierSessionLocator.dispose() opencodeActivity.dispose() // 10. Stop session repair service diff --git a/server/session-association-broadcast.ts b/server/session-association-broadcast.ts index a1e054c8..302916a2 100644 --- a/server/session-association-broadcast.ts +++ b/server/session-association-broadcast.ts @@ -6,7 +6,14 @@ import type { TerminalSeedRecord, } from './terminal-metadata-service.js' -type AssociationBroadcastSource = 'indexer_update' | 'claude_new_session' | 'opencode_controller' | 'codex_durability' +/** Single source of truth — session-observability.ts imports this type. */ +export type AssociationBroadcastSource = + | 'indexer_update' + | 'claude_new_session' + | 'opencode_controller' + | 'codex_durability' + | 'amplifier_locator' + | 'amplifier_new_session' export type AssociationPublicationStatus = | 'published' diff --git a/server/session-observability.ts b/server/session-observability.ts index 33f069d1..e529be16 100644 --- a/server/session-observability.ts +++ b/server/session-observability.ts @@ -1,5 +1,8 @@ import { sessionLifecycleLogger } from './logger.js' import type { CodingCliProviderName } from './coding-cli/types.js' +// Type-only import (erased at compile time), so the runtime dependency stays +// one-way: session-association-broadcast.ts -> session-observability.ts. +import type { AssociationBroadcastSource } from './session-association-broadcast.js' import type { TerminalMode } from './terminal-registry.js' type SessionLifecycleSink = Pick @@ -94,7 +97,7 @@ export type SessionLifecycleEvent = provider: CodingCliProviderName terminalId: string sessionId: string - source: 'indexer_update' | 'claude_new_session' | 'opencode_controller' | 'codex_durability' + source: AssociationBroadcastSource } | { kind: 'terminal_session_bound' diff --git a/test/fixtures/coding-cli/amplifier/events/README.md b/test/fixtures/coding-cli/amplifier/events/README.md new file mode 100644 index 00000000..55bf8bd4 --- /dev/null +++ b/test/fixtures/coding-cli/amplifier/events/README.md @@ -0,0 +1,34 @@ +# Amplifier `events.jsonl` fixtures (Phase 0 captures) + +Converted from the Phase 0 live captures against `amplifier 2026.07.06-7ec5dcd` +(core 1.6.0), per `docs/plans/2026-07-08-amplifier-session-durability-plan.md` +(§2, §9 Phase 1, Appendix B). Source trees: `~/.amplifier/projects/-tmp-amp-p0-{a..f}/sessions/*/events.jsonl`. + +## Scrubbing + +Large raw payloads (LLM request/response bodies, agent-config blobs, tool +inputs/outputs, prompt text) were removed from `data`. Preserved verbatim: +event names, record order, `session_id`, `schema` objects, `ts`, `lvl`, +`redaction`, and `data.parent_id`. For `session:config` records the +`data.raw.project_dir` / `working_dir` / `project_slug` keys are preserved +(the locator's cwd-confirm inputs, plan §5 step 4). Scrubbed records carry +`"scrubbed": "[payload removed for fixture]"` where payload keys were removed. + +## Fixtures + +| File | Source | Shape | +|---|---|---| +| `normal-turn.jsonl` | `-tmp-amp-p0-b` lines 1–20 | Normal single no-tool turn: `session:start` → `session:config` → `prompt:submit` → execution → `prompt:complete` → `session:end` (E2). | +| `tool-turn-out-of-order-end.jsonl` | `-tmp-amp-p0-c` lines 1–31 | Tool turn: `tool:pre`/`tool:post` with a second `provider:request` iteration inside one execution; post-complete background-naming `llm:request` + `provider:retry`×3; **out-of-order** `session:end` (its `ts` predates the retries that appear before it) (E3). | +| `kill9-orphan.jsonl` | `-tmp-amp-p0-e` lines 1–11 | `kill -9` before first `prompt:complete`: file ends at `tool:pre`, no `session:end`, no `metadata.json` ever (E6). | +| `resume-append.jsonl` | `-tmp-amp-p0-b` lines 21–48 | Resume append pass: `session:resume` → `session:config` → full turn → post-complete `llm:request` + `provider:retry`×4 → out-of-order `session:end` → a second `cleanup:finally_*`/`session:end` group (E7/E3). Appends to the same file as `normal-turn.jsonl`. | +| `steering-injection.jsonl` | `-tmp-amp-p0-d` session `5f91a6ca…` lines 1–30 | Mid-turn typing queued as steering: `orchestrator:steering_injected` inside a single `prompt:submit`/`prompt:complete` pair (E5). | +| `continue-attach-orphan-end.jsonl` | `-tmp-amp-p0-b` lines 46–48 | `continue`-attach-then-quit pass: orphan `session:end` with **no** `session:start`/`session:resume` in the pass (E7). | +| `pty-hangup-completes.jsonl` | **synthesized** (modeled on `normal-turn.jsonl`) | PTY hangup, E7 **first clause**: amplifier finishes the turn (`prompt:submit` → execution → `prompt:complete`) and then writes `session:end` promptly. Session id and record shapes follow the real-capture format. | + +All required shapes except `pty-hangup-completes.jsonl` existed in the real +captures. That fixture was synthesized from the `normal-turn.jsonl` record +format (adversarial-review finding O) because the raw E7 hangup capture was +not converted during Phase 1. Synthetic records used by tests +(schema-mismatch, `session:fork`, subagent `parent_id` starts) are constructed +inline in the test files from the real record format. diff --git a/test/fixtures/coding-cli/amplifier/events/continue-attach-orphan-end.jsonl b/test/fixtures/coding-cli/amplifier/events/continue-attach-orphan-end.jsonl new file mode 100644 index 00000000..ed10c6e7 --- /dev/null +++ b/test/fixtures/coding-cli/amplifier/events/continue-attach-orphan-end.jsonl @@ -0,0 +1,3 @@ +{"ts": "2026-07-08T16:02:43.157049338+00:00", "lvl": "INFO", "schema": {"name": "amplifier.log", "ver": "1.0.0"}, "event": "cleanup:finally_begin", "session_id": "1326337c-f0fb-49ff-9ff6-06722c5e0bab", "data": {"parent_id": null}} +{"ts": "2026-07-08T16:02:43.159587467+00:00", "lvl": "INFO", "schema": {"name": "amplifier.log", "ver": "1.0.0"}, "event": "session:end", "redaction": {"applied": true, "rules": ["secrets", "pii-basic"]}, "session_id": "1326337c-f0fb-49ff-9ff6-06722c5e0bab", "data": {"parent_id": null}} +{"ts": "2026-07-08T16:02:43.161258748+00:00", "lvl": "INFO", "schema": {"name": "amplifier.log", "ver": "1.0.0"}, "event": "cleanup:finally_end", "session_id": "1326337c-f0fb-49ff-9ff6-06722c5e0bab", "data": {"parent_id": null}} diff --git a/test/fixtures/coding-cli/amplifier/events/kill9-orphan.jsonl b/test/fixtures/coding-cli/amplifier/events/kill9-orphan.jsonl new file mode 100644 index 00000000..07c4adfd --- /dev/null +++ b/test/fixtures/coding-cli/amplifier/events/kill9-orphan.jsonl @@ -0,0 +1,11 @@ +{"ts": "2026-07-08T16:00:42.802854834+00:00", "lvl": "INFO", "schema": {"name": "amplifier.log", "ver": "1.0.0"}, "event": "session:start", "redaction": {"applied": true, "rules": ["secrets", "pii-basic"]}, "session_id": "a8eeb4e3-cb84-4a10-9adc-087037e5bd6b", "data": {"parent_id": null}} +{"ts": "2026-07-08T16:00:42.816347417+00:00", "lvl": "INFO", "schema": {"name": "amplifier.log", "ver": "1.0.0"}, "event": "session:config", "session_id": "a8eeb4e3-cb84-4a10-9adc-087037e5bd6b", "data": {"parent_id": null, "raw": {"project_dir": "/tmp/amp-p0-e", "working_dir": "/tmp/amp-p0-e", "project_slug": "-tmp-amp-p0-e"}}} +{"ts": "2026-07-08T16:00:42.818827236+00:00", "lvl": "INFO", "schema": {"name": "amplifier.log", "ver": "1.0.0"}, "event": "prompt:submit", "redaction": {"applied": true, "rules": ["secrets", "pii-basic"]}, "session_id": "a8eeb4e3-cb84-4a10-9adc-087037e5bd6b", "data": {"parent_id": null, "scrubbed": "[payload removed for fixture]"}} +{"ts": "2026-07-08T16:00:42.821064306+00:00", "lvl": "INFO", "schema": {"name": "amplifier.log", "ver": "1.0.0"}, "event": "execution:start", "session_id": "a8eeb4e3-cb84-4a10-9adc-087037e5bd6b", "data": {"parent_id": null, "scrubbed": "[payload removed for fixture]"}} +{"ts": "2026-07-08T16:00:42.822264318+00:00", "lvl": "INFO", "schema": {"name": "amplifier.log", "ver": "1.0.0"}, "event": "provider:request", "redaction": {"applied": true, "rules": ["secrets", "pii-basic"]}, "session_id": "a8eeb4e3-cb84-4a10-9adc-087037e5bd6b", "data": {"parent_id": null, "scrubbed": "[payload removed for fixture]"}} +{"ts": "2026-07-08T16:00:42.834186489+00:00", "lvl": "INFO", "schema": {"name": "amplifier.log", "ver": "1.0.0"}, "event": "mentions:resolved", "session_id": "a8eeb4e3-cb84-4a10-9adc-087037e5bd6b", "data": {"parent_id": null, "scrubbed": "[payload removed for fixture]"}} +{"ts": "2026-07-08T16:00:43.130868952+00:00", "lvl": "INFO", "schema": {"name": "amplifier.log", "ver": "1.0.0"}, "event": "llm:request", "redaction": {"applied": true, "rules": ["secrets", "pii-basic"]}, "session_id": "a8eeb4e3-cb84-4a10-9adc-087037e5bd6b", "data": {"parent_id": null, "scrubbed": "[payload removed for fixture]"}} +{"ts": "2026-07-08T16:00:47.450433044+00:00", "lvl": "INFO", "schema": {"name": "amplifier.log", "ver": "1.0.0"}, "event": "llm:response", "duration_ms": 4305, "redaction": {"applied": true, "rules": ["secrets", "pii-basic"]}, "session_id": "a8eeb4e3-cb84-4a10-9adc-087037e5bd6b", "status": "ok", "data": {"parent_id": null, "scrubbed": "[payload removed for fixture]"}} +{"ts": "2026-07-08T16:00:47.453104222+00:00", "lvl": "INFO", "schema": {"name": "amplifier.log", "ver": "1.0.0"}, "event": "content_block:start", "session_id": "a8eeb4e3-cb84-4a10-9adc-087037e5bd6b", "data": {"parent_id": null, "scrubbed": "[payload removed for fixture]"}} +{"ts": "2026-07-08T16:00:47.454516924+00:00", "lvl": "INFO", "schema": {"name": "amplifier.log", "ver": "1.0.0"}, "event": "content_block:end", "redaction": {"applied": true, "rules": ["secrets", "pii-basic"]}, "session_id": "a8eeb4e3-cb84-4a10-9adc-087037e5bd6b", "data": {"parent_id": null, "scrubbed": "[payload removed for fixture]"}} +{"ts": "2026-07-08T16:00:47.456392825+00:00", "lvl": "INFO", "schema": {"name": "amplifier.log", "ver": "1.0.0"}, "event": "tool:pre", "session_id": "a8eeb4e3-cb84-4a10-9adc-087037e5bd6b", "data": {"parent_id": null, "scrubbed": "[payload removed for fixture]"}} diff --git a/test/fixtures/coding-cli/amplifier/events/normal-turn.jsonl b/test/fixtures/coding-cli/amplifier/events/normal-turn.jsonl new file mode 100644 index 00000000..ef559690 --- /dev/null +++ b/test/fixtures/coding-cli/amplifier/events/normal-turn.jsonl @@ -0,0 +1,20 @@ +{"ts": "2026-07-08T15:50:50.757003704+00:00", "lvl": "INFO", "schema": {"name": "amplifier.log", "ver": "1.0.0"}, "event": "session:start", "redaction": {"applied": true, "rules": ["secrets", "pii-basic"]}, "session_id": "1326337c-f0fb-49ff-9ff6-06722c5e0bab", "data": {"parent_id": null}} +{"ts": "2026-07-08T15:50:50.767120714+00:00", "lvl": "INFO", "schema": {"name": "amplifier.log", "ver": "1.0.0"}, "event": "session:config", "session_id": "1326337c-f0fb-49ff-9ff6-06722c5e0bab", "data": {"parent_id": null, "raw": {"project_dir": "/tmp/amp-p0-b", "working_dir": "/tmp/amp-p0-b", "project_slug": "-tmp-amp-p0-b"}}} +{"ts": "2026-07-08T15:50:50.768798476+00:00", "lvl": "INFO", "schema": {"name": "amplifier.log", "ver": "1.0.0"}, "event": "prompt:submit", "redaction": {"applied": true, "rules": ["secrets", "pii-basic"]}, "session_id": "1326337c-f0fb-49ff-9ff6-06722c5e0bab", "data": {"parent_id": null, "scrubbed": "[payload removed for fixture]"}} +{"ts": "2026-07-08T15:50:50.770467743+00:00", "lvl": "INFO", "schema": {"name": "amplifier.log", "ver": "1.0.0"}, "event": "execution:start", "session_id": "1326337c-f0fb-49ff-9ff6-06722c5e0bab", "data": {"parent_id": null, "scrubbed": "[payload removed for fixture]"}} +{"ts": "2026-07-08T15:50:50.771194978+00:00", "lvl": "INFO", "schema": {"name": "amplifier.log", "ver": "1.0.0"}, "event": "provider:request", "redaction": {"applied": true, "rules": ["secrets", "pii-basic"]}, "session_id": "1326337c-f0fb-49ff-9ff6-06722c5e0bab", "data": {"parent_id": null, "scrubbed": "[payload removed for fixture]"}} +{"ts": "2026-07-08T15:50:50.782120064+00:00", "lvl": "INFO", "schema": {"name": "amplifier.log", "ver": "1.0.0"}, "event": "mentions:resolved", "session_id": "1326337c-f0fb-49ff-9ff6-06722c5e0bab", "data": {"parent_id": null, "scrubbed": "[payload removed for fixture]"}} +{"ts": "2026-07-08T15:50:51.202648084+00:00", "lvl": "INFO", "schema": {"name": "amplifier.log", "ver": "1.0.0"}, "event": "llm:request", "redaction": {"applied": true, "rules": ["secrets", "pii-basic"]}, "session_id": "1326337c-f0fb-49ff-9ff6-06722c5e0bab", "data": {"parent_id": null, "scrubbed": "[payload removed for fixture]"}} +{"ts": "2026-07-08T15:50:54.991832175+00:00", "lvl": "INFO", "schema": {"name": "amplifier.log", "ver": "1.0.0"}, "event": "llm:response", "duration_ms": 3778, "redaction": {"applied": true, "rules": ["secrets", "pii-basic"]}, "session_id": "1326337c-f0fb-49ff-9ff6-06722c5e0bab", "status": "ok", "data": {"parent_id": null, "scrubbed": "[payload removed for fixture]"}} +{"ts": "2026-07-08T15:50:54.994756539+00:00", "lvl": "INFO", "schema": {"name": "amplifier.log", "ver": "1.0.0"}, "event": "content_block:start", "session_id": "1326337c-f0fb-49ff-9ff6-06722c5e0bab", "data": {"parent_id": null, "scrubbed": "[payload removed for fixture]"}} +{"ts": "2026-07-08T15:50:54.996013330+00:00", "lvl": "INFO", "schema": {"name": "amplifier.log", "ver": "1.0.0"}, "event": "content_block:end", "redaction": {"applied": true, "rules": ["secrets", "pii-basic"]}, "session_id": "1326337c-f0fb-49ff-9ff6-06722c5e0bab", "data": {"parent_id": null, "scrubbed": "[payload removed for fixture]"}} +{"ts": "2026-07-08T15:50:54.998026843+00:00", "lvl": "INFO", "schema": {"name": "amplifier.log", "ver": "1.0.0"}, "event": "execution:end", "session_id": "1326337c-f0fb-49ff-9ff6-06722c5e0bab", "data": {"parent_id": null}} +{"ts": "2026-07-08T15:50:54.998904223+00:00", "lvl": "INFO", "schema": {"name": "amplifier.log", "ver": "1.0.0"}, "event": "orchestrator:complete", "session_id": "1326337c-f0fb-49ff-9ff6-06722c5e0bab", "status": "success", "data": {"parent_id": null, "scrubbed": "[payload removed for fixture]"}} +{"ts": "2026-07-08T15:50:55.026706606+00:00", "lvl": "INFO", "schema": {"name": "amplifier.log", "ver": "1.0.0"}, "event": "cleanup:render_begin", "session_id": "1326337c-f0fb-49ff-9ff6-06722c5e0bab", "data": {"parent_id": null}} +{"ts": "2026-07-08T15:50:55.029286303+00:00", "lvl": "INFO", "schema": {"name": "amplifier.log", "ver": "1.0.0"}, "event": "cleanup:render_end", "session_id": "1326337c-f0fb-49ff-9ff6-06722c5e0bab", "data": {"parent_id": null}} +{"ts": "2026-07-08T15:50:55.030583685+00:00", "lvl": "INFO", "schema": {"name": "amplifier.log", "ver": "1.0.0"}, "event": "prompt:complete", "redaction": {"applied": true, "rules": ["secrets", "pii-basic"]}, "session_id": "1326337c-f0fb-49ff-9ff6-06722c5e0bab", "data": {"parent_id": null, "scrubbed": "[payload removed for fixture]"}} +{"ts": "2026-07-08T15:50:55.032250417+00:00", "lvl": "INFO", "schema": {"name": "amplifier.log", "ver": "1.0.0"}, "event": "cleanup:store_begin", "session_id": "1326337c-f0fb-49ff-9ff6-06722c5e0bab", "data": {"parent_id": null}} +{"ts": "2026-07-08T15:50:55.033854729+00:00", "lvl": "INFO", "schema": {"name": "amplifier.log", "ver": "1.0.0"}, "event": "cleanup:store_end", "session_id": "1326337c-f0fb-49ff-9ff6-06722c5e0bab", "data": {"parent_id": null}} +{"ts": "2026-07-08T15:51:25.115843477+00:00", "lvl": "INFO", "schema": {"name": "amplifier.log", "ver": "1.0.0"}, "event": "cleanup:finally_begin", "session_id": "1326337c-f0fb-49ff-9ff6-06722c5e0bab", "data": {"parent_id": null}} +{"ts": "2026-07-08T15:51:25.118207230+00:00", "lvl": "INFO", "schema": {"name": "amplifier.log", "ver": "1.0.0"}, "event": "session:end", "redaction": {"applied": true, "rules": ["secrets", "pii-basic"]}, "session_id": "1326337c-f0fb-49ff-9ff6-06722c5e0bab", "data": {"parent_id": null}} +{"ts": "2026-07-08T15:51:25.120438322+00:00", "lvl": "INFO", "schema": {"name": "amplifier.log", "ver": "1.0.0"}, "event": "cleanup:finally_end", "session_id": "1326337c-f0fb-49ff-9ff6-06722c5e0bab", "data": {"parent_id": null}} diff --git a/test/fixtures/coding-cli/amplifier/events/pty-hangup-completes.jsonl b/test/fixtures/coding-cli/amplifier/events/pty-hangup-completes.jsonl new file mode 100644 index 00000000..10ddd0f0 --- /dev/null +++ b/test/fixtures/coding-cli/amplifier/events/pty-hangup-completes.jsonl @@ -0,0 +1,13 @@ +{"ts": "2026-07-08T16:10:00.100000000+00:00", "lvl": "INFO", "schema": {"name": "amplifier.log", "ver": "1.0.0"}, "event": "session:start", "redaction": {"applied": true, "rules": ["secrets", "pii-basic"]}, "session_id": "7a52c04e-11de-4b8c-9c3a-52f1a9b0e6d4", "data": {"parent_id": null}} +{"ts": "2026-07-08T16:10:00.110000000+00:00", "lvl": "INFO", "schema": {"name": "amplifier.log", "ver": "1.0.0"}, "event": "session:config", "session_id": "7a52c04e-11de-4b8c-9c3a-52f1a9b0e6d4", "data": {"parent_id": null, "raw": {"project_dir": "/tmp/amp-p0-e7", "working_dir": "/tmp/amp-p0-e7", "project_slug": "-tmp-amp-p0-e7"}}} +{"ts": "2026-07-08T16:10:00.120000000+00:00", "lvl": "INFO", "schema": {"name": "amplifier.log", "ver": "1.0.0"}, "event": "prompt:submit", "redaction": {"applied": true, "rules": ["secrets", "pii-basic"]}, "session_id": "7a52c04e-11de-4b8c-9c3a-52f1a9b0e6d4", "data": {"parent_id": null, "scrubbed": "[payload removed for fixture]"}} +{"ts": "2026-07-08T16:10:00.130000000+00:00", "lvl": "INFO", "schema": {"name": "amplifier.log", "ver": "1.0.0"}, "event": "execution:start", "session_id": "7a52c04e-11de-4b8c-9c3a-52f1a9b0e6d4", "data": {"parent_id": null, "scrubbed": "[payload removed for fixture]"}} +{"ts": "2026-07-08T16:10:00.140000000+00:00", "lvl": "INFO", "schema": {"name": "amplifier.log", "ver": "1.0.0"}, "event": "provider:request", "redaction": {"applied": true, "rules": ["secrets", "pii-basic"]}, "session_id": "7a52c04e-11de-4b8c-9c3a-52f1a9b0e6d4", "data": {"parent_id": null, "scrubbed": "[payload removed for fixture]"}} +{"ts": "2026-07-08T16:10:00.900000000+00:00", "lvl": "INFO", "schema": {"name": "amplifier.log", "ver": "1.0.0"}, "event": "llm:request", "redaction": {"applied": true, "rules": ["secrets", "pii-basic"]}, "session_id": "7a52c04e-11de-4b8c-9c3a-52f1a9b0e6d4", "data": {"parent_id": null, "scrubbed": "[payload removed for fixture]"}} +{"ts": "2026-07-08T16:10:03.500000000+00:00", "lvl": "INFO", "schema": {"name": "amplifier.log", "ver": "1.0.0"}, "event": "llm:response", "duration_ms": 2600, "redaction": {"applied": true, "rules": ["secrets", "pii-basic"]}, "session_id": "7a52c04e-11de-4b8c-9c3a-52f1a9b0e6d4", "status": "ok", "data": {"parent_id": null, "scrubbed": "[payload removed for fixture]"}} +{"ts": "2026-07-08T16:10:03.510000000+00:00", "lvl": "INFO", "schema": {"name": "amplifier.log", "ver": "1.0.0"}, "event": "execution:end", "session_id": "7a52c04e-11de-4b8c-9c3a-52f1a9b0e6d4", "data": {"parent_id": null}} +{"ts": "2026-07-08T16:10:03.520000000+00:00", "lvl": "INFO", "schema": {"name": "amplifier.log", "ver": "1.0.0"}, "event": "orchestrator:complete", "session_id": "7a52c04e-11de-4b8c-9c3a-52f1a9b0e6d4", "status": "success", "data": {"parent_id": null, "scrubbed": "[payload removed for fixture]"}} +{"ts": "2026-07-08T16:10:03.540000000+00:00", "lvl": "INFO", "schema": {"name": "amplifier.log", "ver": "1.0.0"}, "event": "prompt:complete", "redaction": {"applied": true, "rules": ["secrets", "pii-basic"]}, "session_id": "7a52c04e-11de-4b8c-9c3a-52f1a9b0e6d4", "data": {"parent_id": null, "scrubbed": "[payload removed for fixture]"}} +{"ts": "2026-07-08T16:10:03.560000000+00:00", "lvl": "INFO", "schema": {"name": "amplifier.log", "ver": "1.0.0"}, "event": "cleanup:finally_begin", "session_id": "7a52c04e-11de-4b8c-9c3a-52f1a9b0e6d4", "data": {"parent_id": null}} +{"ts": "2026-07-08T16:10:03.580000000+00:00", "lvl": "INFO", "schema": {"name": "amplifier.log", "ver": "1.0.0"}, "event": "session:end", "redaction": {"applied": true, "rules": ["secrets", "pii-basic"]}, "session_id": "7a52c04e-11de-4b8c-9c3a-52f1a9b0e6d4", "data": {"parent_id": null}} +{"ts": "2026-07-08T16:10:03.590000000+00:00", "lvl": "INFO", "schema": {"name": "amplifier.log", "ver": "1.0.0"}, "event": "cleanup:finally_end", "session_id": "7a52c04e-11de-4b8c-9c3a-52f1a9b0e6d4", "data": {"parent_id": null}} diff --git a/test/fixtures/coding-cli/amplifier/events/resume-append.jsonl b/test/fixtures/coding-cli/amplifier/events/resume-append.jsonl new file mode 100644 index 00000000..4dbf83f4 --- /dev/null +++ b/test/fixtures/coding-cli/amplifier/events/resume-append.jsonl @@ -0,0 +1,28 @@ +{"ts": "2026-07-08T16:01:27.255264049+00:00", "lvl": "INFO", "schema": {"name": "amplifier.log", "ver": "1.0.0"}, "event": "session:resume", "session_id": "1326337c-f0fb-49ff-9ff6-06722c5e0bab", "data": {"parent_id": null}} +{"ts": "2026-07-08T16:01:27.257039381+00:00", "lvl": "INFO", "schema": {"name": "amplifier.log", "ver": "1.0.0"}, "event": "session:config", "session_id": "1326337c-f0fb-49ff-9ff6-06722c5e0bab", "data": {"parent_id": null, "raw": {"project_dir": "/tmp/amp-p0-b", "working_dir": "/tmp/amp-p0-b", "project_slug": "-tmp-amp-p0-b"}}} +{"ts": "2026-07-08T16:01:27.259046323+00:00", "lvl": "INFO", "schema": {"name": "amplifier.log", "ver": "1.0.0"}, "event": "prompt:submit", "redaction": {"applied": true, "rules": ["secrets", "pii-basic"]}, "session_id": "1326337c-f0fb-49ff-9ff6-06722c5e0bab", "data": {"parent_id": null, "scrubbed": "[payload removed for fixture]"}} +{"ts": "2026-07-08T16:01:27.260606318+00:00", "lvl": "INFO", "schema": {"name": "amplifier.log", "ver": "1.0.0"}, "event": "execution:start", "session_id": "1326337c-f0fb-49ff-9ff6-06722c5e0bab", "data": {"parent_id": null, "scrubbed": "[payload removed for fixture]"}} +{"ts": "2026-07-08T16:01:27.261642123+00:00", "lvl": "INFO", "schema": {"name": "amplifier.log", "ver": "1.0.0"}, "event": "provider:request", "redaction": {"applied": true, "rules": ["secrets", "pii-basic"]}, "session_id": "1326337c-f0fb-49ff-9ff6-06722c5e0bab", "data": {"parent_id": null, "scrubbed": "[payload removed for fixture]"}} +{"ts": "2026-07-08T16:01:27.273934616+00:00", "lvl": "INFO", "schema": {"name": "amplifier.log", "ver": "1.0.0"}, "event": "mentions:resolved", "session_id": "1326337c-f0fb-49ff-9ff6-06722c5e0bab", "data": {"parent_id": null, "scrubbed": "[payload removed for fixture]"}} +{"ts": "2026-07-08T16:01:27.725519463+00:00", "lvl": "INFO", "schema": {"name": "amplifier.log", "ver": "1.0.0"}, "event": "llm:request", "redaction": {"applied": true, "rules": ["secrets", "pii-basic"]}, "session_id": "1326337c-f0fb-49ff-9ff6-06722c5e0bab", "data": {"parent_id": null, "scrubbed": "[payload removed for fixture]"}} +{"ts": "2026-07-08T16:01:33.671804266+00:00", "lvl": "INFO", "schema": {"name": "amplifier.log", "ver": "1.0.0"}, "event": "llm:response", "duration_ms": 5935, "redaction": {"applied": true, "rules": ["secrets", "pii-basic"]}, "session_id": "1326337c-f0fb-49ff-9ff6-06722c5e0bab", "status": "ok", "data": {"parent_id": null, "scrubbed": "[payload removed for fixture]"}} +{"ts": "2026-07-08T16:01:33.674121789+00:00", "lvl": "INFO", "schema": {"name": "amplifier.log", "ver": "1.0.0"}, "event": "content_block:start", "session_id": "1326337c-f0fb-49ff-9ff6-06722c5e0bab", "data": {"parent_id": null, "scrubbed": "[payload removed for fixture]"}} +{"ts": "2026-07-08T16:01:33.675638671+00:00", "lvl": "INFO", "schema": {"name": "amplifier.log", "ver": "1.0.0"}, "event": "content_block:end", "redaction": {"applied": true, "rules": ["secrets", "pii-basic"]}, "session_id": "1326337c-f0fb-49ff-9ff6-06722c5e0bab", "data": {"parent_id": null, "scrubbed": "[payload removed for fixture]"}} +{"ts": "2026-07-08T16:01:33.677470043+00:00", "lvl": "INFO", "schema": {"name": "amplifier.log", "ver": "1.0.0"}, "event": "execution:end", "session_id": "1326337c-f0fb-49ff-9ff6-06722c5e0bab", "data": {"parent_id": null}} +{"ts": "2026-07-08T16:01:33.678298745+00:00", "lvl": "INFO", "schema": {"name": "amplifier.log", "ver": "1.0.0"}, "event": "orchestrator:complete", "session_id": "1326337c-f0fb-49ff-9ff6-06722c5e0bab", "status": "success", "data": {"parent_id": null, "scrubbed": "[payload removed for fixture]"}} +{"ts": "2026-07-08T16:01:33.679759979+00:00", "lvl": "INFO", "schema": {"name": "amplifier.log", "ver": "1.0.0"}, "event": "prompt:complete", "redaction": {"applied": true, "rules": ["secrets", "pii-basic"]}, "session_id": "1326337c-f0fb-49ff-9ff6-06722c5e0bab", "data": {"parent_id": null, "scrubbed": "[payload removed for fixture]"}} +{"ts": "2026-07-08T16:01:33.681136391+00:00", "lvl": "INFO", "schema": {"name": "amplifier.log", "ver": "1.0.0"}, "event": "llm:request", "redaction": {"applied": true, "rules": ["secrets", "pii-basic"]}, "session_id": "1326337c-f0fb-49ff-9ff6-06722c5e0bab", "data": {"parent_id": null, "scrubbed": "[payload removed for fixture]"}} +{"ts": "2026-07-08T16:01:33.683830534+00:00", "lvl": "INFO", "schema": {"name": "amplifier.log", "ver": "1.0.0"}, "event": "cleanup:render_begin", "session_id": "1326337c-f0fb-49ff-9ff6-06722c5e0bab", "data": {"parent_id": null}} +{"ts": "2026-07-08T16:01:33.689376742+00:00", "lvl": "INFO", "schema": {"name": "amplifier.log", "ver": "1.0.0"}, "event": "cleanup:render_end", "session_id": "1326337c-f0fb-49ff-9ff6-06722c5e0bab", "data": {"parent_id": null}} +{"ts": "2026-07-08T16:01:33.691450206+00:00", "lvl": "INFO", "schema": {"name": "amplifier.log", "ver": "1.0.0"}, "event": "cleanup:store_begin", "session_id": "1326337c-f0fb-49ff-9ff6-06722c5e0bab", "data": {"parent_id": null}} +{"ts": "2026-07-08T16:01:33.694182670+00:00", "lvl": "INFO", "schema": {"name": "amplifier.log", "ver": "1.0.0"}, "event": "cleanup:store_end", "session_id": "1326337c-f0fb-49ff-9ff6-06722c5e0bab", "data": {"parent_id": null, "scrubbed": "[payload removed for fixture]"}} +{"ts": "2026-07-08T16:01:33.695038609+00:00", "lvl": "INFO", "schema": {"name": "amplifier.log", "ver": "1.0.0"}, "event": "cleanup:finally_begin", "session_id": "1326337c-f0fb-49ff-9ff6-06722c5e0bab", "data": {"parent_id": null}} +{"ts": "2026-07-08T16:01:33.698072006+00:00", "lvl": "INFO", "schema": {"name": "amplifier.log", "ver": "1.0.0"}, "event": "provider:retry", "session_id": "1326337c-f0fb-49ff-9ff6-06722c5e0bab", "data": {"parent_id": null, "scrubbed": "[payload removed for fixture]"}} +{"ts": "2026-07-08T16:01:35.035349076+00:00", "lvl": "INFO", "schema": {"name": "amplifier.log", "ver": "1.0.0"}, "event": "provider:retry", "session_id": "1326337c-f0fb-49ff-9ff6-06722c5e0bab", "data": {"parent_id": null, "scrubbed": "[payload removed for fixture]"}} +{"ts": "2026-07-08T16:01:37.083050384+00:00", "lvl": "INFO", "schema": {"name": "amplifier.log", "ver": "1.0.0"}, "event": "provider:retry", "session_id": "1326337c-f0fb-49ff-9ff6-06722c5e0bab", "data": {"parent_id": null, "scrubbed": "[payload removed for fixture]"}} +{"ts": "2026-07-08T16:01:40.496323448+00:00", "lvl": "INFO", "schema": {"name": "amplifier.log", "ver": "1.0.0"}, "event": "provider:retry", "session_id": "1326337c-f0fb-49ff-9ff6-06722c5e0bab", "data": {"parent_id": null, "scrubbed": "[payload removed for fixture]"}} +{"ts": "2026-07-08T16:01:33.698112197+00:00", "lvl": "INFO", "schema": {"name": "amplifier.log", "ver": "1.0.0"}, "event": "session:end", "redaction": {"applied": true, "rules": ["secrets", "pii-basic"]}, "session_id": "1326337c-f0fb-49ff-9ff6-06722c5e0bab", "data": {"parent_id": null}} +{"ts": "2026-07-08T16:01:43.687698659+00:00", "lvl": "INFO", "schema": {"name": "amplifier.log", "ver": "1.0.0"}, "event": "cleanup:finally_end", "session_id": "1326337c-f0fb-49ff-9ff6-06722c5e0bab", "data": {"parent_id": null}} +{"ts": "2026-07-08T16:02:43.157049338+00:00", "lvl": "INFO", "schema": {"name": "amplifier.log", "ver": "1.0.0"}, "event": "cleanup:finally_begin", "session_id": "1326337c-f0fb-49ff-9ff6-06722c5e0bab", "data": {"parent_id": null}} +{"ts": "2026-07-08T16:02:43.159587467+00:00", "lvl": "INFO", "schema": {"name": "amplifier.log", "ver": "1.0.0"}, "event": "session:end", "redaction": {"applied": true, "rules": ["secrets", "pii-basic"]}, "session_id": "1326337c-f0fb-49ff-9ff6-06722c5e0bab", "data": {"parent_id": null}} +{"ts": "2026-07-08T16:02:43.161258748+00:00", "lvl": "INFO", "schema": {"name": "amplifier.log", "ver": "1.0.0"}, "event": "cleanup:finally_end", "session_id": "1326337c-f0fb-49ff-9ff6-06722c5e0bab", "data": {"parent_id": null}} diff --git a/test/fixtures/coding-cli/amplifier/events/steering-injection.jsonl b/test/fixtures/coding-cli/amplifier/events/steering-injection.jsonl new file mode 100644 index 00000000..1e28f92e --- /dev/null +++ b/test/fixtures/coding-cli/amplifier/events/steering-injection.jsonl @@ -0,0 +1,30 @@ +{"ts": "2026-07-08T15:53:21.221865191+00:00", "lvl": "INFO", "schema": {"name": "amplifier.log", "ver": "1.0.0"}, "event": "session:start", "redaction": {"applied": true, "rules": ["secrets", "pii-basic"]}, "session_id": "5f91a6ca-634c-4596-9722-e737a89d9b4b", "data": {"parent_id": null}} +{"ts": "2026-07-08T15:53:21.232017422+00:00", "lvl": "INFO", "schema": {"name": "amplifier.log", "ver": "1.0.0"}, "event": "session:config", "session_id": "5f91a6ca-634c-4596-9722-e737a89d9b4b", "data": {"parent_id": null, "raw": {"project_dir": "/tmp/amp-p0-d", "working_dir": "/tmp/amp-p0-d", "project_slug": "-tmp-amp-p0-d"}}} +{"ts": "2026-07-08T15:53:21.233411974+00:00", "lvl": "INFO", "schema": {"name": "amplifier.log", "ver": "1.0.0"}, "event": "prompt:submit", "redaction": {"applied": true, "rules": ["secrets", "pii-basic"]}, "session_id": "5f91a6ca-634c-4596-9722-e737a89d9b4b", "data": {"parent_id": null, "scrubbed": "[payload removed for fixture]"}} +{"ts": "2026-07-08T15:53:21.234853565+00:00", "lvl": "INFO", "schema": {"name": "amplifier.log", "ver": "1.0.0"}, "event": "execution:start", "session_id": "5f91a6ca-634c-4596-9722-e737a89d9b4b", "data": {"parent_id": null, "scrubbed": "[payload removed for fixture]"}} +{"ts": "2026-07-08T15:53:21.235508926+00:00", "lvl": "INFO", "schema": {"name": "amplifier.log", "ver": "1.0.0"}, "event": "provider:request", "redaction": {"applied": true, "rules": ["secrets", "pii-basic"]}, "session_id": "5f91a6ca-634c-4596-9722-e737a89d9b4b", "data": {"parent_id": null, "scrubbed": "[payload removed for fixture]"}} +{"ts": "2026-07-08T15:53:21.245777239+00:00", "lvl": "INFO", "schema": {"name": "amplifier.log", "ver": "1.0.0"}, "event": "mentions:resolved", "session_id": "5f91a6ca-634c-4596-9722-e737a89d9b4b", "data": {"parent_id": null, "scrubbed": "[payload removed for fixture]"}} +{"ts": "2026-07-08T15:53:21.468956828+00:00", "lvl": "INFO", "schema": {"name": "amplifier.log", "ver": "1.0.0"}, "event": "llm:request", "redaction": {"applied": true, "rules": ["secrets", "pii-basic"]}, "session_id": "5f91a6ca-634c-4596-9722-e737a89d9b4b", "data": {"parent_id": null, "scrubbed": "[payload removed for fixture]"}} +{"ts": "2026-07-08T15:53:25.293101006+00:00", "lvl": "INFO", "schema": {"name": "amplifier.log", "ver": "1.0.0"}, "event": "llm:response", "duration_ms": 3814, "redaction": {"applied": true, "rules": ["secrets", "pii-basic"]}, "session_id": "5f91a6ca-634c-4596-9722-e737a89d9b4b", "status": "ok", "data": {"parent_id": null, "scrubbed": "[payload removed for fixture]"}} +{"ts": "2026-07-08T15:53:25.294933226+00:00", "lvl": "INFO", "schema": {"name": "amplifier.log", "ver": "1.0.0"}, "event": "content_block:start", "session_id": "5f91a6ca-634c-4596-9722-e737a89d9b4b", "data": {"parent_id": null, "scrubbed": "[payload removed for fixture]"}} +{"ts": "2026-07-08T15:53:25.296073718+00:00", "lvl": "INFO", "schema": {"name": "amplifier.log", "ver": "1.0.0"}, "event": "content_block:end", "redaction": {"applied": true, "rules": ["secrets", "pii-basic"]}, "session_id": "5f91a6ca-634c-4596-9722-e737a89d9b4b", "data": {"parent_id": null, "scrubbed": "[payload removed for fixture]"}} +{"ts": "2026-07-08T15:53:25.297862930+00:00", "lvl": "INFO", "schema": {"name": "amplifier.log", "ver": "1.0.0"}, "event": "tool:pre", "session_id": "5f91a6ca-634c-4596-9722-e737a89d9b4b", "data": {"parent_id": null, "scrubbed": "[payload removed for fixture]"}} +{"ts": "2026-07-08T15:53:51.153569628+00:00", "lvl": "INFO", "schema": {"name": "amplifier.log", "ver": "1.0.0"}, "event": "tool:post", "session_id": "5f91a6ca-634c-4596-9722-e737a89d9b4b", "data": {"parent_id": null, "scrubbed": "[payload removed for fixture]"}} +{"ts": "2026-07-08T15:53:51.159257884+00:00", "lvl": "INFO", "schema": {"name": "amplifier.log", "ver": "1.0.0"}, "event": "orchestrator:steering_injected", "session_id": "5f91a6ca-634c-4596-9722-e737a89d9b4b", "data": {"parent_id": null, "scrubbed": "[payload removed for fixture]"}} +{"ts": "2026-07-08T15:53:51.161165535+00:00", "lvl": "INFO", "schema": {"name": "amplifier.log", "ver": "1.0.0"}, "event": "provider:request", "redaction": {"applied": true, "rules": ["secrets", "pii-basic"]}, "session_id": "5f91a6ca-634c-4596-9722-e737a89d9b4b", "data": {"parent_id": null, "scrubbed": "[payload removed for fixture]"}} +{"ts": "2026-07-08T15:53:51.168256254+00:00", "lvl": "INFO", "schema": {"name": "amplifier.log", "ver": "1.0.0"}, "event": "llm:request", "redaction": {"applied": true, "rules": ["secrets", "pii-basic"]}, "session_id": "5f91a6ca-634c-4596-9722-e737a89d9b4b", "data": {"parent_id": null, "scrubbed": "[payload removed for fixture]"}} +{"ts": "2026-07-08T15:53:53.947370208+00:00", "lvl": "INFO", "schema": {"name": "amplifier.log", "ver": "1.0.0"}, "event": "llm:response", "duration_ms": 2769, "redaction": {"applied": true, "rules": ["secrets", "pii-basic"]}, "session_id": "5f91a6ca-634c-4596-9722-e737a89d9b4b", "status": "ok", "data": {"parent_id": null, "scrubbed": "[payload removed for fixture]"}} +{"ts": "2026-07-08T15:53:53.949472041+00:00", "lvl": "INFO", "schema": {"name": "amplifier.log", "ver": "1.0.0"}, "event": "content_block:start", "session_id": "5f91a6ca-634c-4596-9722-e737a89d9b4b", "data": {"parent_id": null, "scrubbed": "[payload removed for fixture]"}} +{"ts": "2026-07-08T15:53:53.950669428+00:00", "lvl": "INFO", "schema": {"name": "amplifier.log", "ver": "1.0.0"}, "event": "content_block:end", "redaction": {"applied": true, "rules": ["secrets", "pii-basic"]}, "session_id": "5f91a6ca-634c-4596-9722-e737a89d9b4b", "data": {"parent_id": null, "scrubbed": "[payload removed for fixture]"}} +{"ts": "2026-07-08T15:53:53.952494745+00:00", "lvl": "INFO", "schema": {"name": "amplifier.log", "ver": "1.0.0"}, "event": "execution:end", "session_id": "5f91a6ca-634c-4596-9722-e737a89d9b4b", "data": {"parent_id": null}} +{"ts": "2026-07-08T15:53:53.953382075+00:00", "lvl": "INFO", "schema": {"name": "amplifier.log", "ver": "1.0.0"}, "event": "orchestrator:complete", "session_id": "5f91a6ca-634c-4596-9722-e737a89d9b4b", "status": "success", "data": {"parent_id": null, "scrubbed": "[payload removed for fixture]"}} +{"ts": "2026-07-08T15:53:53.995479124+00:00", "lvl": "INFO", "schema": {"name": "amplifier.log", "ver": "1.0.0"}, "event": "cleanup:render_begin", "session_id": "5f91a6ca-634c-4596-9722-e737a89d9b4b", "data": {"parent_id": null}} +{"ts": "2026-07-08T15:53:53.997703137+00:00", "lvl": "INFO", "schema": {"name": "amplifier.log", "ver": "1.0.0"}, "event": "cleanup:render_end", "session_id": "5f91a6ca-634c-4596-9722-e737a89d9b4b", "data": {"parent_id": null}} +{"ts": "2026-07-08T15:53:53.999159617+00:00", "lvl": "INFO", "schema": {"name": "amplifier.log", "ver": "1.0.0"}, "event": "prompt:complete", "redaction": {"applied": true, "rules": ["secrets", "pii-basic"]}, "session_id": "5f91a6ca-634c-4596-9722-e737a89d9b4b", "data": {"parent_id": null, "scrubbed": "[payload removed for fixture]"}} +{"ts": "2026-07-08T15:53:54.000531578+00:00", "lvl": "INFO", "schema": {"name": "amplifier.log", "ver": "1.0.0"}, "event": "llm:request", "redaction": {"applied": true, "rules": ["secrets", "pii-basic"]}, "session_id": "5f91a6ca-634c-4596-9722-e737a89d9b4b", "data": {"parent_id": null, "scrubbed": "[payload removed for fixture]"}} +{"ts": "2026-07-08T15:53:54.003135861+00:00", "lvl": "INFO", "schema": {"name": "amplifier.log", "ver": "1.0.0"}, "event": "cleanup:store_begin", "session_id": "5f91a6ca-634c-4596-9722-e737a89d9b4b", "data": {"parent_id": null}} +{"ts": "2026-07-08T15:53:54.008045077+00:00", "lvl": "INFO", "schema": {"name": "amplifier.log", "ver": "1.0.0"}, "event": "cleanup:store_end", "session_id": "5f91a6ca-634c-4596-9722-e737a89d9b4b", "data": {"parent_id": null}} +{"ts": "2026-07-08T15:53:56.957127524+00:00", "lvl": "INFO", "schema": {"name": "amplifier.log", "ver": "1.0.0"}, "event": "llm:response", "duration_ms": 2953, "redaction": {"applied": true, "rules": ["secrets", "pii-basic"]}, "session_id": "5f91a6ca-634c-4596-9722-e737a89d9b4b", "status": "ok", "data": {"parent_id": null, "scrubbed": "[payload removed for fixture]"}} +{"ts": "2026-07-08T15:56:02.923021817+00:00", "lvl": "INFO", "schema": {"name": "amplifier.log", "ver": "1.0.0"}, "event": "cleanup:finally_begin", "session_id": "5f91a6ca-634c-4596-9722-e737a89d9b4b", "data": {"parent_id": null}} +{"ts": "2026-07-08T15:56:02.926495522+00:00", "lvl": "INFO", "schema": {"name": "amplifier.log", "ver": "1.0.0"}, "event": "session:end", "redaction": {"applied": true, "rules": ["secrets", "pii-basic"]}, "session_id": "5f91a6ca-634c-4596-9722-e737a89d9b4b", "data": {"parent_id": null}} +{"ts": "2026-07-08T15:56:02.929226496+00:00", "lvl": "INFO", "schema": {"name": "amplifier.log", "ver": "1.0.0"}, "event": "cleanup:finally_end", "session_id": "5f91a6ca-634c-4596-9722-e737a89d9b4b", "data": {"parent_id": null}} diff --git a/test/fixtures/coding-cli/amplifier/events/tool-turn-out-of-order-end.jsonl b/test/fixtures/coding-cli/amplifier/events/tool-turn-out-of-order-end.jsonl new file mode 100644 index 00000000..c7842ee9 --- /dev/null +++ b/test/fixtures/coding-cli/amplifier/events/tool-turn-out-of-order-end.jsonl @@ -0,0 +1,31 @@ +{"ts": "2026-07-08T15:52:22.935700865+00:00", "lvl": "INFO", "schema": {"name": "amplifier.log", "ver": "1.0.0"}, "event": "session:start", "redaction": {"applied": true, "rules": ["secrets", "pii-basic"]}, "session_id": "def3cd7e-a841-4a75-88e2-ab69ccde0e05", "data": {"parent_id": null}} +{"ts": "2026-07-08T15:52:22.947350034+00:00", "lvl": "INFO", "schema": {"name": "amplifier.log", "ver": "1.0.0"}, "event": "session:config", "session_id": "def3cd7e-a841-4a75-88e2-ab69ccde0e05", "data": {"parent_id": null, "raw": {"project_dir": "/tmp/amp-p0-c", "working_dir": "/tmp/amp-p0-c", "project_slug": "-tmp-amp-p0-c"}}} +{"ts": "2026-07-08T15:52:22.949158848+00:00", "lvl": "INFO", "schema": {"name": "amplifier.log", "ver": "1.0.0"}, "event": "prompt:submit", "redaction": {"applied": true, "rules": ["secrets", "pii-basic"]}, "session_id": "def3cd7e-a841-4a75-88e2-ab69ccde0e05", "data": {"parent_id": null, "scrubbed": "[payload removed for fixture]"}} +{"ts": "2026-07-08T15:52:22.950947204+00:00", "lvl": "INFO", "schema": {"name": "amplifier.log", "ver": "1.0.0"}, "event": "execution:start", "session_id": "def3cd7e-a841-4a75-88e2-ab69ccde0e05", "data": {"parent_id": null, "scrubbed": "[payload removed for fixture]"}} +{"ts": "2026-07-08T15:52:22.951801291+00:00", "lvl": "INFO", "schema": {"name": "amplifier.log", "ver": "1.0.0"}, "event": "provider:request", "redaction": {"applied": true, "rules": ["secrets", "pii-basic"]}, "session_id": "def3cd7e-a841-4a75-88e2-ab69ccde0e05", "data": {"parent_id": null, "scrubbed": "[payload removed for fixture]"}} +{"ts": "2026-07-08T15:52:22.962418334+00:00", "lvl": "INFO", "schema": {"name": "amplifier.log", "ver": "1.0.0"}, "event": "mentions:resolved", "session_id": "def3cd7e-a841-4a75-88e2-ab69ccde0e05", "data": {"parent_id": null, "scrubbed": "[payload removed for fixture]"}} +{"ts": "2026-07-08T15:52:23.231904276+00:00", "lvl": "INFO", "schema": {"name": "amplifier.log", "ver": "1.0.0"}, "event": "llm:request", "redaction": {"applied": true, "rules": ["secrets", "pii-basic"]}, "session_id": "def3cd7e-a841-4a75-88e2-ab69ccde0e05", "data": {"parent_id": null, "scrubbed": "[payload removed for fixture]"}} +{"ts": "2026-07-08T15:52:27.055371379+00:00", "lvl": "INFO", "schema": {"name": "amplifier.log", "ver": "1.0.0"}, "event": "llm:response", "duration_ms": 3812, "redaction": {"applied": true, "rules": ["secrets", "pii-basic"]}, "session_id": "def3cd7e-a841-4a75-88e2-ab69ccde0e05", "status": "ok", "data": {"parent_id": null, "scrubbed": "[payload removed for fixture]"}} +{"ts": "2026-07-08T15:52:27.058638453+00:00", "lvl": "INFO", "schema": {"name": "amplifier.log", "ver": "1.0.0"}, "event": "content_block:start", "session_id": "def3cd7e-a841-4a75-88e2-ab69ccde0e05", "data": {"parent_id": null, "scrubbed": "[payload removed for fixture]"}} +{"ts": "2026-07-08T15:52:27.059968823+00:00", "lvl": "INFO", "schema": {"name": "amplifier.log", "ver": "1.0.0"}, "event": "content_block:end", "redaction": {"applied": true, "rules": ["secrets", "pii-basic"]}, "session_id": "def3cd7e-a841-4a75-88e2-ab69ccde0e05", "data": {"parent_id": null, "scrubbed": "[payload removed for fixture]"}} +{"ts": "2026-07-08T15:52:27.061766674+00:00", "lvl": "INFO", "schema": {"name": "amplifier.log", "ver": "1.0.0"}, "event": "tool:pre", "session_id": "def3cd7e-a841-4a75-88e2-ab69ccde0e05", "data": {"parent_id": null, "scrubbed": "[payload removed for fixture]"}} +{"ts": "2026-07-08T15:52:27.900363233+00:00", "lvl": "INFO", "schema": {"name": "amplifier.log", "ver": "1.0.0"}, "event": "tool:post", "session_id": "def3cd7e-a841-4a75-88e2-ab69ccde0e05", "data": {"parent_id": null, "scrubbed": "[payload removed for fixture]"}} +{"ts": "2026-07-08T15:52:27.906803836+00:00", "lvl": "INFO", "schema": {"name": "amplifier.log", "ver": "1.0.0"}, "event": "provider:request", "redaction": {"applied": true, "rules": ["secrets", "pii-basic"]}, "session_id": "def3cd7e-a841-4a75-88e2-ab69ccde0e05", "data": {"parent_id": null, "scrubbed": "[payload removed for fixture]"}} +{"ts": "2026-07-08T15:52:27.914575442+00:00", "lvl": "INFO", "schema": {"name": "amplifier.log", "ver": "1.0.0"}, "event": "llm:request", "redaction": {"applied": true, "rules": ["secrets", "pii-basic"]}, "session_id": "def3cd7e-a841-4a75-88e2-ab69ccde0e05", "data": {"parent_id": null, "scrubbed": "[payload removed for fixture]"}} +{"ts": "2026-07-08T15:52:31.113958156+00:00", "lvl": "INFO", "schema": {"name": "amplifier.log", "ver": "1.0.0"}, "event": "llm:response", "duration_ms": 3190, "redaction": {"applied": true, "rules": ["secrets", "pii-basic"]}, "session_id": "def3cd7e-a841-4a75-88e2-ab69ccde0e05", "status": "ok", "data": {"parent_id": null, "scrubbed": "[payload removed for fixture]"}} +{"ts": "2026-07-08T15:52:31.115926461+00:00", "lvl": "INFO", "schema": {"name": "amplifier.log", "ver": "1.0.0"}, "event": "content_block:start", "session_id": "def3cd7e-a841-4a75-88e2-ab69ccde0e05", "data": {"parent_id": null, "scrubbed": "[payload removed for fixture]"}} +{"ts": "2026-07-08T15:52:31.117129736+00:00", "lvl": "INFO", "schema": {"name": "amplifier.log", "ver": "1.0.0"}, "event": "content_block:end", "redaction": {"applied": true, "rules": ["secrets", "pii-basic"]}, "session_id": "def3cd7e-a841-4a75-88e2-ab69ccde0e05", "data": {"parent_id": null, "scrubbed": "[payload removed for fixture]"}} +{"ts": "2026-07-08T15:52:31.119053405+00:00", "lvl": "INFO", "schema": {"name": "amplifier.log", "ver": "1.0.0"}, "event": "execution:end", "session_id": "def3cd7e-a841-4a75-88e2-ab69ccde0e05", "data": {"parent_id": null}} +{"ts": "2026-07-08T15:52:31.119993583+00:00", "lvl": "INFO", "schema": {"name": "amplifier.log", "ver": "1.0.0"}, "event": "orchestrator:complete", "session_id": "def3cd7e-a841-4a75-88e2-ab69ccde0e05", "status": "success", "data": {"parent_id": null, "scrubbed": "[payload removed for fixture]"}} +{"ts": "2026-07-08T15:52:31.132745414+00:00", "lvl": "INFO", "schema": {"name": "amplifier.log", "ver": "1.0.0"}, "event": "cleanup:render_begin", "session_id": "def3cd7e-a841-4a75-88e2-ab69ccde0e05", "data": {"parent_id": null}} +{"ts": "2026-07-08T15:52:31.134585949+00:00", "lvl": "INFO", "schema": {"name": "amplifier.log", "ver": "1.0.0"}, "event": "cleanup:render_end", "session_id": "def3cd7e-a841-4a75-88e2-ab69ccde0e05", "data": {"parent_id": null}} +{"ts": "2026-07-08T15:52:31.135592890+00:00", "lvl": "INFO", "schema": {"name": "amplifier.log", "ver": "1.0.0"}, "event": "prompt:complete", "redaction": {"applied": true, "rules": ["secrets", "pii-basic"]}, "session_id": "def3cd7e-a841-4a75-88e2-ab69ccde0e05", "data": {"parent_id": null, "scrubbed": "[payload removed for fixture]"}} +{"ts": "2026-07-08T15:52:31.137177294+00:00", "lvl": "INFO", "schema": {"name": "amplifier.log", "ver": "1.0.0"}, "event": "llm:request", "redaction": {"applied": true, "rules": ["secrets", "pii-basic"]}, "session_id": "def3cd7e-a841-4a75-88e2-ab69ccde0e05", "data": {"parent_id": null, "scrubbed": "[payload removed for fixture]"}} +{"ts": "2026-07-08T15:52:31.139569940+00:00", "lvl": "INFO", "schema": {"name": "amplifier.log", "ver": "1.0.0"}, "event": "cleanup:store_begin", "session_id": "def3cd7e-a841-4a75-88e2-ab69ccde0e05", "data": {"parent_id": null}} +{"ts": "2026-07-08T15:52:31.142984929+00:00", "lvl": "INFO", "schema": {"name": "amplifier.log", "ver": "1.0.0"}, "event": "cleanup:store_end", "session_id": "def3cd7e-a841-4a75-88e2-ab69ccde0e05", "data": {"parent_id": null}} +{"ts": "2026-07-08T15:52:33.479936018+00:00", "lvl": "INFO", "schema": {"name": "amplifier.log", "ver": "1.0.0"}, "event": "cleanup:finally_begin", "session_id": "def3cd7e-a841-4a75-88e2-ab69ccde0e05", "data": {"parent_id": null}} +{"ts": "2026-07-08T15:52:33.482887047+00:00", "lvl": "INFO", "schema": {"name": "amplifier.log", "ver": "1.0.0"}, "event": "provider:retry", "session_id": "def3cd7e-a841-4a75-88e2-ab69ccde0e05", "data": {"parent_id": null, "scrubbed": "[payload removed for fixture]"}} +{"ts": "2026-07-08T15:52:34.745565493+00:00", "lvl": "INFO", "schema": {"name": "amplifier.log", "ver": "1.0.0"}, "event": "provider:retry", "session_id": "def3cd7e-a841-4a75-88e2-ab69ccde0e05", "data": {"parent_id": null, "scrubbed": "[payload removed for fixture]"}} +{"ts": "2026-07-08T15:52:36.533983397+00:00", "lvl": "INFO", "schema": {"name": "amplifier.log", "ver": "1.0.0"}, "event": "provider:retry", "session_id": "def3cd7e-a841-4a75-88e2-ab69ccde0e05", "data": {"parent_id": null, "scrubbed": "[payload removed for fixture]"}} +{"ts": "2026-07-08T15:52:33.482919096+00:00", "lvl": "INFO", "schema": {"name": "amplifier.log", "ver": "1.0.0"}, "event": "session:end", "redaction": {"applied": true, "rules": ["secrets", "pii-basic"]}, "session_id": "def3cd7e-a841-4a75-88e2-ab69ccde0e05", "data": {"parent_id": null}} +{"ts": "2026-07-08T15:52:41.139577549+00:00", "lvl": "INFO", "schema": {"name": "amplifier.log", "ver": "1.0.0"}, "event": "cleanup:finally_end", "session_id": "def3cd7e-a841-4a75-88e2-ab69ccde0e05", "data": {"parent_id": null}} diff --git a/test/server/amplifier-session-association.test.ts b/test/server/amplifier-session-association.test.ts new file mode 100644 index 00000000..b1ed8420 --- /dev/null +++ b/test/server/amplifier-session-association.test.ts @@ -0,0 +1,304 @@ +/** + * Amplifier association integration tests (plan 2026-07-08 §5 / §9 Phase 3). + * + * 1. Full locator flow: armed amplifier terminal → PTY submit → session dir + * written on disk → controller binds → terminal.session.associated broadcast + * with lifecycle source 'amplifier_locator' + events tailer attach at start. + * 2. Fast-path variant: an amplifier session discovered via the indexer's + * onNewSession binds a still-unbound terminal (locator-missed simulation) + * through the coordinator, broadcast source 'amplifier_new_session'. + * 3. Fast-path guard widening does not regress the claude path. + */ +import fsp from 'node:fs/promises' +import os from 'node:os' +import path from 'node:path' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { + TerminalRegistry, + registerCodingCliCommands, + type CodingCliCommandSpec, +} from '../../server/terminal-registry' +import { CodingCliSessionIndexer } from '../../server/coding-cli/session-indexer' +import { SessionAssociationCoordinator } from '../../server/session-association-coordinator' +import { broadcastTerminalSessionAssociation } from '../../server/session-association-broadcast' +import { AmplifierSessionLocator } from '../../server/coding-cli/amplifier-session-locator' +import { AmplifierSessionController } from '../../server/coding-cli/amplifier-session-controller' +import { recordSessionLifecycleEvent } from '../../server/session-observability' + +vi.mock('node-pty', () => ({ + spawn: vi.fn(() => ({ + onData: vi.fn(), + onExit: vi.fn(), + write: vi.fn(), + resize: vi.fn(), + kill: vi.fn(), + })), +})) + +vi.mock('../../server/mcp/config-writer.js', () => ({ + generateMcpInjection: vi.fn(() => ({ args: [], env: {} })), + cleanupMcpConfig: vi.fn(), +})) + +vi.mock('../../server/session-observability.js', () => ({ + recordSessionLifecycleEvent: vi.fn(), +})) + +const SCHEMA = { name: 'amplifier.log', ver: '1.0.0' } + +function eventsJsonl(sessionId: string, cwd: string): string { + const start = { + ts: new Date().toISOString(), + lvl: 'INFO', + schema: SCHEMA, + event: 'session:start', + session_id: sessionId, + data: { parent_id: null }, + } + const config = { + ts: new Date().toISOString(), + lvl: 'INFO', + schema: SCHEMA, + event: 'session:config', + session_id: sessionId, + data: { raw: { working_dir: cwd, project_dir: cwd } }, + } + return `${JSON.stringify(start)}\n${JSON.stringify(config)}\n` +} + +async function waitFor( + predicate: () => boolean, + { timeoutMs = 8_000, intervalMs = 20, message = 'condition' } = {}, +): Promise { + const deadline = Date.now() + timeoutMs + while (Date.now() < deadline) { + if (predicate()) return + await new Promise((resolve) => setTimeout(resolve, intervalMs)) + } + throw new Error(`Timed out waiting for ${message}`) +} + +const COMMAND_SPECS: Array<[string, CodingCliCommandSpec]> = [ + ['claude', { + label: 'Claude CLI', + envVar: 'CLAUDE_CMD', + defaultCommand: 'claude', + resumeArgs: (sessionId: string) => ['--resume', sessionId], + createSessionArgs: (sessionId: string) => ['--session-id', sessionId], + }], + ['amplifier', { + label: 'Amplifier', + envVar: 'AMPLIFIER_CMD', + defaultCommand: 'amplifier', + resumeArgs: (sessionId: string) => ['resume', sessionId], + }], +] + +const SESSION_ID = '9e107d9d-3721-4b28-b2f1-aab1c826dcaf' +const CLAUDE_SESSION_ID = '550e8400-e29b-41d4-a716-446655440000' + +beforeEach(() => { + registerCodingCliCommands(new Map(COMMAND_SPECS)) + vi.mocked(recordSessionLifecycleEvent).mockClear() +}) + +describe('amplifier locator association end-to-end', () => { + const cleanups: Array<() => Promise | void> = [] + + afterEach(async () => { + while (cleanups.length > 0) { + await cleanups.pop()!() + } + }) + + it('binds an armed terminal from a fixture-written session dir and broadcasts with source amplifier_locator', async () => { + const home = await fsp.mkdtemp(path.join(os.tmpdir(), 'freshell-amp-assoc-home-')) + const cwdRaw = await fsp.mkdtemp(path.join(os.tmpdir(), 'freshell-amp-assoc-cwd-')) + const cwd = await fsp.realpath(cwdRaw) + await fsp.mkdir(path.join(home, 'projects'), { recursive: true }) + cleanups.push(() => fsp.rm(home, { recursive: true, force: true })) + cleanups.push(() => fsp.rm(cwdRaw, { recursive: true, force: true })) + + const registry = new TerminalRegistry() + cleanups.push(() => registry.shutdown()) + + const locator = new AmplifierSessionLocator({ + registry, + amplifierHome: home, + log: { warn: vi.fn() }, + windowMs: 400, + probePollMs: 25, + }) + cleanups.push(() => locator.dispose()) + const controller = new AmplifierSessionController({ registry, locator }) + cleanups.push(() => controller.dispose()) + + const broadcasts: any[] = [] + const wsHandler = { broadcast: (message: unknown) => broadcasts.push(message) } + const attachCalls: any[] = [] + // Mirrors the index.ts wiring: controller 'associated' → shared broadcast + // helper + integration.attachTailer at offset 0. + controller.on('associated', ({ terminalId, sessionId, eventsPath }) => { + broadcastTerminalSessionAssociation({ + wsHandler, + terminalMetadata: { associateSession: () => undefined } as any, + broadcastTerminalMetaUpserts: () => {}, + provider: 'amplifier', + terminalId, + sessionId, + source: 'amplifier_locator', + }) + attachCalls.push([terminalId, sessionId, eventsPath, 'start']) + }) + + const terminal = registry.create({ mode: 'amplifier', cwd }) + expect(terminal.resumeSessionId).toBeUndefined() + await locator.whenReady() + + registry.input(terminal.terminalId, '\r') + const sessionDir = path.join(home, 'projects', 'slug', 'sessions', SESSION_ID) + await fsp.mkdir(sessionDir, { recursive: true }) + await fsp.writeFile(path.join(sessionDir, 'events.jsonl'), eventsJsonl(SESSION_ID, cwd), 'utf8') + + await waitFor( + () => broadcasts.some((m) => m.type === 'terminal.session.associated'), + { message: 'terminal.session.associated broadcast' }, + ) + + expect(broadcasts).toContainEqual({ + type: 'terminal.session.associated', + terminalId: terminal.terminalId, + sessionRef: { provider: 'amplifier', sessionId: SESSION_ID }, + }) + expect(registry.get(terminal.terminalId)?.resumeSessionId).toBe(SESSION_ID) + expect(recordSessionLifecycleEvent).toHaveBeenCalledWith({ + kind: 'session_association_broadcast', + provider: 'amplifier', + terminalId: terminal.terminalId, + sessionId: SESSION_ID, + source: 'amplifier_locator', + }) + expect(attachCalls).toEqual([[ + terminal.terminalId, + SESSION_ID, + path.join(sessionDir, 'events.jsonl'), + 'start', + ]]) + // The locator unregisters on bind and stops its watcher. + await waitFor(() => locator.armedCount() === 0, { message: 'locator disarm after bind' }) + }) +}) + +describe('amplifier fast path via indexer onNewSession', () => { + function setupFastPath() { + const registry = new TerminalRegistry() + const indexer = new CodingCliSessionIndexer([]) + const coordinator = new SessionAssociationCoordinator(registry, 30_000) + const broadcasts: any[] = [] + const wsHandler = { broadcast: (message: unknown) => broadcasts.push(message) } + + // Replicates the widened index.ts onNewSession fast path (claude + amplifier). + indexer.onNewSession((session) => { + if (session.provider !== 'claude' && session.provider !== 'amplifier') return + if (!session.cwd) return + const provider = session.provider + const shouldAssociate = coordinator.noteSession({ + provider, + sessionId: session.sessionId, + projectPath: session.projectPath, + lastActivityAt: session.lastActivityAt, + cwd: session.cwd, + }) + if (!shouldAssociate) return + const result = coordinator.associateSingleSession({ + provider, + sessionId: session.sessionId, + projectPath: session.projectPath, + lastActivityAt: session.lastActivityAt, + cwd: session.cwd, + }) + if (!result.associated || !result.terminalId) return + broadcastTerminalSessionAssociation({ + wsHandler, + terminalMetadata: { associateSession: () => undefined } as any, + broadcastTerminalMetaUpserts: () => {}, + provider, + terminalId: result.terminalId, + sessionId: session.sessionId, + source: provider === 'claude' ? 'claude_new_session' : 'amplifier_new_session', + }) + }) + ;(indexer as any)['initialized'] = true + return { registry, indexer, broadcasts } + } + + it('binds a still-unbound amplifier terminal with source amplifier_new_session', () => { + const { registry, indexer, broadcasts } = setupFastPath() + const terminal = registry.create({ mode: 'amplifier', cwd: '/home/user/project' }) + + ;(indexer as any)['detectNewSessions']([{ + provider: 'amplifier', + sessionId: SESSION_ID, + projectPath: '/home/user/project', + lastActivityAt: Date.now(), + cwd: '/home/user/project', + }]) + + expect(registry.get(terminal.terminalId)?.resumeSessionId).toBe(SESSION_ID) + expect(broadcasts).toContainEqual({ + type: 'terminal.session.associated', + terminalId: terminal.terminalId, + sessionRef: { provider: 'amplifier', sessionId: SESSION_ID }, + }) + expect(recordSessionLifecycleEvent).toHaveBeenCalledWith(expect.objectContaining({ + kind: 'session_association_broadcast', + provider: 'amplifier', + source: 'amplifier_new_session', + })) + + registry.shutdown() + }) + + it('does not regress the claude fast path (source claude_new_session)', () => { + const { registry, indexer, broadcasts } = setupFastPath() + const terminal = registry.create({ mode: 'claude', cwd: '/home/user/project' }) + + ;(indexer as any)['detectNewSessions']([{ + provider: 'claude', + sessionId: CLAUDE_SESSION_ID, + projectPath: '/home/user/project', + lastActivityAt: Date.now(), + cwd: '/home/user/project', + }]) + + expect(registry.get(terminal.terminalId)?.resumeSessionId).toBe(CLAUDE_SESSION_ID) + expect(broadcasts).toContainEqual({ + type: 'terminal.session.associated', + terminalId: terminal.terminalId, + sessionRef: { provider: 'claude', sessionId: CLAUDE_SESSION_ID }, + }) + expect(recordSessionLifecycleEvent).toHaveBeenCalledWith(expect.objectContaining({ + kind: 'session_association_broadcast', + provider: 'claude', + source: 'claude_new_session', + })) + + registry.shutdown() + }) + + it('ignores other providers in the widened guard', () => { + const { registry, indexer, broadcasts } = setupFastPath() + registry.create({ mode: 'claude', cwd: '/home/user/project' }) + + ;(indexer as any)['detectNewSessions']([{ + provider: 'codex', + sessionId: 'codex-session-1', + projectPath: '/home/user/project', + lastActivityAt: Date.now(), + cwd: '/home/user/project', + }]) + + expect(broadcasts).toHaveLength(0) + registry.shutdown() + }) +}) diff --git a/test/server/ws-amplifier-activity.test.ts b/test/server/ws-amplifier-activity.test.ts index b9778393..dcb53e77 100644 --- a/test/server/ws-amplifier-activity.test.ts +++ b/test/server/ws-amplifier-activity.test.ts @@ -1,5 +1,6 @@ import { describe, it, expect, beforeAll, afterAll, vi } from 'vitest' import http from 'http' +import { EventEmitter } from 'events' import WebSocket from 'ws' import { WS_PROTOCOL_VERSION } from '../../shared/ws-protocol' @@ -224,3 +225,285 @@ describe('ws amplifier activity protocol', () => { unauthenticated.close() }) }) + +// --------------------------------------------------------------------------- +// Events-driven integration over the wire (plan 2026-07-08 §9 Phase 2): real +// tracker + wiring + integration composed exactly like server/index.ts, with +// injected fs/watch fakes standing in for events.jsonl and chokidar. +// --------------------------------------------------------------------------- + +const AMP_SCHEMA = '"schema": {"name": "amplifier.log", "ver": "1.0.0"}' + +function eventsLine(event: string): string { + return `{"ts": "${new Date().toISOString()}", "lvl": "INFO", ${AMP_SCHEMA}, ` + + `"event": "${event}", "session_id": "session-ev", "data": {"parent_id": null}}\n` +} + +function createFakeEventsFs() { + const files = new Map() + const statFailures = new Set() + return { + fsImpl: { + async stat(path: string) { + if (statFailures.has(path)) throw new Error('EIO: injected stat failure') + const content = files.get(path) + if (!content) throw Object.assign(new Error('ENOENT'), { code: 'ENOENT' }) + return { size: content.length } + }, + async open(path: string) { + return { + async read(buffer: Buffer, offset: number, length: number, position: number) { + const content = files.get(path) ?? Buffer.alloc(0) + const slice = content.subarray(position, position + length) + slice.copy(buffer, offset) + return { bytesRead: slice.length } + }, + async close() {}, + } + }, + }, + write(path: string, text: string) { + files.set(path, Buffer.from(text, 'utf8')) + }, + append(path: string, text: string) { + files.set(path, Buffer.concat([files.get(path) ?? Buffer.alloc(0), Buffer.from(text, 'utf8')])) + }, + failStat(path: string) { + statFailures.add(path) + }, + } +} + +type FakeEventsWatcher = { + watchedPath: string + closed: boolean + on(event: string, handler: (...args: any[]) => void): FakeEventsWatcher + close(): Promise + fire(event: string, path: string): void +} + +function createFakeWatchFactory() { + const watchers: FakeEventsWatcher[] = [] + const factory = (watchedPath: string) => { + const handlers = new Map void>>() + const watcher: FakeEventsWatcher = { + watchedPath, + closed: false, + on(event, handler) { + const list = handlers.get(event) ?? [] + list.push(handler) + handlers.set(event, list) + return watcher + }, + async close() { + watcher.closed = true + }, + fire(event, path) { + for (const handler of handlers.get(event) ?? []) handler(path) + }, + } + watchers.push(watcher) + return watcher + } + return { factory, watchers } +} + +async function flushAsync(rounds = 10): Promise { + for (let i = 0; i < rounds; i += 1) { + await new Promise((resolve) => setImmediate(resolve)) + } +} + +async function waitUntil(predicate: () => boolean, timeoutMs = 2000): Promise { + const deadline = Date.now() + timeoutMs + while (!predicate()) { + if (Date.now() > deadline) throw new Error('Timed out in waitUntil') + await new Promise((resolve) => setTimeout(resolve, 10)) + } +} + +class EventedFakeRegistry extends EventEmitter { + list() { + return [] + } + get() { return null } + create() { throw new Error('not used') } + attach() { return null } + finishAttachSnapshot() {} + detach() { return false } + input() { return false } + resize() { return false } + kill() { return false } + findRunningClaudeTerminalBySession() { return undefined } +} + +describe('ws amplifier events-driven activity', () => { + let server: http.Server + let port: number + let wsHandler: any + let registry: EventedFakeRegistry + let amplifierActivity: any + let integration: any + const fsStore = createFakeEventsFs() + const watch = createFakeWatchFactory() + const warn = vi.fn() + const eventsPathBySession: Record = { + 's-ev1': '/fake/amp/sessions/s-ev1/events.jsonl', + 's-ev2': '/fake/amp/sessions/s-ev2/events.jsonl', + } + + async function connect(): Promise { + const ws = new WebSocket(`ws://127.0.0.1:${port}/ws`) + await new Promise((resolve) => ws.on('open', () => resolve())) + ws.send(JSON.stringify({ type: 'hello', token: 'amplifier-activity-token', protocolVersion: WS_PROTOCOL_VERSION })) + await waitForMessage(ws, (msg) => msg.type === 'ready') + return ws + } + + beforeAll(async () => { + process.env.NODE_ENV = 'test' + process.env.AUTH_TOKEN = 'amplifier-activity-token' + + const { WsHandler } = await import('../../server/ws-handler') + const { wireAmplifierActivityTracker } = await import('../../server/coding-cli/amplifier-activity-wiring') + const { createAmplifierActivityIntegration } = await import('../../server/coding-cli/amplifier-activity-integration') + + registry = new EventedFakeRegistry() + amplifierActivity = wireAmplifierActivityTracker({ registry: registry as any }) + integration = createAmplifierActivityIntegration({ + registry: registry as any, + tracker: amplifierActivity.tracker, + resolveEventsPath: (sessionId: string) => eventsPathBySession[sessionId], + log: { warn }, + watchImpl: watch.factory as any, + fsImpl: fsStore.fsImpl as any, + }) + + server = http.createServer((_req, res) => { + res.statusCode = 404 + res.end() + }) + wsHandler = new WsHandler( + server, + registry as any, + { + amplifierActivityListProvider: () => amplifierActivity.tracker.list(), + amplifierLatestTurnCompletionsProvider: () => amplifierActivity.tracker.listLatestCompletions(), + }, + ) + // Mirror server/index.ts composition: tracker events → WS broadcasts. + amplifierActivity.tracker.on('changed', (payload: any) => { + wsHandler.broadcastAmplifierActivityUpdated(payload) + }) + amplifierActivity.tracker.on('turn.complete', (payload: any) => { + wsHandler.broadcastTerminalTurnComplete({ + provider: 'amplifier', + terminalId: payload.terminalId, + at: payload.at, + completionSeq: payload.completionSeq, + ...(payload.sessionId ? { sessionId: payload.sessionId } : {}), + }) + }) + port = await listen(server) + }) + + afterAll(async () => { + await integration?.dispose?.() + amplifierActivity?.dispose?.() + wsHandler?.close?.() + await new Promise((resolve) => server.close(() => resolve())) + }) + + it('drives busy on prompt:submit and exactly one terminal.turn.complete on prompt:complete; naming records never re-busy', async () => { + const ws = await connect() + + // Resume bind → EOF attach on the pre-existing events file (no replay). + fsStore.write(eventsPathBySession['s-ev1'], eventsLine('session:start') + eventsLine('session:config')) + registry.emit('terminal.session.bound', { + terminalId: 'term-ev1', + provider: 'amplifier', + sessionId: 's-ev1', + reason: 'resume', + }) + await waitUntil(() => watch.watchers.some((w) => w.watchedPath === '/fake/amp/sessions/s-ev1')) + await flushAsync() + + fsStore.append(eventsPathBySession['s-ev1'], eventsLine('prompt:submit')) + watch.watchers.find((w) => w.watchedPath === '/fake/amp/sessions/s-ev1')!.fire('change', eventsPathBySession['s-ev1']) + const busy = await waitForMessage(ws, (msg) => + msg.type === 'amplifier.activity.updated' + && msg.upsert?.some((record: any) => record.terminalId === 'term-ev1' && record.phase === 'busy')) + expect(busy.upsert[0]).toMatchObject({ terminalId: 'term-ev1', sessionId: 's-ev1', phase: 'busy' }) + + fsStore.append(eventsPathBySession['s-ev1'], eventsLine('prompt:complete')) + watch.watchers.find((w) => w.watchedPath === '/fake/amp/sessions/s-ev1')!.fire('change', eventsPathBySession['s-ev1']) + const completed = await waitForMessage(ws, (msg) => + msg.type === 'terminal.turn.complete' && msg.terminalId === 'term-ev1') + expect(completed).toMatchObject({ + provider: 'amplifier', + terminalId: 'term-ev1', + sessionId: 's-ev1', + completionSeq: 1, + }) + + // Post-complete background naming events (E2): no re-busy, no second completion. + fsStore.append(eventsPathBySession['s-ev1'], eventsLine('llm:request') + eventsLine('provider:retry')) + watch.watchers.find((w) => w.watchedPath === '/fake/amp/sessions/s-ev1')!.fire('change', eventsPathBySession['s-ev1']) + await expect(expectNoMatchingMessage(ws, (msg) => + (msg.type === 'amplifier.activity.updated' + && msg.upsert?.some((record: any) => record.terminalId === 'term-ev1' && record.phase === 'busy')) + || (msg.type === 'terminal.turn.complete' && msg.terminalId === 'term-ev1'))).resolves.toBeUndefined() + + ws.close() + }) + + it('tailer failure mid-turn reverts the phase to idle with NO turn.complete (single degrade warn)', async () => { + const ws = await connect() + + fsStore.write(eventsPathBySession['s-ev2'], eventsLine('session:start')) + registry.emit('terminal.session.bound', { + terminalId: 'term-ev2', + provider: 'amplifier', + sessionId: 's-ev2', + reason: 'resume', + }) + await waitUntil(() => watch.watchers.some((w) => w.watchedPath === '/fake/amp/sessions/s-ev2')) + await flushAsync() + + fsStore.append(eventsPathBySession['s-ev2'], eventsLine('prompt:submit')) + const watcher = watch.watchers.find((w) => w.watchedPath === '/fake/amp/sessions/s-ev2')! + watcher.fire('change', eventsPathBySession['s-ev2']) + await waitForMessage(ws, (msg) => + msg.type === 'amplifier.activity.updated' + && msg.upsert?.some((record: any) => record.terminalId === 'term-ev2' && record.phase === 'busy')) + + // Tailer error mid-turn → single degrade warn + silent idle reversion over + // the wire. Attach the idle listener BEFORE firing so the broadcast cannot + // race past it. + const idlePromise = waitForMessage(ws, (msg) => + msg.type === 'amplifier.activity.updated' + && msg.upsert?.some((record: any) => record.terminalId === 'term-ev2' && record.phase === 'idle')) + fsStore.failStat(eventsPathBySession['s-ev2']) + watcher.fire('change', eventsPathBySession['s-ev2']) + await waitUntil(() => warn.mock.calls.some((call) => + (call[0] as any)?.event === 'amplifier_events_lane_degraded' && (call[0] as any)?.terminalId === 'term-ev2')) + const idle = await idlePromise + expect(idle.upsert.find((record: any) => record.terminalId === 'term-ev2')).toMatchObject({ phase: 'idle' }) + const degradeWarns = warn.mock.calls.filter((call) => + (call[0] as any)?.event === 'amplifier_events_lane_degraded' && (call[0] as any)?.terminalId === 'term-ev2') + expect(degradeWarns).toHaveLength(1) + + // NO timing fallback: PTY output after the degrade must not resurrect the + // turn or fabricate a completion (the removed heuristic fired ~2s after + // output-silence, so the window is held open past that). + registry.emit('terminal.output.raw', { terminalId: 'term-ev2', data: 'tool output', at: Date.now() }) + await expect(expectNoMatchingMessage(ws, (msg) => + (msg.type === 'terminal.turn.complete' && msg.terminalId === 'term-ev2') + || (msg.type === 'amplifier.activity.updated' + && msg.upsert?.some((record: any) => record.terminalId === 'term-ev2' && record.phase === 'busy')), + 2600)).resolves.toBeUndefined() + expect(amplifierActivity.tracker.getActivity('term-ev2')?.phase).toBe('idle') + + ws.close() + }) +}) diff --git a/test/unit/server/coding-cli/amplifier-activity-integration.test.ts b/test/unit/server/coding-cli/amplifier-activity-integration.test.ts new file mode 100644 index 00000000..063c1f79 --- /dev/null +++ b/test/unit/server/coding-cli/amplifier-activity-integration.test.ts @@ -0,0 +1,573 @@ +import { EventEmitter } from 'events' +import { describe, expect, it, vi } from 'vitest' +import { + AMPLIFIER_BUSY_DEADMAN_MS, + AmplifierActivityTracker, + type AmplifierTurnCompleteEvent, +} from '../../../../server/coding-cli/amplifier-activity-tracker.js' +import { + AMPLIFIER_CATCHUP_MAX_BYTES, + createAmplifierActivityIntegration, + type AmplifierEventsWatchFactory, + type AmplifierEventsWatcher, +} from '../../../../server/coding-cli/amplifier-activity-integration.js' +import type { AmplifierTailerFs } from '../../../../server/coding-cli/amplifier-events-tailer.js' + +const SCHEMA = '"schema": {"name": "amplifier.log", "ver": "1.0.0"}' + +// Records carry ISO timestamps derived from small epoch-ms values so tracker +// timestamps (parsed from record ts) are easy to reason about in assertions. +function line(event: string, atMs = 1000, extra = ''): string { + return `{"ts": "${new Date(atMs).toISOString()}", "lvl": "INFO", ${SCHEMA}, ` + + `"event": "${event}", "session_id": "session-1", "data": {"parent_id": null${extra}}}\n` +} + +function badSchemaLine(event: string, atMs = 1000): string { + return `{"ts": "${new Date(atMs).toISOString()}", "schema": {"name": "amplifier.log", "ver": "2.0.0"}, ` + + `"event": "${event}", "session_id": "session-1", "data": {"parent_id": null}}\n` +} + +type FakeFsStore = { + fsImpl: AmplifierTailerFs + write(path: string, text: string): void + append(path: string, text: string): void + failStat(path: string): void +} + +function createFakeFsStore(): FakeFsStore { + const files = new Map() + const statFailures = new Set() + const fsImpl: AmplifierTailerFs = { + async stat(path) { + if (statFailures.has(path)) throw new Error('EIO: injected stat failure') + const content = files.get(path) + if (!content) throw Object.assign(new Error('ENOENT'), { code: 'ENOENT' }) + return { size: content.length } + }, + async open(path) { + return { + async read(buffer: Buffer, offset: number, length: number, position: number) { + const content = files.get(path) ?? Buffer.alloc(0) + const slice = content.subarray(position, position + length) + slice.copy(buffer, offset) + return { bytesRead: slice.length } + }, + async close() {}, + } + }, + } + return { + fsImpl, + write(path, text) { + files.set(path, Buffer.from(text, 'utf8')) + }, + append(path, text) { + files.set(path, Buffer.concat([files.get(path) ?? Buffer.alloc(0), Buffer.from(text, 'utf8')])) + }, + failStat(path) { + statFailures.add(path) + }, + } +} + +type FakeWatcher = AmplifierEventsWatcher & { + watchedPath: string + closed: boolean + fire(event: string, path: string): void +} + +function createFakeWatchFactory(): { factory: AmplifierEventsWatchFactory; watchers: FakeWatcher[] } { + const watchers: FakeWatcher[] = [] + const factory: AmplifierEventsWatchFactory = (watchedPath) => { + const handlers = new Map void>>() + const watcher: FakeWatcher = { + watchedPath, + closed: false, + on(event, handler) { + const list = handlers.get(event) ?? [] + list.push(handler) + handlers.set(event, list) + return watcher + }, + async close() { + watcher.closed = true + }, + fire(event, path) { + for (const handler of handlers.get(event) ?? []) handler(path) + }, + } + watchers.push(watcher) + return watcher + } + return { factory, watchers } +} + +class FakeRegistry extends EventEmitter { + terminals = new Map() + list() { + return Array.from(this.terminals.values()).map(({ terminalId }) => ({ terminalId })) + } + get(terminalId: string) { + return this.terminals.get(terminalId) + } +} + +async function flush(rounds = 8): Promise { + for (let i = 0; i < rounds; i += 1) { + await new Promise((resolve) => setImmediate(resolve)) + } +} + +const EVENTS_PATH = '/fake/projects/-work/sessions/session-1/events.jsonl' + +function setup(options: { + resolveEventsPath?: (sessionId: string) => string | undefined +} = {}) { + const registry = new FakeRegistry() + const tracker = new AmplifierActivityTracker() + const completions: AmplifierTurnCompleteEvent[] = [] + tracker.on('turn.complete', (event: AmplifierTurnCompleteEvent) => completions.push(event)) + const fsStore = createFakeFsStore() + const { factory, watchers } = createFakeWatchFactory() + const warn = vi.fn() + const integration = createAmplifierActivityIntegration({ + registry, + tracker, + resolveEventsPath: options.resolveEventsPath ?? (() => EVENTS_PATH), + log: { warn }, + watchImpl: factory, + fsImpl: fsStore.fsImpl, + }) + return { registry, tracker, completions, fsStore, watchers, warn, integration } +} + +function bound(registry: FakeRegistry, input: { + terminalId?: string + sessionId?: string + reason?: 'start' | 'resume' | 'association' +}) { + registry.emit('terminal.session.bound', { + terminalId: input.terminalId ?? 't1', + provider: 'amplifier', + sessionId: input.sessionId ?? 'session-1', + reason: input.reason ?? 'association', + }) +} + +describe('amplifier activity integration', () => { + it('fresh bind over already-finished turns adopts final state without phantom completions (catch-up suppression)', async () => { + const { registry, tracker, completions, fsStore, watchers } = setup() + // Fast-path/coordinator binds fire only AFTER metadata.json lands (= after + // the first prompt:complete), so the file already holds finished turns. + fsStore.write( + EVENTS_PATH, + line('session:start', 1000) + + line('session:config', 1010, ', "raw": {"working_dir": "/work"}') + + line('prompt:submit', 2000) + + line('prompt:complete', 5000) + + line('prompt:submit', 6000) + + line('prompt:complete', 7000), + ) + + bound(registry, { reason: 'association' }) + await flush() + + // Catch-up drain: state adopted (idle), ZERO replayed turn.completes — + // turns that finished before the bind are history, not live turns (finding C). + expect(completions).toHaveLength(0) + expect(tracker.getActivity('t1')).toMatchObject({ phase: 'idle', sessionId: 'session-1' }) + expect(watchers).toHaveLength(1) + expect(watchers[0].watchedPath).toBe('/fake/projects/-work/sessions/session-1') + + // Records appended AFTER catch-up are live and emit normally. + fsStore.append(EVENTS_PATH, line('prompt:submit', 8000)) + watchers[0].fire('change', EVENTS_PATH) + await flush() + expect(tracker.getActivity('t1')?.phase).toBe('busy') + fsStore.append(EVENTS_PATH, line('prompt:complete', 9000)) + watchers[0].fire('change', EVENTS_PATH) + await flush() + expect(completions).toHaveLength(1) + expect(completions[0]).toMatchObject({ terminalId: 't1', at: 9000, completionSeq: 1 }) + }) + + it('locator bind mid-turn: catch-up adopts busy, the live prompt:complete emits exactly one completion', async () => { + const { tracker, completions, fsStore, watchers, integration } = setup() + // Locator-fresh bind: the file holds an in-flight turn at attach time. + // Record timestamps are ANCIENT epoch values (2000ms = 1970): without the + // staleness clamp the adopted busy would look >120s silent and trigger an + // instant deadman force-read on the first sweep (re-verify quirk). + fsStore.write( + EVENTS_PATH, + line('session:start', 1000) + + line('session:config', 1010, ', "raw": {"working_dir": "/work"}') + + line('prompt:submit', 2000), + ) + + const beforeAttach = Date.now() + await integration.attachTailer('t1', 'session-1', EVENTS_PATH, 'start') + await flush() + // Catch-up ends busy (adopted once), with no completion emitted yet. + expect(tracker.getActivity('t1')?.phase).toBe('busy') + expect(completions).toHaveLength(0) + // Liveness bookkeeping clamped to attach time: no instant-deadman on + // adoption of an old in-flight turn. + expect(tracker.getActivity('t1')!.updatedAt).toBeGreaterThanOrEqual(beforeAttach) + + fsStore.append(EVENTS_PATH, line('prompt:complete', 5000)) + watchers[0].fire('change', EVENTS_PATH) + await flush() + expect(tracker.getActivity('t1')?.phase).toBe('idle') + expect(completions).toHaveLength(1) + expect(completions[0]).toMatchObject({ terminalId: 't1', at: 5000, completionSeq: 1 }) + }) + + it('skips catch-up entirely and attaches at EOF when the backlog exceeds the cap', async () => { + const { registry, tracker, completions, fsStore, watchers } = setup() + // Real events files reach hundreds of MB; replaying them at attach would + // hold the process hostage. Over the cap: attach at EOF, stay idle. + fsStore.write(EVENTS_PATH, 'x'.repeat(AMPLIFIER_CATCHUP_MAX_BYTES + 1024) + '\n') + + bound(registry, { reason: 'association' }) + await flush() + expect(completions).toHaveLength(0) + expect(tracker.getActivity('t1')?.phase).toBe('idle') + + // Live records take over from EOF. + fsStore.append(EVENTS_PATH, line('prompt:submit', 9000)) + watchers[0].fire('change', EVENTS_PATH) + await flush() + expect(tracker.getActivity('t1')?.phase).toBe('busy') + }) + + it('same-tick double attach keeps exactly one live watcher and one completion; dispose closes everything', async () => { + const { tracker, completions, fsStore, watchers, integration } = setup() + fsStore.write(EVENTS_PATH, line('session:start', 1000)) + + // Probe-reproduced cascade (finding B): bindSession → onBound attach #1 and + // controller 'associated' → attach #2 in the same synchronous cascade. + const first = integration.attachTailer('t1', 'session-1', EVENTS_PATH, 'start') + const second = integration.attachTailer('t1', 'session-1', EVENTS_PATH, 'start') + await Promise.all([first, second]) + await flush() + + expect(watchers).toHaveLength(2) + expect(watchers.filter((watcher) => !watcher.closed)).toHaveLength(1) + + // A full live turn pumped through BOTH watchers still emits exactly once + // (the orphaned first attachment is closed, not double-pumping). + fsStore.append(EVENTS_PATH, line('prompt:submit', 2000) + line('prompt:complete', 5000)) + for (const watcher of watchers) watcher.fire('change', EVENTS_PATH) + await flush() + expect(completions).toHaveLength(1) + expect(completions[0]).toMatchObject({ terminalId: 't1', at: 5000, completionSeq: 1 }) + expect(tracker.getActivity('t1')?.phase).toBe('idle') + + await integration.dispose() + expect(watchers.every((watcher) => watcher.closed)).toBe(true) + }) + + it('resume bind attaches at EOF: history is not replayed, later appends flow through', async () => { + const { registry, tracker, completions, fsStore, watchers } = setup() + fsStore.write( + EVENTS_PATH, + line('session:start', 1000) + line('prompt:submit', 2000) + line('prompt:complete', 5000), + ) + + bound(registry, { reason: 'resume' }) + await flush() + expect(completions).toHaveLength(0) + expect(tracker.getActivity('t1')?.phase).toBe('idle') + + fsStore.append(EVENTS_PATH, line('session:resume', 6000) + line('prompt:submit', 7000)) + watchers[0].fire('change', EVENTS_PATH) + await flush() + expect(tracker.getActivity('t1')?.phase).toBe('busy') + + fsStore.append(EVENTS_PATH, line('prompt:complete', 9000)) + watchers[0].fire('change', EVENTS_PATH) + await flush() + expect(tracker.getActivity('t1')?.phase).toBe('idle') + expect(completions).toHaveLength(1) + expect(completions[0]).toMatchObject({ terminalId: 't1', at: 9000, completionSeq: 1 }) + }) + + it('fresh bind tolerates a not-yet-existing events file (dir watcher drives the first read)', async () => { + const { registry, tracker, fsStore, watchers, warn } = setup() + + bound(registry, { reason: 'association' }) + await flush() + expect(warn).not.toHaveBeenCalled() + expect(watchers).toHaveLength(1) + + fsStore.write(EVENTS_PATH, line('session:start', 1000) + line('prompt:submit', 2000)) + watchers[0].fire('add', EVENTS_PATH) + await flush() + expect(tracker.getActivity('t1')?.phase).toBe('busy') + }) + + it('reducer lane.degrade stops events tracking once with a single warn and closes the watcher', async () => { + const { registry, tracker, fsStore, watchers, warn } = setup() + const signalLostSpy = vi.spyOn(tracker, 'noteEventsSignalLost') + fsStore.write(EVENTS_PATH, line('session:start', 1000)) + + bound(registry, {}) + await flush() + expect(warn).not.toHaveBeenCalled() + + // The tailer's schema gate only checks the first record of the file; a later + // bad-schema record reaches the reducer, which degrades the tracking. + fsStore.append(EVENTS_PATH, badSchemaLine('prompt:submit', 2000)) + watchers[0].fire('change', EVENTS_PATH) + await flush() + + expect(signalLostSpy).toHaveBeenCalledWith('t1') + expect(warn).toHaveBeenCalledTimes(1) + expect(warn.mock.calls[0][0]).toMatchObject({ + event: 'amplifier_events_lane_degraded', + terminalId: 't1', + reason: 'schema_version_unsupported', + }) + expect(watchers[0].closed).toBe(true) + + // Further watcher noise is ignored: no second warn, no churn. + fsStore.append(EVENTS_PATH, line('prompt:submit', 3000)) + watchers[0].fire('change', EVENTS_PATH) + await flush() + expect(warn).toHaveBeenCalledTimes(1) + expect(tracker.getActivity('t1')?.phase).toBe('idle') + }) + + it('tailer error mid-turn degrades once: idle with NO completion (no timing fallback)', async () => { + const { registry, tracker, completions, fsStore, watchers, warn } = setup() + fsStore.write(EVENTS_PATH, line('session:start', 1000)) + bound(registry, {}) + await flush() + + fsStore.append(EVENTS_PATH, line('prompt:submit', 2000)) + watchers[0].fire('change', EVENTS_PATH) + await flush() + expect(tracker.getActivity('t1')?.phase).toBe('busy') + + fsStore.failStat(EVENTS_PATH) + watchers[0].fire('change', EVENTS_PATH) + await flush() + expect(warn).toHaveBeenCalledTimes(1) + expect(warn.mock.calls[0][0]).toMatchObject({ + event: 'amplifier_events_lane_degraded', + terminalId: 't1', + reason: 'read_error', + }) + // Signal-loss policy: the in-flight turn is dropped — idle silently, no + // fabricated turn.complete, and nothing ever finishes it later. + expect(tracker.getActivity('t1')?.phase).toBe('idle') + expect(completions).toHaveLength(0) + expect(watchers[0].closed).toBe(true) + }) + + it('deadman force-read recovers a completion missed by the watcher (WSL2 backstop)', async () => { + const { registry, tracker, completions, fsStore, watchers } = setup() + fsStore.write(EVENTS_PATH, line('session:start', 1000)) + bound(registry, {}) + await flush() + + fsStore.append(EVENTS_PATH, line('prompt:submit', 2000)) + watchers[0].fire('change', EVENTS_PATH) + await flush() + expect(tracker.getActivity('t1')?.phase).toBe('busy') + + // The completion lands on disk but the watcher never fires (dropped inotify). + fsStore.append(EVENTS_PATH, line('prompt:complete', 5000)) + tracker.expire(2000 + AMPLIFIER_BUSY_DEADMAN_MS + 1) + await flush() + + expect(tracker.getActivity('t1')?.phase).toBe('idle') + expect(completions).toHaveLength(1) + expect(completions[0]).toMatchObject({ terminalId: 't1', at: 5000, completionSeq: 1 }) + }) + + it('deadman force-read with nothing new on disk stays busy (genuine long turn)', async () => { + const { registry, tracker, completions, fsStore, watchers } = setup() + fsStore.write(EVENTS_PATH, line('session:start', 1000)) + bound(registry, {}) + await flush() + fsStore.append(EVENTS_PATH, line('prompt:submit', 2000)) + watchers[0].fire('change', EVENTS_PATH) + await flush() + + tracker.expire(2000 + AMPLIFIER_BUSY_DEADMAN_MS + 1) + await flush() + expect(tracker.getActivity('t1')?.phase).toBe('busy') + expect(completions).toHaveLength(0) + }) + + it('terminal exit closes the watcher and stops all reads (leak assertion)', async () => { + const { registry, tracker, fsStore, watchers, integration } = setup() + fsStore.write(EVENTS_PATH, line('session:start', 1000)) + bound(registry, {}) + await flush() + expect(watchers).toHaveLength(1) + expect(watchers[0].closed).toBe(false) + + registry.emit('terminal.exit', { terminalId: 't1' }) + // Mirror production: the frozen wiring removes the tracker state on exit. + tracker.noteExit({ terminalId: 't1' }) + await flush() + expect(watchers.every((watcher) => watcher.closed)).toBe(true) + + fsStore.append(EVENTS_PATH, line('prompt:submit', 2000)) + watchers[0].fire('change', EVENTS_PATH) + await flush() + expect(tracker.getActivity('t1')).toBeUndefined() + + // N3: the per-terminal serialization chain entry is mirror-deleted once it + // settles — bind→exit cycles must not grow the map forever. + expect(integration.getAttachChainCount()).toBe(0) + + // A second bind→exit cycle also returns to zero. + bound(registry, { terminalId: 't2', sessionId: 'session-1' }) + await flush() + expect(integration.getAttachChainCount()).toBe(0) + registry.emit('terminal.exit', { terminalId: 't2' }) + await flush() + expect(integration.getAttachChainCount()).toBe(0) + }) + + it('a synchronous throw during attach degrades the terminal instead of escaping the chain (N2)', async () => { + const registry = new FakeRegistry() + const tracker = new AmplifierActivityTracker() + const signalLostSpy = vi.spyOn(tracker, 'noteEventsSignalLost') + const fsStore = createFakeFsStore() + fsStore.write(EVENTS_PATH, line('session:start', 1000)) + const warn = vi.fn() + const { factory, watchers } = createFakeWatchFactory() + // e.g. chokidar hitting ENOSPC (inotify watch limit) throws synchronously + // on the FIRST attach; a later attach succeeds (limit freed up). + let watchCalls = 0 + const integration = createAmplifierActivityIntegration({ + registry, + tracker, + resolveEventsPath: () => EVENTS_PATH, + log: { warn }, + watchImpl: (watchPath, options) => { + watchCalls += 1 + if (watchCalls === 1) { + throw new Error('ENOSPC: System limit for number of file watchers reached') + } + return factory(watchPath, options) + }, + fsImpl: fsStore.fsImpl, + }) + + // Must RESOLVE (contained), never escape as an unhandled rejection. + await expect(integration.attachTailer('t1', 'session-1', EVENTS_PATH, 'start')) + .resolves.toBeUndefined() + expect(signalLostSpy).toHaveBeenCalledWith('t1') + expect(warn).toHaveBeenCalledTimes(1) + expect(warn.mock.calls[0][0]).toMatchObject({ + event: 'amplifier_events_lane_degraded', + terminalId: 't1', + reason: 'attach_error', + }) + + // The serialized chain recovered: the NEXT attach for the same terminal + // works normally (watcher created, events tracking live again). + await expect(integration.attachTailer('t1', 'session-1', EVENTS_PATH, 'start')) + .resolves.toBeUndefined() + await flush() + expect(watchers).toHaveLength(1) + expect(watchers[0].closed).toBe(false) + fsStore.append(EVENTS_PATH, line('prompt:submit', 2000)) + watchers[0].fire('change', EVENTS_PATH) + await flush() + expect(tracker.getActivity('t1')?.phase).toBe('busy') + // Still exactly one attach_error warn: the recovery attach warned nothing. + expect(warn).toHaveBeenCalledTimes(1) + + // Dispose settles cleanly too. + await expect(integration.dispose()).resolves.toBeUndefined() + expect(watchers.every((watcher) => watcher.closed)).toBe(true) + }) + + it('re-attaching a terminal closes the previous watcher first', async () => { + const { fsStore, watchers, integration } = setup() + fsStore.write(EVENTS_PATH, line('session:start', 1000)) + await integration.attachTailer('t1', 'session-1', EVENTS_PATH, 'eof') + await integration.attachTailer('t1', 'session-1', EVENTS_PATH, 'eof') + await flush() + expect(watchers).toHaveLength(2) + expect(watchers[0].closed).toBe(true) + expect(watchers[1].closed).toBe(false) + }) + + it('attaches at EOF for already-running bound amplifier terminals at construction', async () => { + const registry = new FakeRegistry() + registry.terminals.set('t-restored', { + terminalId: 't-restored', + mode: 'amplifier', + status: 'running', + resumeSessionId: 'session-1', + }) + registry.terminals.set('t-shell', { terminalId: 't-shell', mode: 'shell', status: 'running' }) + const tracker = new AmplifierActivityTracker() + const completions: AmplifierTurnCompleteEvent[] = [] + tracker.on('turn.complete', (event: AmplifierTurnCompleteEvent) => completions.push(event)) + const fsStore = createFakeFsStore() + fsStore.write(EVENTS_PATH, line('session:start', 1000) + line('prompt:submit', 2000) + line('prompt:complete', 3000)) + const { factory, watchers } = createFakeWatchFactory() + const integration = createAmplifierActivityIntegration({ + registry, + tracker, + resolveEventsPath: () => EVENTS_PATH, + watchImpl: factory, + fsImpl: fsStore.fsImpl, + }) + await flush() + + // EOF attach: pre-existing history is never replayed as live turns. + expect(watchers).toHaveLength(1) + expect(completions).toHaveLength(0) + + fsStore.append(EVENTS_PATH, line('prompt:submit', 9000)) + watchers[0].fire('change', EVENTS_PATH) + await flush() + expect(tracker.getActivity('t-restored')?.phase).toBe('busy') + await integration.dispose() + }) + + it('ignores binds whose session has no resolvable events path (fresh, unindexed — Phase 3 territory)', async () => { + const { registry, watchers, warn } = setup({ resolveEventsPath: () => undefined }) + bound(registry, {}) + await flush() + expect(watchers).toHaveLength(0) + expect(warn).not.toHaveBeenCalled() + }) + + it('ignores non-amplifier binds', async () => { + const { registry, watchers } = setup() + registry.emit('terminal.session.bound', { + terminalId: 't1', + provider: 'claude', + sessionId: 'session-1', + reason: 'resume', + }) + await flush() + expect(watchers).toHaveLength(0) + }) + + it('dispose unsubscribes and closes every watcher', async () => { + const { registry, fsStore, watchers, integration } = setup() + fsStore.write(EVENTS_PATH, line('session:start', 1000)) + bound(registry, {}) + await flush() + expect(watchers).toHaveLength(1) + + await integration.dispose() + expect(watchers.every((watcher) => watcher.closed)).toBe(true) + + // Listener removed: a new bound event no longer creates watchers. + bound(registry, { terminalId: 't2' }) + await flush() + expect(watchers).toHaveLength(1) + }) +}) diff --git a/test/unit/server/coding-cli/amplifier-activity-tracker.test.ts b/test/unit/server/coding-cli/amplifier-activity-tracker.test.ts index 18cd3191..62e31afb 100644 --- a/test/unit/server/coding-cli/amplifier-activity-tracker.test.ts +++ b/test/unit/server/coding-cli/amplifier-activity-tracker.test.ts @@ -1,9 +1,11 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { AMPLIFIER_BUSY_DEADMAN_MS, - AMPLIFIER_IDLE_DEBOUNCE_MS, + AMPLIFIER_GRACE_REVERSION_SUSPECT_THRESHOLD, + AMPLIFIER_SUBMIT_GRACE_MS, AmplifierActivityTracker, type AmplifierActivityChange, + type AmplifierEventsForceReadRequest, type AmplifierTurnCompleteEvent, } from '../../../../server/coding-cli/amplifier-activity-tracker' @@ -16,15 +18,23 @@ function setup() { return { tracker, changes, completions } } +// ISO timestamp for an epoch-ms value (reducer effects carry ISO `at` strings). +function iso(ms: number): string { + return new Date(ms).toISOString() +} + describe('AmplifierActivityTracker', () => { beforeEach(() => { vi.useFakeTimers() + vi.setSystemTime(0) }) afterEach(() => { vi.useRealTimers() }) - it('starts idle on track and goes busy on submit', () => { + // ---- PTY basics ----------------------------------------------------------- + + it('starts idle on track and goes (provisionally) busy on submit', () => { const { tracker } = setup() tracker.trackTerminal({ terminalId: 't1', at: 1000 }) expect(tracker.getActivity('t1')?.phase).toBe('idle') @@ -39,103 +49,308 @@ describe('AmplifierActivityTracker', () => { expect(tracker.getActivity('t1')?.phase).toBe('idle') }) - it('holds busy while output streams (each output restarts the idle-debounce)', () => { - const { tracker, completions } = setup() + it('removes state on exit and emits a removal', () => { + const { tracker, changes } = setup() + tracker.trackTerminal({ terminalId: 't1', at: 1000 }) + tracker.noteExit({ terminalId: 't1' }) + expect(tracker.getActivity('t1')).toBeUndefined() + expect(changes.at(-1)).toEqual({ upsert: [], remove: ['t1'] }) + }) + + it('list() reflects current records', () => { + const { tracker } = setup() tracker.trackTerminal({ terminalId: 't1', at: 1000 }) tracker.noteInput({ terminalId: 't1', data: '\r', at: 2000 }) - tracker.noteOutput({ terminalId: 't1', data: 'thinking...', at: 2500 }) - expect(tracker.getActivity('t1')?.phase).toBe('busy') - // Output arriving just before the debounce elapses restarts the timer. - vi.advanceTimersByTime(AMPLIFIER_IDLE_DEBOUNCE_MS - 1) - tracker.noteOutput({ terminalId: 't1', data: 'more', at: 4499 }) - vi.advanceTimersByTime(AMPLIFIER_IDLE_DEBOUNCE_MS - 1) - expect(tracker.getActivity('t1')?.phase).toBe('busy') + expect(tracker.list()).toEqual([{ terminalId: 't1', phase: 'busy', updatedAt: 2000 }]) + }) + + it('attaches sessionId via bindSession', () => { + const { tracker } = setup() + tracker.trackTerminal({ terminalId: 't1', at: 1000 }) + tracker.bindSession({ terminalId: 't1', sessionId: 's-1', at: 1500 }) + expect(tracker.getActivity('t1')?.sessionId).toBe('s-1') + }) + + it('applyLifecycle/noteEventsSignalLost are safe no-ops for unknown terminals', () => { + const { tracker, changes, completions } = setup() + tracker.applyLifecycle('missing', { kind: 'turn.began', at: iso(1000) }) + tracker.noteEventsSignalLost('missing') + expect(changes).toHaveLength(0) expect(completions).toHaveLength(0) }) - it('completes a turn on output-idle and emits exactly one turn.complete', () => { - const { tracker, completions } = setup() + // ---- submit grace (provisional busy) -------------------------------------- + + it('PTY submit is only provisionally busy: grace expiry force-reads once, then silently reverts', () => { + const { tracker, changes, completions } = setup() + const forceReads: AmplifierEventsForceReadRequest[] = [] + tracker.on('events.force-read', (request: AmplifierEventsForceReadRequest) => forceReads.push(request)) tracker.trackTerminal({ terminalId: 't1', at: 1000 }) tracker.noteInput({ terminalId: 't1', data: '\r', at: 2000 }) - tracker.noteOutput({ terminalId: 't1', data: 'thinking...', at: 2500 }) expect(tracker.getActivity('t1')?.phase).toBe('busy') - // AMPLIFIER_IDLE_DEBOUNCE_MS of silence after the last output ends the turn. - vi.advanceTimersByTime(AMPLIFIER_IDLE_DEBOUNCE_MS) + + // First grace expiry: NOT a reversion yet — a silently-dead watcher (WSL2 + // inotify) may have swallowed the prompt:submit record, so request a + // force-read and extend the grace once (adversarial finding D). + vi.advanceTimersByTime(AMPLIFIER_SUBMIT_GRACE_MS) + expect(tracker.getActivity('t1')?.phase).toBe('busy') + expect(forceReads).toHaveLength(1) + expect(forceReads[0]).toMatchObject({ terminalId: 't1' }) + + // Second expiry with still no prompt:submit: silent reversion — NO + // turn.complete (empty-Enter writes zero events, E5) — but the phase flip + // is publicly visible. + vi.advanceTimersByTime(AMPLIFIER_SUBMIT_GRACE_MS) expect(tracker.getActivity('t1')?.phase).toBe('idle') - expect(completions).toHaveLength(1) - expect(completions[0]).toMatchObject({ terminalId: 't1', completionSeq: 1 }) - expect(tracker.listLatestCompletions()).toHaveLength(1) - expect(tracker.listLatestCompletions()[0]).toMatchObject({ terminalId: 't1', completionSeq: 1 }) + expect(completions).toHaveLength(0) + expect(changes.at(-1)?.upsert[0]).toMatchObject({ terminalId: 't1', phase: 'idle' }) }) - it('does not go idle before the first output arrives after a submit', () => { + it('grace-expiry force-read that drains a prompt:submit confirms busy (watcher-dead backstop)', () => { const { tracker, completions } = setup() + // Simulate the integration: the force-read drain surfaces the prompt:submit + // record that the dead watcher never announced. + tracker.on('events.force-read', () => { + tracker.applyLifecycle('t1', { kind: 'turn.began', at: iso(2100) }) + }) tracker.trackTerminal({ terminalId: 't1', at: 1000 }) tracker.noteInput({ terminalId: 't1', data: '\r', at: 2000 }) - // No output yet: the idle-debounce timer is never armed, so pre-first-token - // latency (even well past the debounce) must NOT end the turn. - vi.advanceTimersByTime(AMPLIFIER_IDLE_DEBOUNCE_MS * 5) + + vi.advanceTimersByTime(AMPLIFIER_SUBMIT_GRACE_MS) + // Confirmed busy from the drained records: no reversion, ever. + vi.advanceTimersByTime(AMPLIFIER_SUBMIT_GRACE_MS * 5) expect(tracker.getActivity('t1')?.phase).toBe('busy') expect(completions).toHaveLength(0) }) - it('self-heals a stuck-busy terminal after the deadman and completes the turn', () => { + it('a session that never writes events.jsonl only ever shows grace-bounded busy pulses and no completions', () => { + // Documented behavior for bundles without the hooks-logging module: no + // events.jsonl ⇒ no confirmed busy, no turn.complete — ever. const { tracker, completions } = setup() tracker.trackTerminal({ terminalId: 't1', at: 1000 }) - tracker.noteInput({ terminalId: 't1', data: '\r', at: 2000 }) - // No output ever arrives, so the idle-debounce timer is never armed. The deadman - // sweep is the only failsafe end-of-turn and it also emits a completion. - tracker.expire(2000 + AMPLIFIER_BUSY_DEADMAN_MS + 1) - expect(tracker.getActivity('t1')?.phase).toBe('idle') - expect(completions).toHaveLength(1) - expect(completions[0]).toMatchObject({ terminalId: 't1', completionSeq: 1 }) + for (let i = 0; i < 3; i += 1) { + tracker.noteInput({ terminalId: 't1', data: '\r', at: 2000 + i * 10_000 }) + expect(tracker.getActivity('t1')?.phase).toBe('busy') + tracker.noteOutput({ terminalId: 't1', data: 'echo + spinner', at: 2100 + i * 10_000 }) + // Force-read retry + silent reversion. + vi.advanceTimersByTime(AMPLIFIER_SUBMIT_GRACE_MS * 2) + expect(tracker.getActivity('t1')?.phase).toBe('idle') + } + expect(completions).toHaveLength(0) + expect(tracker.listLatestCompletions()).toHaveLength(0) }) - it('output refreshes liveness so the deadman does not fire on an active turn', () => { - const { tracker } = setup() + it('three consecutive empty-Enter grace reversions log amplifier_events_lane_suspect once', () => { + const warn = vi.fn() + const tracker = new AmplifierActivityTracker({ log: { warn } }) + const completions: AmplifierTurnCompleteEvent[] = [] + tracker.on('turn.complete', (event: AmplifierTurnCompleteEvent) => completions.push(event)) + tracker.trackTerminal({ terminalId: 't1', at: 1000 }) + + for (let i = 0; i < AMPLIFIER_GRACE_REVERSION_SUSPECT_THRESHOLD + 1; i += 1) { + tracker.noteInput({ terminalId: 't1', data: '\r', at: 2000 + i * 10_000 }) + // Force-read retry + reversion. + vi.advanceTimersByTime(AMPLIFIER_SUBMIT_GRACE_MS * 2) + expect(tracker.getActivity('t1')?.phase).toBe('idle') + } + + const suspectWarns = warn.mock.calls.filter( + ([payload]) => (payload as any)?.event === 'amplifier_events_lane_suspect', + ) + expect(suspectWarns).toHaveLength(1) + expect(suspectWarns[0][0]).toMatchObject({ terminalId: 't1' }) + expect(completions).toHaveLength(0) + // Log signal only — lifecycle effects keep working. + tracker.applyLifecycle('t1', { kind: 'turn.began', at: iso(90_000) }) + expect(tracker.getActivity('t1')?.phase).toBe('busy') + }) + + // ---- events.jsonl lifecycle (the single source of turn boundaries) -------- + + it('turn.began confirms the provisional busy and cancels the grace reversion', () => { + const { tracker, completions } = setup() tracker.trackTerminal({ terminalId: 't1', at: 1000 }) tracker.noteInput({ terminalId: 't1', data: '\r', at: 2000 }) - tracker.noteOutput({ terminalId: 't1', data: 'progress', at: 2000 + AMPLIFIER_BUSY_DEADMAN_MS }) - tracker.expire(2000 + AMPLIFIER_BUSY_DEADMAN_MS + 1) + tracker.applyLifecycle('t1', { kind: 'turn.began', at: iso(2100) }) + + vi.advanceTimersByTime(AMPLIFIER_SUBMIT_GRACE_MS * 3) expect(tracker.getActivity('t1')?.phase).toBe('busy') + expect(completions).toHaveLength(0) }) - it('emits sequential completions across turns and carries the bound sessionId', () => { + it('turn.completed ends the turn with exactly one completion and sequential completionSeq', () => { const { tracker, completions } = setup() tracker.trackTerminal({ terminalId: 't1', at: 1000 }) tracker.bindSession({ terminalId: 't1', sessionId: 's-1', at: 1000 }) - // Turn 1 + + tracker.applyLifecycle('t1', { kind: 'turn.began', at: iso(2000) }) + expect(tracker.getActivity('t1')?.phase).toBe('busy') + tracker.applyLifecycle('t1', { kind: 'turn.completed', at: iso(5000) }) + expect(tracker.getActivity('t1')?.phase).toBe('idle') + expect(completions).toHaveLength(1) + expect(completions[0]).toMatchObject({ terminalId: 't1', sessionId: 's-1', at: 5000, completionSeq: 1 }) + expect(tracker.listLatestCompletions()).toEqual([{ terminalId: 't1', at: 5000, completionSeq: 1 }]) + + // A turn.completed at idle is not a turn (reducer already gates this; the + // tracker must not double-emit either). + tracker.applyLifecycle('t1', { kind: 'turn.completed', at: iso(5100) }) + expect(completions).toHaveLength(1) + + tracker.applyLifecycle('t1', { kind: 'turn.began', at: iso(6000) }) + tracker.applyLifecycle('t1', { kind: 'turn.completed', at: iso(9000) }) + expect(completions.map((completion) => completion.completionSeq)).toEqual([1, 2]) + }) + + it('emits sequential completions across PTY-submitted turns and carries the bound sessionId', () => { + const { tracker, completions } = setup() + tracker.trackTerminal({ terminalId: 't1', at: 1000 }) + tracker.bindSession({ terminalId: 't1', sessionId: 's-1', at: 1000 }) + // Turn 1: PTY submit → prompt:submit record confirms → prompt:complete ends. tracker.noteInput({ terminalId: 't1', data: '\r', at: 2000 }) - tracker.noteOutput({ terminalId: 't1', data: 'a', at: 2100 }) - vi.advanceTimersByTime(AMPLIFIER_IDLE_DEBOUNCE_MS) + tracker.applyLifecycle('t1', { kind: 'turn.began', at: iso(2100) }) + tracker.applyLifecycle('t1', { kind: 'turn.completed', at: iso(4000) }) expect(tracker.getActivity('t1')?.phase).toBe('idle') // Turn 2 tracker.noteInput({ terminalId: 't1', data: '\r', at: 5000 }) - tracker.noteOutput({ terminalId: 't1', data: 'b', at: 5100 }) - vi.advanceTimersByTime(AMPLIFIER_IDLE_DEBOUNCE_MS) + tracker.applyLifecycle('t1', { kind: 'turn.began', at: iso(5100) }) + tracker.applyLifecycle('t1', { kind: 'turn.completed', at: iso(7000) }) expect(completions.map((completion) => completion.completionSeq)).toEqual([1, 2]) expect(completions.every((completion) => completion.sessionId === 's-1')).toBe(true) }) - it('removes state on exit and emits a removal', () => { - const { tracker, changes } = setup() + it('PTY output never ends a turn: a 10-minute silent busy stays busy with no fabricated completion', () => { + const { tracker, completions } = setup() + tracker.trackTerminal({ terminalId: 't1', at: 1000 }) + tracker.applyLifecycle('t1', { kind: 'turn.began', at: iso(2000) }) + tracker.noteOutput({ terminalId: 't1', data: 'spinner', at: 2500 }) + + vi.advanceTimersByTime(10 * 60_000) + expect(tracker.getActivity('t1')?.phase).toBe('busy') + expect(completions).toHaveLength(0) + }) + + it('PTY submit during busy re-arms nothing (mid-turn steering stays in the same turn)', () => { + const { tracker, completions } = setup() + tracker.trackTerminal({ terminalId: 't1', at: 1000 }) + tracker.noteInput({ terminalId: 't1', data: '\r', at: 2000 }) + tracker.applyLifecycle('t1', { kind: 'turn.began', at: iso(2100) }) + + // Steering Enter mid-turn: no new grace timer, no reversion, no completion. + tracker.noteInput({ terminalId: 't1', data: '\r', at: 3000 }) + vi.advanceTimersByTime(AMPLIFIER_SUBMIT_GRACE_MS * 2) + expect(tracker.getActivity('t1')?.phase).toBe('busy') + expect(completions).toHaveLength(0) + }) + + it('session.identified updates the sessionId carried on later completions', () => { + const { tracker, completions } = setup() + tracker.trackTerminal({ terminalId: 't1', at: 1000 }) + tracker.applyLifecycle('t1', { kind: 'session.identified', sessionId: 's-9', cwd: '/work' }) + expect(tracker.getActivity('t1')?.sessionId).toBe('s-9') + + tracker.applyLifecycle('t1', { kind: 'turn.began', at: iso(2000) }) + tracker.applyLifecycle('t1', { kind: 'turn.completed', at: iso(3000) }) + expect(completions[0]).toMatchObject({ sessionId: 's-9', completionSeq: 1 }) + }) + + // ---- deadman = force-read failsafe ONLY ------------------------------------ + + it('deadman requests a force-read (logged once) and never fabricates a completion', () => { + const warn = vi.fn() + const tracker = new AmplifierActivityTracker({ log: { warn } }) + const completions: AmplifierTurnCompleteEvent[] = [] + const forceReads: AmplifierEventsForceReadRequest[] = [] + tracker.on('turn.complete', (event: AmplifierTurnCompleteEvent) => completions.push(event)) + tracker.on('events.force-read', (request: AmplifierEventsForceReadRequest) => forceReads.push(request)) + + tracker.trackTerminal({ terminalId: 't1', sessionId: 's-1', at: 1000 }) + tracker.applyLifecycle('t1', { kind: 'turn.began', at: iso(2000) }) + + const firstSweepAt = 2000 + AMPLIFIER_BUSY_DEADMAN_MS + 1 + tracker.expire(firstSweepAt) + expect(forceReads).toEqual([{ terminalId: 't1', sessionId: 's-1', at: firstSweepAt }]) + expect(tracker.getActivity('t1')?.phase).toBe('busy') + expect(completions).toHaveLength(0) + + // Every sweep past the deadman re-requests a force-read, but the warn is + // logged only once per stuck-busy period. + tracker.expire(firstSweepAt + 5000) + expect(forceReads).toHaveLength(2) + expect(completions).toHaveLength(0) + expect(warn).toHaveBeenCalledTimes(1) + expect(warn.mock.calls[0][0]).toMatchObject({ event: 'amplifier_activity_deadman_force_read', terminalId: 't1' }) + }) + + it('output refreshes liveness so the deadman force-read does not fire on an active turn', () => { + const { tracker } = setup() + const forceReads: AmplifierEventsForceReadRequest[] = [] + tracker.on('events.force-read', (request: AmplifierEventsForceReadRequest) => forceReads.push(request)) + tracker.trackTerminal({ terminalId: 't1', at: 1000 }) + tracker.applyLifecycle('t1', { kind: 'turn.began', at: iso(2000) }) + tracker.noteOutput({ terminalId: 't1', data: 'progress', at: 2000 + AMPLIFIER_BUSY_DEADMAN_MS }) + tracker.expire(2000 + AMPLIFIER_BUSY_DEADMAN_MS + 1) + expect(tracker.getActivity('t1')?.phase).toBe('busy') + expect(forceReads).toHaveLength(0) + }) + + // ---- PTY exit --------------------------------------------------------------- + + it('PTY exit removes state unconditionally (authoritative end, no completion)', () => { + const { tracker, changes, completions } = setup() tracker.trackTerminal({ terminalId: 't1', at: 1000 }) + tracker.applyLifecycle('t1', { kind: 'turn.began', at: iso(2000) }) + tracker.noteExit({ terminalId: 't1' }) expect(tracker.getActivity('t1')).toBeUndefined() expect(changes.at(-1)).toEqual({ upsert: [], remove: ['t1'] }) + expect(completions).toHaveLength(0) }) - it('list() reflects current records', () => { - const { tracker } = setup() + // ---- signal loss: idle-and-stop, never a timing fallback --------------------- + + it('noteEventsSignalLost mid-turn reverts to idle with NO turn.complete (visible phase change)', () => { + const { tracker, changes, completions } = setup() tracker.trackTerminal({ terminalId: 't1', at: 1000 }) tracker.noteInput({ terminalId: 't1', data: '\r', at: 2000 }) - expect(tracker.list()).toEqual([{ terminalId: 't1', phase: 'busy', updatedAt: 2000 }]) + tracker.applyLifecycle('t1', { kind: 'turn.began', at: iso(2100) }) + expect(tracker.getActivity('t1')?.phase).toBe('busy') + + tracker.noteEventsSignalLost('t1') + expect(tracker.getActivity('t1')?.phase).toBe('idle') + expect(completions).toHaveLength(0) + expect(changes.at(-1)?.upsert[0]).toMatchObject({ terminalId: 't1', phase: 'idle' }) + + // No timing heuristic takes over: output on the idle terminal does nothing, + // and nothing ever completes the dropped turn. + tracker.noteOutput({ terminalId: 't1', data: 'tail output', at: 5000 }) + vi.advanceTimersByTime(10 * 60_000) + tracker.expire(10 * 60_000) + expect(tracker.getActivity('t1')?.phase).toBe('idle') + expect(completions).toHaveLength(0) }) - it('attaches sessionId via bindSession', () => { - const { tracker } = setup() + it('lane.degrade effect behaves as signal loss: idle silently, no completion', () => { + const { tracker, completions } = setup() tracker.trackTerminal({ terminalId: 't1', at: 1000 }) - tracker.bindSession({ terminalId: 't1', sessionId: 's-1', at: 1500 }) - expect(tracker.getActivity('t1')?.sessionId).toBe('s-1') + tracker.applyLifecycle('t1', { kind: 'turn.began', at: iso(2000) }) + tracker.applyLifecycle('t1', { kind: 'lane.degrade', reason: 'schema_version_unsupported' }) + + expect(tracker.getActivity('t1')?.phase).toBe('idle') + expect(completions).toHaveLength(0) + }) + + it('after signal loss the terminal only shows provisional busy pulses that revert silently', () => { + const { tracker, completions } = setup() + tracker.trackTerminal({ terminalId: 't1', at: 1000 }) + tracker.applyLifecycle('t1', { kind: 'turn.began', at: iso(2000) }) + tracker.noteEventsSignalLost('t1') + expect(tracker.getActivity('t1')?.phase).toBe('idle') + + // Submit-grace pulse: busy, then (force-read retry, no-op) silent reversion. + tracker.noteInput({ terminalId: 't1', data: '\r', at: 5000 }) + expect(tracker.getActivity('t1')?.phase).toBe('busy') + vi.advanceTimersByTime(AMPLIFIER_SUBMIT_GRACE_MS * 2) + expect(tracker.getActivity('t1')?.phase).toBe('idle') + expect(completions).toHaveLength(0) }) }) diff --git a/test/unit/server/coding-cli/amplifier-activity-wiring.test.ts b/test/unit/server/coding-cli/amplifier-activity-wiring.test.ts index 22b6938c..f7ab2cdf 100644 --- a/test/unit/server/coding-cli/amplifier-activity-wiring.test.ts +++ b/test/unit/server/coding-cli/amplifier-activity-wiring.test.ts @@ -1,7 +1,7 @@ import { EventEmitter } from 'events' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { wireAmplifierActivityTracker } from '../../../../server/coding-cli/amplifier-activity-wiring' -import { AMPLIFIER_IDLE_DEBOUNCE_MS } from '../../../../server/coding-cli/amplifier-activity-tracker' +import { AMPLIFIER_SUBMIT_GRACE_MS } from '../../../../server/coding-cli/amplifier-activity-tracker' class FakeRegistry extends EventEmitter { records = new Map() @@ -17,7 +17,7 @@ describe('wireAmplifierActivityTracker', () => { vi.useRealTimers() }) - it('tracks only amplifier terminals and drives phase via submit + output-idle', () => { + it('tracks only amplifier terminals and drives provisional busy via PTY submit', () => { const registry = new FakeRegistry() const now = vi.fn(() => 1000) const { tracker, dispose } = wireAmplifierActivityTracker({ @@ -30,7 +30,7 @@ describe('wireAmplifierActivityTracker', () => { registry.emit('terminal.created', { terminalId: 'shell-1', mode: 'shell', status: 'running' }) registry.emit('terminal.created', { terminalId: 'claude-1', mode: 'claude', status: 'running' }) registry.emit('terminal.created', { terminalId: 't1', mode: 'amplifier', status: 'running' }) - // Neither the shell nor the claude terminal is tracked by the amplifier lane. + // Neither the shell nor the claude terminal is tracked by the amplifier tracker. expect(tracker.getActivity('shell-1')).toBeUndefined() expect(tracker.getActivity('claude-1')).toBeUndefined() expect(tracker.getActivity('t1')?.phase).toBe('idle') @@ -38,12 +38,13 @@ describe('wireAmplifierActivityTracker', () => { registry.emit('terminal.input.raw', { terminalId: 't1', data: '\r', at: 2000 }) expect(tracker.getActivity('t1')?.phase).toBe('busy') - // Output holds it busy; there is no turn-complete BEL for amplifier. + // Output only refreshes liveness; it never ends a turn. registry.emit('terminal.output.raw', { terminalId: 't1', data: 'thinking', at: 2500 }) expect(tracker.getActivity('t1')?.phase).toBe('busy') - // Output-idle after the debounce ends the turn. - vi.advanceTimersByTime(AMPLIFIER_IDLE_DEBOUNCE_MS) + // No prompt:submit record ever arrives (no events tailer in this test): + // one force-read retry, then the submit-grace reverts silently to idle. + vi.advanceTimersByTime(AMPLIFIER_SUBMIT_GRACE_MS * 2) expect(tracker.getActivity('t1')?.phase).toBe('idle') registry.emit('terminal.exit', { terminalId: 't1' }) diff --git a/test/unit/server/coding-cli/amplifier-events-reducer.test.ts b/test/unit/server/coding-cli/amplifier-events-reducer.test.ts new file mode 100644 index 00000000..9c53e909 --- /dev/null +++ b/test/unit/server/coding-cli/amplifier-events-reducer.test.ts @@ -0,0 +1,385 @@ +import fs from 'node:fs' +import path from 'node:path' +import { describe, expect, it } from 'vitest' +import { + createAmplifierReducerState, + reduceAmplifierEvent, + type AmplifierParsedRecord, + type AmplifierReducerEffect, + type AmplifierReducerState, +} from '../../../../server/coding-cli/amplifier-events-reducer.js' + +const FIXTURES_DIR = path.resolve(__dirname, '../../../fixtures/coding-cli/amplifier/events') + +function loadFixture(name: string): AmplifierParsedRecord[] { + const raw = fs.readFileSync(path.join(FIXTURES_DIR, name), 'utf8') + return raw + .split('\n') + .filter((line) => line.trim().length > 0) + .map((line) => JSON.parse(line) as AmplifierParsedRecord) +} + +function reduceAll( + records: AmplifierParsedRecord[], + initial: AmplifierReducerState = createAmplifierReducerState(), +): { state: AmplifierReducerState; effects: AmplifierReducerEffect[] } { + let state = initial + const effects: AmplifierReducerEffect[] = [] + for (const record of records) { + const result = reduceAmplifierEvent(state, record) + state = result.state + effects.push(...result.effects) + } + return { state, effects } +} + +function kinds(effects: AmplifierReducerEffect[]): string[] { + return effects.map((effect) => effect.kind) +} + +function record(overrides: Partial & { event: string }): AmplifierParsedRecord { + return { + ts: '2026-07-08T15:50:50.757003704+00:00', + schema: { name: 'amplifier.log', ver: '1.0.0' }, + session_id: 'test-session', + data: { parent_id: null }, + ...overrides, + } +} + +describe('amplifier events reducer', () => { + it('starts idle, not degraded, with no subagent indicator (empty input)', () => { + const state = createAmplifierReducerState() + expect(state.phase).toBe('idle') + expect(state.degraded).toBe(false) + expect(state.subagent).toBe(false) + }) + + describe('fixture: normal-turn (E2)', () => { + it('emits session.identified, one turn.began, exactly one turn.completed, ends idle', () => { + const { state, effects } = reduceAll(loadFixture('normal-turn.jsonl')) + + expect(kinds(effects)).toEqual(['session.identified', 'turn.began', 'turn.completed']) + expect(state.phase).toBe('idle') + expect(state.degraded).toBe(false) + expect(state.sessionId).toBe('1326337c-f0fb-49ff-9ff6-06722c5e0bab') + }) + + it('session.identified carries sessionId and cwd from session:config data.raw', () => { + const { effects } = reduceAll(loadFixture('normal-turn.jsonl')) + const identified = effects.find((effect) => effect.kind === 'session.identified') + expect(identified).toEqual({ + kind: 'session.identified', + sessionId: '1326337c-f0fb-49ff-9ff6-06722c5e0bab', + cwd: '/tmp/amp-p0-b', + }) + }) + + it('turn.completed fires on prompt:complete, not session:end (session:end at idle is ignored)', () => { + const records = loadFixture('normal-turn.jsonl') + const completeIndex = records.findIndex((r) => r.event === 'prompt:complete') + const endIndex = records.findIndex((r) => r.event === 'session:end') + expect(completeIndex).toBeGreaterThan(0) + expect(endIndex).toBeGreaterThan(completeIndex) + + let state = createAmplifierReducerState() + for (const [index, rec] of records.entries()) { + const result = reduceAmplifierEvent(state, rec) + state = result.state + if (index === completeIndex) { + expect(kinds(result.effects)).toEqual(['turn.completed']) + } + if (index === endIndex) { + expect(result.effects).toEqual([]) + } + } + }) + }) + + describe('fixture: pty-hangup-completes (E7 first clause, synthesized)', () => { + it('turn completes on prompt:complete; the hangup-written session:end at idle is ignored', () => { + const { state, effects } = reduceAll(loadFixture('pty-hangup-completes.jsonl')) + + // Exactly one completion for the turn — session:end after prompt:complete + // (PTY hangup lets amplifier finish the turn, E7) never double-ends it. + expect(kinds(effects)).toEqual(['session.identified', 'turn.began', 'turn.completed']) + expect(state.phase).toBe('idle') + expect(state.degraded).toBe(false) + }) + + it('the session:end record itself emits no effects (turn already idle)', () => { + const records = loadFixture('pty-hangup-completes.jsonl') + const endIndex = records.findIndex((r) => r.event === 'session:end') + const completeIndex = records.findIndex((r) => r.event === 'prompt:complete') + expect(endIndex).toBeGreaterThan(completeIndex) + + let state = createAmplifierReducerState() + for (const [index, rec] of records.entries()) { + const result = reduceAmplifierEvent(state, rec) + state = result.state + if (index === endIndex) { + expect(result.effects).toEqual([]) + } + } + }) + }) + + describe('fixture: tool-turn-out-of-order-end (E3)', () => { + it('tool loop provider:request iterations stay one turn; post-complete records never re-busy', () => { + const { state, effects } = reduceAll(loadFixture('tool-turn-out-of-order-end.jsonl')) + + expect(kinds(effects)).toEqual(['session.identified', 'turn.began', 'turn.completed']) + expect(state.phase).toBe('idle') + }) + + it('stays idle across post-complete llm:request, provider:retry and out-of-order session:end tail', () => { + const records = loadFixture('tool-turn-out-of-order-end.jsonl') + const completeIndex = records.findIndex((r) => r.event === 'prompt:complete') + + let state = createAmplifierReducerState() + for (const [index, rec] of records.entries()) { + const result = reduceAmplifierEvent(state, rec) + state = result.state + if (index > completeIndex) { + expect(state.phase).toBe('idle') + expect(result.effects).toEqual([]) + } + } + }) + }) + + describe('fixture: kill9-orphan (E6)', () => { + it('ends busy with no turn.completed when the file dies at tool:pre', () => { + const { state, effects } = reduceAll(loadFixture('kill9-orphan.jsonl')) + + expect(kinds(effects)).toEqual(['session.identified', 'turn.began']) + expect(state.phase).toBe('busy') + }) + }) + + describe('fixture: resume-append (E7)', () => { + it('session:resume causes no phase change; one full turn; duplicate session:end records emit nothing extra', () => { + const records = loadFixture('resume-append.jsonl') + expect(records[0]?.event).toBe('session:resume') + + const first = reduceAmplifierEvent(createAmplifierReducerState(), records[0]) + expect(first.state.phase).toBe('idle') + expect(first.effects).toEqual([]) + + const { state, effects } = reduceAll(records) + expect(kinds(effects)).toEqual(['session.identified', 'turn.began', 'turn.completed']) + expect(state.phase).toBe('idle') + }) + + it('session:resume while busy does not change phase', () => { + const busy = reduceAll([record({ event: 'prompt:submit' })]).state + expect(busy.phase).toBe('busy') + + const result = reduceAmplifierEvent(busy, record({ event: 'session:resume' })) + expect(result.state.phase).toBe('busy') + expect(result.effects).toEqual([]) + }) + }) + + describe('fixture: steering-injection (E5)', () => { + it('orchestrator:steering_injected stays inside a single submit/complete pair', () => { + const records = loadFixture('steering-injection.jsonl') + expect(records.some((r) => r.event === 'orchestrator:steering_injected')).toBe(true) + + const { state, effects } = reduceAll(records) + expect(kinds(effects)).toEqual(['session.identified', 'turn.began', 'turn.completed']) + expect(state.phase).toBe('idle') + }) + }) + + describe('fixture: continue-attach-orphan-end (E7)', () => { + it('orphan session:end with no start/resume is a legal no-op from idle', () => { + const { state, effects } = reduceAll(loadFixture('continue-attach-orphan-end.jsonl')) + expect(effects).toEqual([]) + expect(state.phase).toBe('idle') + expect(state.degraded).toBe(false) + }) + }) + + describe('transition table (§6)', () => { + it('prompt:submit is the only record input that enters busy', () => { + const nonSubmit = [ + 'session:start', + 'session:config', + 'session:resume', + 'execution:start', + 'provider:request', + 'llm:request', + 'llm:response', + 'tool:pre', + 'tool:post', + 'orchestrator:steering_injected', + 'content_block:start', + 'cleanup:finally_begin', + ] + let state = createAmplifierReducerState() + for (const event of nonSubmit) { + state = reduceAmplifierEvent(state, record({ event })).state + expect(state.phase).toBe('idle') + } + + const result = reduceAmplifierEvent(state, record({ event: 'prompt:submit' })) + expect(result.state.phase).toBe('busy') + expect(kinds(result.effects)).toEqual(['turn.began']) + }) + + it('prompt:submit while busy stays busy without a second turn.began', () => { + const busy = reduceAmplifierEvent(createAmplifierReducerState(), record({ event: 'prompt:submit' })).state + const result = reduceAmplifierEvent(busy, record({ event: 'prompt:submit' })) + expect(result.state.phase).toBe('busy') + expect(result.effects).toEqual([]) + }) + + it('session:end while busy goes idle and emits turn.completed (PTY hangup / quit mid-turn)', () => { + const { state, effects } = reduceAll([ + record({ event: 'session:start' }), + record({ event: 'prompt:submit' }), + record({ event: 'session:end' }), + ]) + expect(kinds(effects)).toEqual(['turn.began', 'turn.completed']) + expect(state.phase).toBe('idle') + }) + + it('prompt:complete while idle is ignored (any non-prompt:submit record at idle)', () => { + const result = reduceAmplifierEvent(createAmplifierReducerState(), record({ event: 'prompt:complete' })) + expect(result.state.phase).toBe('idle') + expect(result.effects).toEqual([]) + }) + + it('turn effects carry the record ts through (at), never used for ordering', () => { + const submit = reduceAmplifierEvent( + createAmplifierReducerState(), + record({ event: 'prompt:submit', ts: '2026-07-08T15:50:50.768798476+00:00' }), + ) + expect(submit.effects).toEqual([ + { kind: 'turn.began', at: '2026-07-08T15:50:50.768798476+00:00' }, + ]) + + // Completion record whose ts predates the submit: still completes (keyed on type, E3). + const complete = reduceAmplifierEvent( + submit.state, + record({ event: 'prompt:complete', ts: '2026-07-08T15:50:40.000000000+00:00' }), + ) + expect(complete.state.phase).toBe('idle') + expect(complete.effects).toEqual([ + { kind: 'turn.completed', at: '2026-07-08T15:50:40.000000000+00:00' }, + ]) + }) + }) + + describe('subagent indicators', () => { + it('session:start with parent_id set marks subagent, no phase change, no effects', () => { + const result = reduceAmplifierEvent( + createAmplifierReducerState(), + record({ event: 'session:start', data: { parent_id: 'parent-session-id' } }), + ) + expect(result.state.subagent).toBe(true) + expect(result.state.phase).toBe('idle') + expect(result.effects).toEqual([]) + }) + + it('session:fork marks subagent, no phase change, no effects', () => { + const busy = reduceAmplifierEvent(createAmplifierReducerState(), record({ event: 'prompt:submit' })).state + const result = reduceAmplifierEvent(busy, record({ event: 'session:fork' })) + expect(result.state.subagent).toBe(true) + expect(result.state.phase).toBe('busy') + expect(result.effects).toEqual([]) + }) + + it('top-level session:start (parent_id null) does not mark subagent', () => { + const result = reduceAmplifierEvent( + createAmplifierReducerState(), + record({ event: 'session:start', data: { parent_id: null } }), + ) + expect(result.state.subagent).toBe(false) + }) + }) + + describe('session.identified (§5 step 4)', () => { + it('prefers working_dir, falls back to project_dir', () => { + const withBoth = reduceAmplifierEvent(createAmplifierReducerState(), record({ + event: 'session:config', + data: { parent_id: null, raw: { project_dir: '/tmp/proj', working_dir: '/tmp/work' } }, + })) + expect(withBoth.effects).toEqual([ + { kind: 'session.identified', sessionId: 'test-session', cwd: '/tmp/work' }, + ]) + + const projectOnly = reduceAmplifierEvent(createAmplifierReducerState(), record({ + event: 'session:config', + data: { parent_id: null, raw: { project_dir: '/tmp/proj' } }, + })) + expect(projectOnly.effects).toEqual([ + { kind: 'session.identified', sessionId: 'test-session', cwd: '/tmp/proj' }, + ]) + }) + + it('session:config without project_dir/working_dir emits nothing', () => { + const result = reduceAmplifierEvent(createAmplifierReducerState(), record({ + event: 'session:config', + data: { parent_id: null, raw: {} }, + })) + expect(result.effects).toEqual([]) + }) + }) + + describe('schema gate (E10)', () => { + it('degrades on schema name mismatch', () => { + const result = reduceAmplifierEvent(createAmplifierReducerState(), record({ + event: 'session:start', + schema: { name: 'other.log', ver: '1.0.0' }, + })) + expect(result.state.degraded).toBe(true) + expect(result.effects).toEqual([ + { kind: 'lane.degrade', reason: 'schema_name_mismatch' }, + ]) + }) + + it('degrades on unsupported major version', () => { + const result = reduceAmplifierEvent(createAmplifierReducerState(), record({ + event: 'session:start', + schema: { name: 'amplifier.log', ver: '2.0.0' }, + })) + expect(result.state.degraded).toBe(true) + expect(result.effects).toEqual([ + { kind: 'lane.degrade', reason: 'schema_version_unsupported' }, + ]) + }) + + it('degrades on missing schema', () => { + const result = reduceAmplifierEvent(createAmplifierReducerState(), { + event: 'prompt:submit', + session_id: 'test-session', + }) + expect(result.state.degraded).toBe(true) + expect(result.effects).toEqual([ + { kind: 'lane.degrade', reason: 'schema_missing' }, + ]) + }) + + it('accepts any 1.x version (major-version gate)', () => { + const result = reduceAmplifierEvent(createAmplifierReducerState(), record({ + event: 'prompt:submit', + schema: { name: 'amplifier.log', ver: '1.4.2' }, + })) + expect(result.state.degraded).toBe(false) + expect(result.state.phase).toBe('busy') + }) + + it('degrade is sticky: further records are ignored with no effects', () => { + const degraded = reduceAmplifierEvent(createAmplifierReducerState(), record({ + event: 'session:start', + schema: { name: 'amplifier.log', ver: '2.0.0' }, + })).state + + const result = reduceAmplifierEvent(degraded, record({ event: 'prompt:submit' })) + expect(result.state).toEqual(degraded) + expect(result.effects).toEqual([]) + }) + }) +}) diff --git a/test/unit/server/coding-cli/amplifier-events-tailer.test.ts b/test/unit/server/coding-cli/amplifier-events-tailer.test.ts new file mode 100644 index 00000000..08c3371d --- /dev/null +++ b/test/unit/server/coding-cli/amplifier-events-tailer.test.ts @@ -0,0 +1,320 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' +import { + AMPLIFIER_TAILER_PARTIAL_MAX_BYTES, + AMPLIFIER_TAILER_READ_BATCH_MAX_BYTES, + createAmplifierEventsTailer, + type AmplifierTailerFs, + type AmplifierTailerReadResult, +} from '../../../../server/coding-cli/amplifier-events-tailer.js' + +const SCHEMA = '"schema": {"name": "amplifier.log", "ver": "1.0.0"}' + +function line(event: string, extra = ''): string { + return `{"ts": "2026-07-08T15:50:50.757003704+00:00", "lvl": "INFO", ${SCHEMA}, ` + + `"event": "${event}", "session_id": "session-1", "data": {"parent_id": null${extra}}}\n` +} + +type FakeFs = AmplifierTailerFs & { + append(text: string): void + truncate(bytes: number): void + readCalls: Array<{ position: number; length: number }> +} + +function createFakeFs(initial = ''): FakeFs { + let content = Buffer.from(initial, 'utf8') + const readCalls: Array<{ position: number; length: number }> = [] + return { + readCalls, + append(text: string) { + content = Buffer.concat([content, Buffer.from(text, 'utf8')]) + }, + truncate(bytes: number) { + content = content.subarray(0, bytes) + }, + async stat() { + return { size: content.length } + }, + async open() { + return { + async read(buffer: Buffer, offset: number, length: number, position: number) { + readCalls.push({ position, length }) + const slice = content.subarray(position, position + length) + slice.copy(buffer, offset) + return { bytesRead: slice.length } + }, + async close() {}, + } + }, + } +} + +function okRecords(result: AmplifierTailerReadResult): string[] { + if (!result.ok) throw new Error(`expected ok read, got degrade: ${result.reason}`) + return result.records.map((record) => record.event) +} + +afterEach(() => { + vi.restoreAllMocks() +}) + +describe('amplifier events tailer', () => { + it('attaches at offset 0 (fresh) and parses lifecycle records in order', async () => { + const fsImpl = createFakeFs( + line('session:start') + + line('session:config') + + line('prompt:submit') + + line('execution:start') + + line('prompt:complete'), + ) + const tailer = createAmplifierEventsTailer({ filePath: '/fake/events.jsonl', fsImpl, attachAt: 'start' }) + const attached = await tailer.attach() + expect(attached).toEqual({ ok: true, offset: 0 }) + + const result = await tailer.read() + expect(okRecords(result)).toEqual([ + 'session:start', + 'session:config', + 'prompt:submit', + 'execution:start', + 'prompt:complete', + ]) + }) + + it('attaches at EOF (resume) and only sees records appended afterwards', async () => { + const historical = line('session:start') + line('session:config') + line('prompt:submit') + line('prompt:complete') + const fsImpl = createFakeFs(historical) + const tailer = createAmplifierEventsTailer({ filePath: '/fake/events.jsonl', fsImpl, attachAt: 'eof' }) + const attached = await tailer.attach() + expect(attached).toEqual({ ok: true, offset: Buffer.byteLength(historical) }) + + const empty = await tailer.read() + expect(okRecords(empty)).toEqual([]) + + fsImpl.append(line('session:resume') + line('prompt:submit')) + const appended = await tailer.read() + expect(okRecords(appended)).toEqual(['session:resume', 'prompt:submit']) + }) + + it('reads only appended bytes on subsequent reads (never re-reads consumed bytes)', async () => { + const first = line('session:start') + const fsImpl = createFakeFs(first) + const tailer = createAmplifierEventsTailer({ filePath: '/fake/events.jsonl', fsImpl, attachAt: 'start' }) + await tailer.attach() + okRecords(await tailer.read()) + + fsImpl.readCalls.length = 0 + fsImpl.append(line('prompt:submit')) + const result = await tailer.read() + expect(okRecords(result)).toEqual(['prompt:submit']) + expect(fsImpl.readCalls.length).toBeGreaterThan(0) + for (const call of fsImpl.readCalls) { + expect(call.position).toBeGreaterThanOrEqual(Buffer.byteLength(first)) + } + }) + + it('does not open the file at all when size has not grown', async () => { + const fsImpl = createFakeFs(line('session:start')) + const tailer = createAmplifierEventsTailer({ filePath: '/fake/events.jsonl', fsImpl, attachAt: 'start' }) + await tailer.attach() + okRecords(await tailer.read()) + + fsImpl.readCalls.length = 0 + const result = await tailer.read() + expect(okRecords(result)).toEqual([]) + expect(fsImpl.readCalls).toEqual([]) + }) + + it('buffers a partial trailing line until it is completed', async () => { + const whole = line('prompt:submit') + const cut = 40 + const fsImpl = createFakeFs(line('session:start') + whole.slice(0, cut)) + const tailer = createAmplifierEventsTailer({ filePath: '/fake/events.jsonl', fsImpl, attachAt: 'start' }) + await tailer.attach() + + const first = await tailer.read() + expect(okRecords(first)).toEqual(['session:start']) + + // Still partial: nothing new, no torn parse. + const second = await tailer.read() + expect(okRecords(second)).toEqual([]) + + fsImpl.append(whole.slice(cut)) + const third = await tailer.read() + expect(okRecords(third)).toEqual(['prompt:submit']) + }) + + it('pre-filters noise lines without calling JSON.parse on them', async () => { + const fsImpl = createFakeFs( + line('session:start') + + line('prompt:submit') + + line('llm:request', ', "raw": "big"') + + line('llm:response') + + line('content_block:start') + + line('content_block:end') + + line('tool:pre') + + line('tool:post') + + line('mentions:resolved') + + line('provider:request') + + line('provider:retry') + + line('cleanup:render_begin') + + line('orchestrator:complete') + + line('orchestrator:steering_injected') + + line('execution:end') + + line('prompt:complete'), + ) + const tailer = createAmplifierEventsTailer({ filePath: '/fake/events.jsonl', fsImpl, attachAt: 'start' }) + await tailer.attach() + + const parseSpy = vi.spyOn(JSON, 'parse') + const result = await tailer.read() + + // session:start, prompt:submit, orchestrator:steering_injected, execution:end, prompt:complete + expect(okRecords(result)).toEqual([ + 'session:start', + 'prompt:submit', + 'orchestrator:steering_injected', + 'execution:end', + 'prompt:complete', + ]) + expect(parseSpy).toHaveBeenCalledTimes(5) + if (!result.ok) throw new Error('unreachable') + expect(result.skippedLines).toBe(11) + }) + + it('degrades with file_reset when size < offset, and stays degraded', async () => { + const initial = line('session:start') + line('prompt:submit') + const fsImpl = createFakeFs(initial) + const tailer = createAmplifierEventsTailer({ filePath: '/fake/events.jsonl', fsImpl, attachAt: 'start' }) + await tailer.attach() + okRecords(await tailer.read()) + + fsImpl.truncate(10) + const reset = await tailer.read() + expect(reset.ok).toBe(false) + if (reset.ok) throw new Error('unreachable') + expect(reset.reason).toBe('file_reset') + + fsImpl.append(line('prompt:complete')) + const after = await tailer.read() + expect(after.ok).toBe(false) + if (after.ok) throw new Error('unreachable') + expect(after.reason).toBe('file_reset') + }) + + it('degrades with schema_mismatch when the first parsed record fails the schema gate', async () => { + const fsImpl = createFakeFs( + '{"ts": "2026-07-08T15:50:50.757003704+00:00", "schema": {"name": "amplifier.log", "ver": "2.0.0"}, ' + + '"event": "session:start", "session_id": "session-1", "data": {"parent_id": null}}\n', + ) + const tailer = createAmplifierEventsTailer({ filePath: '/fake/events.jsonl', fsImpl, attachAt: 'start' }) + await tailer.attach() + + const result = await tailer.read() + expect(result.ok).toBe(false) + if (result.ok) throw new Error('unreachable') + expect(result.reason).toBe('schema_mismatch') + }) + + it('validates the schema only once per file (single gate on first parsed record)', async () => { + const fsImpl = createFakeFs(line('session:start')) + const tailer = createAmplifierEventsTailer({ filePath: '/fake/events.jsonl', fsImpl, attachAt: 'start' }) + await tailer.attach() + okRecords(await tailer.read()) + + // A later record with a bad schema does not degrade the tailer; per-record + // gating is the reducer's job (the tailer validates the file header once). + fsImpl.append( + '{"ts": "2026-07-08T15:50:51.000000000+00:00", "schema": {"name": "amplifier.log", "ver": "2.0.0"}, ' + + '"event": "prompt:submit", "session_id": "session-1", "data": {"parent_id": null}}\n', + ) + const result = await tailer.read() + expect(okRecords(result)).toEqual(['prompt:submit']) + }) + + it('degrades with read_error when the file cannot be statted', async () => { + const fsImpl = createFakeFs('') + fsImpl.stat = async () => { + throw Object.assign(new Error('ENOENT'), { code: 'ENOENT' }) + } + const tailer = createAmplifierEventsTailer({ filePath: '/fake/events.jsonl', fsImpl, attachAt: 'start' }) + await tailer.attach() + + const result = await tailer.read() + expect(result.ok).toBe(false) + if (result.ok) throw new Error('unreachable') + expect(result.reason).toBe('read_error') + }) + + it('forceRead() reads the tail without any watcher having fired', async () => { + const fsImpl = createFakeFs(line('session:start')) + const tailer = createAmplifierEventsTailer({ filePath: '/fake/events.jsonl', fsImpl, attachAt: 'start' }) + await tailer.attach() + okRecords(await tailer.read()) + + fsImpl.append(line('prompt:complete')) + const result = await tailer.forceRead() + expect(okRecords(result)).toEqual(['prompt:complete']) + }) + + it('caps the partial-line buffer: a 10MB no-newline stream is dropped, later lines still parse', async () => { + // Multi-MB llm:request lines are normal (plan §2 last row): the tailer must + // bound its remainder buffer instead of retaining the whole line. + const oversized = 'x'.repeat(10 * 1024 * 1024) + const fsImpl = createFakeFs(line('session:start') + oversized) + const debug = vi.fn() + const tailer = createAmplifierEventsTailer({ + filePath: '/fake/events.jsonl', + fsImpl, + attachAt: 'start', + log: { debug }, + }) + await tailer.attach() + + const first = await tailer.read() + expect(okRecords(first)).toEqual(['session:start']) + // Buffered bytes stay bounded (the oversized partial is dropped outright). + expect(tailer.getBufferedBytes()).toBeLessThanOrEqual(AMPLIFIER_TAILER_PARTIAL_MAX_BYTES) + expect(debug).toHaveBeenCalledTimes(1) + + // Completing the oversized line and appending a valid record: the oversized + // line counts as skipped, the lane does NOT degrade, and parsing resumes. + fsImpl.append('tail-of-oversized-line\n' + line('prompt:submit')) + const second = await tailer.read() + expect(okRecords(second)).toEqual(['prompt:submit']) + if (!second.ok) throw new Error('unreachable') + expect(second.skippedLines).toBe(1) + expect(tailer.getBufferedBytes()).toBe(0) + }) + + it('reads large appends in bounded batches without splitting records across batch boundaries', async () => { + // > one read batch of noise so readAppended must loop; lifecycle records on + // both sides of the batch boundary must still parse exactly once. + const noise = line('content_block:start').repeat( + Math.ceil((AMPLIFIER_TAILER_READ_BATCH_MAX_BYTES + 64 * 1024) / line('content_block:start').length), + ) + const fsImpl = createFakeFs(line('session:start') + noise + line('prompt:complete')) + const tailer = createAmplifierEventsTailer({ filePath: '/fake/events.jsonl', fsImpl, attachAt: 'start' }) + await tailer.attach() + + const result = await tailer.read() + expect(okRecords(result)).toEqual(['session:start', 'prompt:complete']) + if (!result.ok) throw new Error('unreachable') + expect(result.offset).toBe(tailer.getOffset()) + // Every positional read stays within the batch bound. + for (const call of fsImpl.readCalls) { + expect(call.length).toBeLessThanOrEqual(AMPLIFIER_TAILER_READ_BATCH_MAX_BYTES) + } + }) + + it('tracks the byte offset across reads, including buffered partial bytes', async () => { + const first = line('session:start') + const partial = '{"ts": "2026-07-08T15:' + const fsImpl = createFakeFs(first + partial) + const tailer = createAmplifierEventsTailer({ filePath: '/fake/events.jsonl', fsImpl, attachAt: 'start' }) + await tailer.attach() + await tailer.read() + + // Offset covers everything read so far (partial bytes live in the buffer). + expect(tailer.getOffset()).toBe(Buffer.byteLength(first + partial)) + }) +}) diff --git a/test/unit/server/coding-cli/amplifier-provider.test.ts b/test/unit/server/coding-cli/amplifier-provider.test.ts index 9a590712..445a41e1 100644 --- a/test/unit/server/coding-cli/amplifier-provider.test.ts +++ b/test/unit/server/coding-cli/amplifier-provider.test.ts @@ -1,4 +1,4 @@ -import { describe, it, expect, afterEach } from 'vitest' +import { describe, it, expect, afterEach, vi } from 'vitest' import path from 'path' import os from 'os' import fsp from 'fs/promises' @@ -251,6 +251,81 @@ describe('amplifier-provider', () => { }) }) + describe('listSessionFiles()', () => { + const originalEnv = process.env.AMPLIFIER_HOME + + afterEach(() => { + if (originalEnv === undefined) { + delete process.env.AMPLIFIER_HOME + } else { + process.env.AMPLIFIER_HOME = originalEnv + } + vi.resetModules() + }) + + it('skips metadata-less orphan dirs (kill -9 before first prompt:complete)', async () => { + // Pins the INDEXER POLICY documented in providers/amplifier.ts (plan §7, + // Phase 0 finding E6): a session dir containing only events.jsonl -- created + // when amplifier is killed before its first prompt:complete, so metadata.json + // never appears -- is NOT resumable and must be excluded from discovery. + // Discovery deliberately keys on metadata.json. + const home = await fsp.mkdtemp(path.join(os.tmpdir(), 'amplifier-home-')) + try { + const sessionsDir = path.join(home, 'projects', '-home-dan-code-freshell', 'sessions') + const normalDir = path.join(sessionsDir, 'normal-session') + const orphanDir = path.join(sessionsDir, 'orphan-session') + await fsp.mkdir(normalDir, { recursive: true }) + await fsp.mkdir(orphanDir, { recursive: true }) + + // (a) Normal session dir: metadata.json (+ transcript.jsonl). + await fsp.writeFile( + path.join(normalDir, 'metadata.json'), + JSON.stringify({ session_id: 'normal-session', working_dir: '/home/dan/code/freshell' }), + ) + await fsp.writeFile( + path.join(normalDir, 'transcript.jsonl'), + '{"role":"user","content":"hi"}\n', + ) + + // (b) Orphan session dir: ONLY events.jsonl. Realistic records mirror the + // amplifier.log 1.0.0 schema; the log ends after prompt:submit because the + // process was killed before prompt:complete. + const orphanEvents = [ + JSON.stringify({ + ts: '2026-07-08T16:01:27.255264049+00:00', + lvl: 'INFO', + schema: { name: 'amplifier.log', ver: '1.0.0' }, + event: 'session:start', + session_id: 'orphan-session', + data: { parent_id: null }, + }), + JSON.stringify({ + ts: '2026-07-08T16:01:27.259046323+00:00', + lvl: 'INFO', + schema: { name: 'amplifier.log', ver: '1.0.0' }, + event: 'prompt:submit', + session_id: 'orphan-session', + data: { parent_id: null }, + }), + ].join('\n') + await fsp.writeFile(path.join(orphanDir, 'events.jsonl'), `${orphanEvents}\n`) + + // homeDir is captured at module load, so point AMPLIFIER_HOME at the temp + // home and re-import a fresh provider instance. + process.env.AMPLIFIER_HOME = home + vi.resetModules() + const { amplifierProvider: freshProvider } = await import( + '../../../../server/coding-cli/providers/amplifier' + ) + + const files = await freshProvider.listSessionFiles() + expect(files).toEqual([path.join(normalDir, 'metadata.json')]) + } finally { + await fsp.rm(home, { recursive: true, force: true }) + } + }) + }) + it('parseEvent maps a user line to message.user', () => { const events = amplifierProvider.parseEvent(JSON.stringify({ role: 'user', content: 'Hello there' })) expect(events).toHaveLength(1) diff --git a/test/unit/server/coding-cli/amplifier-session-controller.test.ts b/test/unit/server/coding-cli/amplifier-session-controller.test.ts new file mode 100644 index 00000000..4b203756 --- /dev/null +++ b/test/unit/server/coding-cli/amplifier-session-controller.test.ts @@ -0,0 +1,163 @@ +/** + * AmplifierSessionController tests (plan 2026-07-08 §5 step 5 / §9 Phase 3). + * Pattern mirrors opencode-session-controller.test.ts: fake locator emitting + * 'session.located', fake registry with injectable get/bindSession. + */ +import { EventEmitter } from 'events' +import { describe, expect, it, vi } from 'vitest' +import { AmplifierSessionController } from '../../../../server/coding-cli/amplifier-session-controller.js' + +class FakeLocator extends EventEmitter {} + +const EVENTS_PATH = '/home/user/.amplifier/projects/-p/sessions/session-1/events.jsonl' + +function locatedRequest(overrides: Record = {}) { + return { + terminalId: 'term-1', + sessionId: 'session-1', + eventsPath: EVENTS_PATH, + ...overrides, + } +} + +function makeRegistry(input: { + get?: ReturnType + bindSession?: ReturnType +} = {}) { + return { + get: input.get ?? vi.fn(() => ({ + terminalId: 'term-1', + mode: 'amplifier', + status: 'running', + resumeSessionId: undefined, + })), + bindSession: input.bindSession ?? vi.fn(() => ({ + ok: true as const, + terminalId: 'term-1', + sessionId: 'session-1', + })), + } +} + +describe('AmplifierSessionController', () => { + it('binds via registry.bindSession and emits associated with the events path', () => { + const locator = new FakeLocator() + const registry = makeRegistry() + const associated = vi.fn() + const controller = new AmplifierSessionController({ + locator: locator as any, + registry: registry as any, + }) + controller.on('associated', associated) + + locator.emit('session.located', locatedRequest()) + + expect(registry.bindSession).toHaveBeenCalledWith('term-1', 'amplifier', 'session-1', 'association') + expect(associated).toHaveBeenCalledWith({ + terminalId: 'term-1', + sessionId: 'session-1', + eventsPath: EVENTS_PATH, + }) + + controller.dispose() + }) + + it.each([ + { + name: 'missing terminal', + terminal: undefined, + reason: 'terminal_missing_or_not_running', + extra: {}, + }, + { + name: 'non-amplifier terminal', + terminal: { terminalId: 'term-1', mode: 'claude', status: 'running', resumeSessionId: undefined }, + reason: 'terminal_not_amplifier', + extra: { mode: 'claude' }, + }, + { + name: 'exited terminal', + terminal: { terminalId: 'term-1', mode: 'amplifier', status: 'exited', resumeSessionId: undefined }, + reason: 'terminal_missing_or_not_running', + extra: { status: 'exited' }, + }, + { + name: 'already-bound terminal', + terminal: { terminalId: 'term-1', mode: 'amplifier', status: 'running', resumeSessionId: 'other-session' }, + reason: 'terminal_already_bound', + extra: { previousSessionId: 'other-session' }, + }, + ])('logs and rejects for $name without binding', ({ terminal, reason, extra }) => { + const locator = new FakeLocator() + const registry = makeRegistry({ get: vi.fn(() => terminal) }) + const log = { warn: vi.fn() } + const associated = vi.fn() + const controller = new AmplifierSessionController({ + locator: locator as any, + registry: registry as any, + log, + }) + controller.on('associated', associated) + + locator.emit('session.located', locatedRequest()) + + expect(registry.bindSession).not.toHaveBeenCalled() + expect(associated).not.toHaveBeenCalled() + expect(log.warn).toHaveBeenCalledWith({ + terminalId: 'term-1', + sessionId: 'session-1', + reason, + ...extra, + }, 'Rejected Amplifier session association') + + controller.dispose() + }) + + it('rejects when bindSession reports an ownership conflict', () => { + const locator = new FakeLocator() + const registry = makeRegistry({ + bindSession: vi.fn(() => ({ + ok: false as const, + reason: 'session_already_owned', + owner: 'other-terminal', + })), + }) + const log = { warn: vi.fn() } + const associated = vi.fn() + const controller = new AmplifierSessionController({ + locator: locator as any, + registry: registry as any, + log, + }) + controller.on('associated', associated) + + locator.emit('session.located', locatedRequest()) + + expect(associated).not.toHaveBeenCalled() + expect(log.warn).toHaveBeenCalledWith({ + terminalId: 'term-1', + sessionId: 'session-1', + reason: 'session_already_owned', + ownerTerminalId: 'other-terminal', + }, 'Rejected Amplifier session association') + + controller.dispose() + }) + + it('dispose unsubscribes from the locator', () => { + const locator = new FakeLocator() + const registry = makeRegistry() + const associated = vi.fn() + const controller = new AmplifierSessionController({ + locator: locator as any, + registry: registry as any, + }) + controller.on('associated', associated) + controller.dispose() + + locator.emit('session.located', locatedRequest()) + + expect(registry.bindSession).not.toHaveBeenCalled() + expect(associated).not.toHaveBeenCalled() + }) +}) diff --git a/test/unit/server/coding-cli/amplifier-session-locator.test.ts b/test/unit/server/coding-cli/amplifier-session-locator.test.ts new file mode 100644 index 00000000..55d95124 --- /dev/null +++ b/test/unit/server/coding-cli/amplifier-session-locator.test.ts @@ -0,0 +1,565 @@ +/** + * AmplifierSessionLocator tests (plan 2026-07-08 §5 / §9 Phase 3). + * + * Uses a real temp Amplifier home (mkdtemp) and real chokidar — the pattern of + * amplifier-provider.test.ts (fake home) combined with session-indexer-style + * watcher assertions. Windows are shortened via injected windowMs so tests stay + * bounded; every wait is polled with a generous timeout. + */ +import { EventEmitter } from 'events' +import fs from 'node:fs' +import fsp from 'node:fs/promises' +import os from 'node:os' +import path from 'node:path' +import chokidar from 'chokidar' +import { afterEach, describe, expect, it, vi } from 'vitest' +import { + AMPLIFIER_DIR_APPEAR_WINDOW_MS, + AMPLIFIER_DIR_PRE_EPSILON_MS, + AmplifierSessionLocator, + type AmplifierLocatorWatchFactory, + type AmplifierSessionLocatedEvent, +} from '../../../../server/coding-cli/amplifier-session-locator.js' + +const SCHEMA = { name: 'amplifier.log', ver: '1.0.0' } + +function eventsJsonl(input: { + sessionId: string + cwd: string + parentId?: string | null + firstEvent?: string +}): string { + const start = { + ts: new Date().toISOString(), + lvl: 'INFO', + schema: SCHEMA, + event: input.firstEvent ?? 'session:start', + session_id: input.sessionId, + data: { parent_id: input.parentId ?? null }, + } + const config = { + ts: new Date().toISOString(), + lvl: 'INFO', + schema: SCHEMA, + event: 'session:config', + session_id: input.sessionId, + data: { raw: { working_dir: input.cwd, project_dir: input.cwd, project_slug: 'slug' } }, + } + return `${JSON.stringify(start)}\n${JSON.stringify(config)}\n` +} + +type Harness = { + home: string + cwd: string + registry: EventEmitter + locator: AmplifierSessionLocator + located: AmplifierSessionLocatedEvent[] + warn: ReturnType + /** Every path handed to the watch factory (finding A: must stay inside home). */ + watchPaths: string[] + writeSessionDir(id: string, opts?: { + cwd?: string + parentId?: string | null + omitEvents?: boolean + firstEvent?: string + }): Promise + arm(terminalId: string, opts?: { cwd?: string; resumeSessionId?: string }): void + submit(terminalId: string): void +} + +const cleanups: Array<() => Promise> = [] + +// Deterministic wait helpers (finding M): poll observable locator state instead +// of sleeping and asserting a negative. +function discoveryState(h: Harness, dir: string): string | undefined { + return ((h.locator as any).discoveries as Map).get(path.resolve(dir))?.state +} + +function hasDiscovery(h: Harness, dir: string): boolean { + return ((h.locator as any).discoveries as Map).has(path.resolve(dir)) +} + +/** True once the terminal's correlation window (opened by submit) has fully resolved. */ +function windowResolved(h: Harness, terminalId: string): boolean { + const armed = ((h.locator as any).armed as Map).get(terminalId) + return !!armed && armed.window === undefined +} + +/** Watcher that emits 'ready' but never announces anything (findings J/N1). */ +function inertWatchFactory(): AmplifierLocatorWatchFactory { + return () => { + const emitter = new EventEmitter() + setImmediate(() => emitter.emit('ready')) + return { + on: (event: string, handler: (...args: any[]) => void) => emitter.on(event, handler), + close: async () => {}, + } + } +} + +async function createHarness(options: { + windowMs?: number + probeTimeoutMs?: number + createProjectsDir?: boolean + /** Finding A: simulate a completely absent amplifier home. */ + removeHome?: boolean + watchImpl?: AmplifierLocatorWatchFactory + /** Finding H: terminals that exist in the registry before construction. */ + preexistingTerminals?: Array<{ terminalId: string; cwd?: string; resumeSessionId?: string }> +} = {}): Promise { + const home = await fsp.mkdtemp(path.join(os.tmpdir(), 'freshell-amp-locator-home-')) + const cwdRaw = await fsp.mkdtemp(path.join(os.tmpdir(), 'freshell-amp-locator-cwd-')) + const cwd = await fsp.realpath(cwdRaw) + if (options.removeHome) { + await fsp.rm(home, { recursive: true, force: true }) + } else if (options.createProjectsDir !== false) { + await fsp.mkdir(path.join(home, 'projects'), { recursive: true }) + } + + const registry = new EventEmitter() + if (options.preexistingTerminals) { + const records = options.preexistingTerminals.map((terminal) => ({ + terminalId: terminal.terminalId, + mode: 'amplifier', + status: 'running', + cwd: terminal.cwd ?? cwd, + resumeSessionId: terminal.resumeSessionId, + })) + ;(registry as any).list = () => records.map(({ terminalId }) => ({ terminalId })) + ;(registry as any).get = (terminalId: string) => records.find((r) => r.terminalId === terminalId) + } + const warn = vi.fn() + const watchPaths: string[] = [] + const innerWatch: AmplifierLocatorWatchFactory = options.watchImpl + ?? ((watchPath, watchOptions) => chokidar.watch(watchPath, watchOptions)) + const locator = new AmplifierSessionLocator({ + registry: registry as any, + amplifierHome: home, + log: { warn }, + windowMs: options.windowMs ?? 400, + probeTimeoutMs: options.probeTimeoutMs ?? 2_000, + probePollMs: 25, + watchImpl: (watchPath, watchOptions) => { + watchPaths.push(watchPath) + return innerWatch(watchPath, watchOptions) + }, + }) + const located: AmplifierSessionLocatedEvent[] = [] + locator.on('session.located', (event: AmplifierSessionLocatedEvent) => located.push(event)) + + cleanups.push(async () => { + await locator.dispose() + await fsp.rm(home, { recursive: true, force: true }) + await fsp.rm(cwdRaw, { recursive: true, force: true }) + }) + + return { + home, + cwd, + registry, + locator, + located, + warn, + watchPaths, + async writeSessionDir(id, opts = {}) { + const dir = path.join(home, 'projects', 'slug', 'sessions', id) + await fsp.mkdir(dir, { recursive: true }) + if (!opts.omitEvents) { + await fsp.writeFile( + path.join(dir, 'events.jsonl'), + eventsJsonl({ + sessionId: id, + cwd: opts.cwd ?? cwd, + parentId: opts.parentId ?? null, + firstEvent: opts.firstEvent, + }), + 'utf8', + ) + } + return dir + }, + arm(terminalId, opts = {}) { + registry.emit('terminal.created', { + terminalId, + mode: 'amplifier', + status: 'running', + cwd: opts.cwd ?? cwd, + resumeSessionId: opts.resumeSessionId, + }) + }, + submit(terminalId) { + registry.emit('terminal.input.raw', { terminalId, data: '\r', at: Date.now() }) + }, + } +} + +afterEach(async () => { + while (cleanups.length > 0) { + const cleanup = cleanups.pop()! + await cleanup() + } +}) + +async function waitFor( + predicate: () => boolean, + { timeoutMs = 8_000, intervalMs = 20, message = 'condition' } = {}, +): Promise { + const deadline = Date.now() + timeoutMs + while (Date.now() < deadline) { + if (predicate()) return + await new Promise((resolve) => setTimeout(resolve, intervalMs)) + } + throw new Error(`Timed out waiting for ${message}`) +} + +function sleep(ms: number): Promise { + return new Promise((resolve) => setTimeout(resolve, ms)) +} + +describe('AmplifierSessionLocator', () => { + it('exports the plan §5 window constant', () => { + expect(AMPLIFIER_DIR_APPEAR_WINDOW_MS).toBe(2_000) + }) + + it('binds two same-cwd terminals prompted in inverted spawn order via Enter-correlation (E8)', async () => { + const h = await createHarness() + h.arm('t1') + h.arm('t2') + await h.locator.whenReady() + + // Second-spawned terminal is prompted FIRST. + h.submit('t2') + await h.writeSessionDir('sess-b') + await waitFor(() => h.located.length === 1, { message: 't2 located' }) + expect(h.located[0]).toMatchObject({ terminalId: 't2', sessionId: 'sess-b' }) + expect(h.located[0].eventsPath).toBe( + path.join(h.home, 'projects', 'slug', 'sessions', 'sess-b', 'events.jsonl'), + ) + // Simulate the controller bind completing (locator unregisters on bind). + h.registry.emit('terminal.session.bound', { + terminalId: 't2', + provider: 'amplifier', + sessionId: 'sess-b', + reason: 'association', + }) + await waitFor(() => h.locator.armedCount() === 1, { message: 't2 disarmed' }) + + // First-spawned terminal prompts second. + h.submit('t1') + await h.writeSessionDir('sess-a') + await waitFor(() => h.located.length === 2, { message: 't1 located' }) + expect(h.located[1]).toMatchObject({ terminalId: 't1', sessionId: 'sess-a' }) + }) + + it('never binds a never-prompted terminal, cleans its entry on exit, and stops the watcher when none are armed', async () => { + const h = await createHarness() + h.arm('t1') + await h.locator.whenReady() + expect(h.locator.armedCount()).toBe(1) + expect(h.locator.isWatching()).toBe(true) + + h.registry.emit('terminal.exit', { terminalId: 't1' }) + expect(h.locator.armedCount()).toBe(0) + await waitFor(() => !h.locator.isWatching(), { message: 'watcher stopped' }) + expect(h.located).toHaveLength(0) + }) + + it('does not arm resume terminals', async () => { + const h = await createHarness() + h.arm('t1', { resumeSessionId: 'existing-session' }) + expect(h.locator.armedCount()).toBe(0) + expect(h.locator.isWatching()).toBe(false) + }) + + it('refuses ambiguous double-first-prompt within the window and leaves both terminals to the slow path', async () => { + const h = await createHarness({ windowMs: 1_000 }) + h.arm('t1') + h.arm('t2') + await h.locator.whenReady() + + h.submit('t1') + h.submit('t2') + await h.writeSessionDir('sess-a') + await h.writeSessionDir('sess-b') + + await waitFor( + () => h.warn.mock.calls.some(([payload]) => payload?.event === 'amplifier_locator_ambiguous'), + { message: 'ambiguity warning' }, + ) + // Deterministic: both windows have provably resolved — no bind happened. + await waitFor( + () => windowResolved(h, 't1') && windowResolved(h, 't2'), + { message: 'both windows resolved' }, + ) + expect(h.located).toHaveLength(0) + // Both stay armed — the coordinator slow-path remains eligible. + expect(h.locator.armedCount()).toBe(2) + }) + + it('ignores underscore-named dirs and session:start records with parent_id', async () => { + const h = await createHarness() + h.arm('t1') + await h.locator.whenReady() + + h.submit('t1') + await h.writeSessionDir('sub_agent-dir') + await h.writeSessionDir('parented-dir', { parentId: 'parent-1' }) + await h.writeSessionDir('good-dir') + + await waitFor(() => h.located.length === 1, { message: 'good dir located' }) + expect(h.located[0]).toMatchObject({ terminalId: 't1', sessionId: 'good-dir' }) + }) + + it('rejects candidates whose session:config cwd does not match the terminal cwd', async () => { + const h = await createHarness({ windowMs: 300 }) + h.arm('t1') + await h.locator.whenReady() + + h.submit('t1') + await h.writeSessionDir('other-cwd-dir', { cwd: '/somewhere/else/entirely' }) + + // Deterministic: wait for the window to provably resolve, then assert. + await waitFor(() => windowResolved(h, 't1'), { message: 'window resolved' }) + expect(h.located).toHaveLength(0) + // Zero candidates: the locator keeps watching (E5 empty-Enter semantics). + expect(h.locator.armedCount()).toBe(1) + }) + + it('still correlates when events.jsonl appears after the dir (even past window close)', async () => { + const h = await createHarness({ windowMs: 250, probeTimeoutMs: 3_000 }) + h.arm('t1') + await h.locator.whenReady() + + h.submit('t1') + const dir = await h.writeSessionDir('late-events-dir', { omitEvents: true }) + // File lands after the correlation window has already closed. + await sleep(450) + await fsp.writeFile( + path.join(dir, 'events.jsonl'), + eventsJsonl({ sessionId: 'late-events-dir', cwd: h.cwd }), + 'utf8', + ) + + await waitFor(() => h.located.length === 1, { message: 'late events located' }) + expect(h.located[0]).toMatchObject({ terminalId: 't1', sessionId: 'late-events-dir' }) + }) + + it('excludes dirs from the pre-spawn snapshot', async () => { + const h = await createHarness() + // Pre-existing session dir with a matching cwd: must never be a candidate. + await h.writeSessionDir('old-dir') + + h.arm('t1') + await h.locator.whenReady() + h.submit('t1') + await h.writeSessionDir('fresh-dir') + + await waitFor(() => h.located.length === 1, { message: 'fresh dir located' }) + expect(h.located[0]).toMatchObject({ terminalId: 't1', sessionId: 'fresh-dir' }) + expect(h.warn.mock.calls.some(([payload]) => payload?.event === 'amplifier_locator_ambiguous')).toBe(false) + }) + + it('creates and watches projects/ when it does not exist yet (never an ancestor)', async () => { + const h = await createHarness({ createProjectsDir: false }) + h.arm('t1') + await h.locator.whenReady() + + // Finding A: arming pre-creates projects/ and watches exactly it. + const projectsDir = path.join(h.home, 'projects') + expect(fs.existsSync(projectsDir)).toBe(true) + expect(h.watchPaths).toEqual([path.resolve(projectsDir)]) + + h.submit('t1') + await h.writeSessionDir('first-ever-dir') + + await waitFor(() => h.located.length === 1, { message: 'first-ever dir located' }) + expect(h.located[0]).toMatchObject({ terminalId: 't1', sessionId: 'first-ever-dir' }) + }) + + it('arming with amplifierHome entirely absent creates projects/ and never watches outside amplifierHome', async () => { + // Finding A regression: the old nearest-existing-ancestor walk escaped to + // $HOME (or /) when the amplifier home was missing, recursively watching + // the whole home tree (inotify exhaustion). + const h = await createHarness({ removeHome: true }) + h.arm('t1') + await h.locator.whenReady() + + const projectsDir = path.resolve(path.join(h.home, 'projects')) + expect(fs.existsSync(projectsDir)).toBe(true) + expect(h.watchPaths.length).toBeGreaterThan(0) + for (const watchPath of h.watchPaths) { + expect(watchPath).toBe(projectsDir) + expect(path.resolve(watchPath).startsWith(path.resolve(h.home) + path.sep)).toBe(true) + } + + // The watch is functional: first-ever session dir still correlates. + h.submit('t1') + await h.writeSessionDir('first-ever-dir') + await waitFor(() => h.located.length === 1, { message: 'first-ever dir located' }) + expect(h.located[0]).toMatchObject({ terminalId: 't1', sessionId: 'first-ever-dir' }) + }) + + it('a persistent watcher error self-disables the locator with a single warn', async () => { + const emitters: Array<{ emitter: EventEmitter; closed: boolean }> = [] + const h = await createHarness({ + watchImpl: () => { + const entry = { emitter: new EventEmitter(), closed: false } + emitters.push(entry) + setImmediate(() => entry.emitter.emit('ready')) + return { + on: (event: string, handler: (...args: any[]) => void) => entry.emitter.on(event, handler), + close: async () => { + entry.closed = true + }, + } + }, + }) + h.arm('t1') + await h.locator.whenReady() + expect(h.locator.isWatching()).toBe(true) + + emitters[0].emitter.emit('error', new Error('EMFILE: inotify exhausted')) + emitters[0].emitter.emit('error', new Error('EMFILE: inotify exhausted')) + + const watchErrorWarns = h.warn.mock.calls.filter( + ([payload]) => payload?.event === 'amplifier_locator_watch_error', + ) + expect(watchErrorWarns).toHaveLength(1) + expect(h.locator.isWatching()).toBe(false) + await waitFor(() => emitters[0].closed, { message: 'failed watcher closed' }) + + // Sticky: arming more terminals never re-creates a watcher. + h.arm('t2') + expect(h.locator.isWatching()).toBe(false) + expect(emitters).toHaveLength(1) + }) + + it('never correlates a dir created well before the Enter press (pre-epsilon bound, finding F)', async () => { + const h = await createHarness() + h.arm('t1') + await h.locator.whenReady() + + // A FOREIGN session dir appears with a matching cwd (e.g. another launcher + // in the same cwd) — observed by the watcher well before any Enter here. + const dir = await h.writeSessionDir('foreign-dir') + await waitFor(() => hasDiscovery(h, dir), { message: 'foreign dir discovered' }) + // Age the discovery past the jitter allowance, deterministically. + await sleep(AMPLIFIER_DIR_PRE_EPSILON_MS + 150) + + h.submit('t1') + await waitFor(() => windowResolved(h, 't1'), { message: 'window resolved' }) + expect(h.located).toHaveLength(0) + expect(h.locator.armedCount()).toBe(1) + }) + + it('still correlates a dir observed marginally before the Enter (jitter tolerance)', async () => { + const h = await createHarness() + h.arm('t1') + await h.locator.whenReady() + + const dir = await h.writeSessionDir('jitter-dir') + await waitFor(() => hasDiscovery(h, dir), { message: 'dir discovered' }) + // ~100ms of observed-before-submit skew: within the 250ms pre-epsilon. + await sleep(100) + h.submit('t1') + + await waitFor(() => h.located.length === 1, { message: 'jitter dir located' }) + expect(h.located[0]).toMatchObject({ terminalId: 't1', sessionId: 'jitter-dir' }) + }) + + it('arms running unbound amplifier terminals that existed before construction (finding H)', async () => { + const h = await createHarness({ + preexistingTerminals: [ + { terminalId: 't-pre' }, + { terminalId: 't-pre-resume', resumeSessionId: 'existing-session' }, + ], + }) + // Constructor sweep armed the unbound terminal only (resume never arms). + expect(h.locator.armedCount()).toBe(1) + expect(h.locator.isWatching()).toBe(true) + await h.locator.whenReady() + + h.submit('t-pre') + await h.writeSessionDir('pre-existing-terminal-dir') + await waitFor(() => h.located.length === 1, { message: 'pre-existing terminal located' }) + expect(h.located[0]).toMatchObject({ terminalId: 't-pre', sessionId: 'pre-existing-terminal-dir' }) + }) + + it('re-probes a probe_timeout-rejected discovery when events.jsonl changes later (finding I)', async () => { + // Short probe deadline, long window: the session's config record lands + // AFTER the probe hard-deadline but while the window is still open. + const h = await createHarness({ windowMs: 3_000, probeTimeoutMs: 100 }) + h.arm('t1') + await h.locator.whenReady() + + h.submit('t1') + const dir = await h.writeSessionDir('slow-config-dir', { omitEvents: true }) + await waitFor( + () => discoveryState(h, dir) === 'rejected', + { message: 'probe_timeout rejection' }, + ) + + // events.jsonl finally lands: the rejection must not be permanent. + await fsp.writeFile( + path.join(dir, 'events.jsonl'), + eventsJsonl({ sessionId: 'slow-config-dir', cwd: h.cwd }), + 'utf8', + ) + await waitFor(() => h.located.length === 1, { message: 'slow-config dir located' }) + expect(h.located[0]).toMatchObject({ terminalId: 't1', sessionId: 'slow-config-dir' }) + }) + + it('rescans projects/*/sessions/* at window close when the watcher announced nothing (finding J)', async () => { + // Inert watcher: simulates a dir created during chokidar's initial scan + // (swallowed by ignoreInitial and never announced). + const h = await createHarness({ watchImpl: inertWatchFactory() }) + h.arm('t1') + await h.locator.whenReady() + + h.submit('t1') + await h.writeSessionDir('unannounced-dir') + + // Window close performs the one-shot readdir snapshot-diff, anchors the + // dir at its fs.stat birth/mtime (after the Enter here), and feeds it + // through the normal discovery path (probe + cwd confirm). + await waitFor(() => h.located.length === 1, { message: 'unannounced dir located via rescan' }) + expect(h.located[0]).toMatchObject({ terminalId: 't1', sessionId: 'unannounced-dir' }) + }) + + it('rescan never resurrects a dir born before the Enter press (N1: stat-anchored, pre-epsilon enforced)', async () => { + // Re-verification finding N1: anchoring rescanned dirs at window.openedAt + // auto-satisfied the eligibility bounds, defeating finding F's pre-Enter + // epsilon — an alien same-cwd session dir created BEFORE the Enter got + // bound (the OpenCode-RCA wrong-binding class). The rescan must anchor at + // the dir's fs.stat birthtime/mtime instead and reject pre-Enter dirs. + const h = await createHarness({ watchImpl: inertWatchFactory() }) + h.arm('t1') + await h.locator.whenReady() + + // Alien same-cwd session dir born ~400ms BEFORE the Enter; the inert + // watcher never announces it, so only the rescan could ever see it. + await h.writeSessionDir('alien-dir') + await sleep(AMPLIFIER_DIR_PRE_EPSILON_MS + 150) + + h.submit('t1') + await waitFor(() => windowResolved(h, 't1'), { message: 'window resolved' }) + expect(h.located).toHaveLength(0) + // Still armed: the coordinator slow-path remains eligible. + expect(h.locator.armedCount()).toBe(1) + }) + + it('dispose closes the watcher and clears armed terminals', async () => { + const h = await createHarness() + h.arm('t1') + await h.locator.whenReady() + expect(h.locator.isWatching()).toBe(true) + + await h.locator.dispose() + expect(h.locator.armedCount()).toBe(0) + expect(h.locator.isWatching()).toBe(false) + + // Registered handlers are detached: further registry events are ignored. + h.arm('t2') + expect(h.locator.armedCount()).toBe(0) + }) +}) diff --git a/test/unit/server/coding-cli/turn-completion-ledger.test.ts b/test/unit/server/coding-cli/turn-completion-ledger.test.ts new file mode 100644 index 00000000..a4079108 --- /dev/null +++ b/test/unit/server/coding-cli/turn-completion-ledger.test.ts @@ -0,0 +1,62 @@ +import { describe, expect, it } from 'vitest' +import { TurnCompletionLedger } from '../../../../server/coding-cli/turn-completion-ledger' + +describe('TurnCompletionLedger', () => { + it('starts completionSeq at 1 and increments monotonically per terminalId', () => { + const ledger = new TurnCompletionLedger() + expect(ledger.recordTurnCompletion({ terminalId: 't1', at: 1000 }).completionSeq).toBe(1) + expect(ledger.recordTurnCompletion({ terminalId: 't1', at: 2000 }).completionSeq).toBe(2) + expect(ledger.recordTurnCompletion({ terminalId: 't1', at: 3000 }).completionSeq).toBe(3) + }) + + it('keeps independent sequences per terminalId', () => { + const ledger = new TurnCompletionLedger() + expect(ledger.recordTurnCompletion({ terminalId: 't1', at: 1000 }).completionSeq).toBe(1) + expect(ledger.recordTurnCompletion({ terminalId: 't2', at: 1100 }).completionSeq).toBe(1) + expect(ledger.recordTurnCompletion({ terminalId: 't1', at: 1200 }).completionSeq).toBe(2) + expect(ledger.recordTurnCompletion({ terminalId: 't2', at: 1300 }).completionSeq).toBe(2) + }) + + it('returns the input spread with completionSeq appended (extra fields pass through)', () => { + const ledger = new TurnCompletionLedger() + const event = ledger.recordTurnCompletion({ terminalId: 't1', sessionId: 's-1', at: 1000 }) + expect(event).toEqual({ terminalId: 't1', sessionId: 's-1', at: 1000, completionSeq: 1 }) + // Exact key order of the four trackers' original `{ ...input, completionSeq }`. + expect(JSON.stringify(event)).toBe('{"terminalId":"t1","sessionId":"s-1","at":1000,"completionSeq":1}') + }) + + it('listLatestCompletions keeps only the latest snapshot per terminal, without extra fields', () => { + const ledger = new TurnCompletionLedger() + ledger.recordTurnCompletion({ terminalId: 't1', sessionId: 's-1', at: 1000 }) + ledger.recordTurnCompletion({ terminalId: 't1', sessionId: 's-1', at: 2000 }) + expect(ledger.listLatestCompletions()).toEqual([{ terminalId: 't1', at: 2000, completionSeq: 2 }]) + // Snapshot shape is exactly { terminalId, at, completionSeq } (no sessionId). + expect(JSON.stringify(ledger.listLatestCompletions())).toBe( + '[{"terminalId":"t1","at":2000,"completionSeq":2}]', + ) + }) + + it('listLatestCompletions preserves first-completion insertion order across terminals', () => { + const ledger = new TurnCompletionLedger() + ledger.recordTurnCompletion({ terminalId: 't1', at: 1000 }) + ledger.recordTurnCompletion({ terminalId: 't2', at: 1100 }) + // Updating t1 must not move it behind t2 (Map.set on an existing key keeps position). + ledger.recordTurnCompletion({ terminalId: 't1', at: 1200 }) + expect(ledger.listLatestCompletions()).toEqual([ + { terminalId: 't1', at: 1200, completionSeq: 2 }, + { terminalId: 't2', at: 1100, completionSeq: 1 }, + ]) + }) + + it('never forgets a terminal: seq stays monotonic and snapshots survive (matches tracker removal semantics)', () => { + // None of the four trackers clear ledger state on terminal removal (noteExit / + // untrackTerminal): completionSeq must stay monotonic if the same terminalId + // completes again, and the latest snapshot outlives the activity record so + // late-attaching clients still receive it. + const ledger = new TurnCompletionLedger() + ledger.recordTurnCompletion({ terminalId: 't1', at: 1000 }) + // (a tracker would remove + re-track t1 here) + expect(ledger.recordTurnCompletion({ terminalId: 't1', at: 5000 }).completionSeq).toBe(2) + expect(ledger.listLatestCompletions()).toEqual([{ terminalId: 't1', at: 5000, completionSeq: 2 }]) + }) +}) diff --git a/test/unit/server/coding-cli/turn-completion-snapshots.test.ts b/test/unit/server/coding-cli/turn-completion-snapshots.test.ts new file mode 100644 index 00000000..93731d35 --- /dev/null +++ b/test/unit/server/coding-cli/turn-completion-snapshots.test.ts @@ -0,0 +1,189 @@ +/** + * Phase 4 behavior-preservation snapshots (plan + * docs/plans/2026-07-08-amplifier-session-durability-plan.md §9 Phase 4). + * + * Scripted turn sequences on each of the four trackers must produce + * byte-identical `listLatestCompletions()` JSON and identical emitted + * `completionSeq` sequences before AND after the TurnCompletionLedger + * extraction. These assertions were captured against the pre-extraction + * trackers (verified green before the ledger adoption commit) and must never + * change without an explicit protocol decision. + */ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { ClaudeActivityTracker, type ClaudeTurnCompleteEvent } from '../../../../server/coding-cli/claude-activity-tracker' +import { CodexActivityTracker, type CodexTurnCompleteEvent } from '../../../../server/coding-cli/codex-activity-tracker' +import { OpencodeActivityTracker } from '../../../../server/coding-cli/opencode-activity-tracker' +import { + AmplifierActivityTracker, + type AmplifierTurnCompleteEvent, +} from '../../../../server/coding-cli/amplifier-activity-tracker' + +describe('ClaudeActivityTracker turn-completion snapshot', () => { + it('scripted turns: exact completionSeq sequence and byte-identical listLatestCompletions()', () => { + const tracker = new ClaudeActivityTracker() + const completions: ClaudeTurnCompleteEvent[] = [] + tracker.on('turn.complete', (e: ClaudeTurnCompleteEvent) => completions.push(e)) + + // t1: two BEL-terminated turns. + tracker.trackTerminal({ terminalId: 't1', sessionId: 's-1', at: 1000 }) + tracker.noteInput({ terminalId: 't1', data: '\r', at: 2000 }) + tracker.noteOutput({ terminalId: 't1', data: '\x07', at: 3000 }) + tracker.noteInput({ terminalId: 't1', data: '\r', at: 4000 }) + tracker.noteOutput({ terminalId: 't1', data: '\x07', at: 5000 }) + // t2: one turn. + tracker.trackTerminal({ terminalId: 't2', at: 1000 }) + tracker.noteInput({ terminalId: 't2', data: '\r', at: 6000 }) + tracker.noteOutput({ terminalId: 't2', data: '\x07', at: 7000 }) + // t1 exits and is re-tracked: the sequence stays monotonic (no reset to 1). + tracker.noteExit({ terminalId: 't1' }) + tracker.trackTerminal({ terminalId: 't1', at: 8000 }) + tracker.noteInput({ terminalId: 't1', data: '\r', at: 9000 }) + tracker.noteOutput({ terminalId: 't1', data: '\x07', at: 10_000 }) + + expect(completions).toEqual([ + { terminalId: 't1', sessionId: 's-1', at: 3000, completionSeq: 1 }, + { terminalId: 't1', sessionId: 's-1', at: 5000, completionSeq: 2 }, + { terminalId: 't2', at: 7000, completionSeq: 1 }, + { terminalId: 't1', at: 10_000, completionSeq: 3 }, + ]) + expect(completions.map((c) => c.completionSeq)).toEqual([1, 2, 1, 3]) + expect(JSON.stringify(tracker.listLatestCompletions())).toBe( + '[{"terminalId":"t1","at":10000,"completionSeq":3},' + + '{"terminalId":"t2","at":7000,"completionSeq":1}]', + ) + }) +}) + +describe('CodexActivityTracker turn-completion snapshot', () => { + it('scripted turns: exact completionSeq sequence and byte-identical listLatestCompletions()', () => { + const tracker = new CodexActivityTracker() + const completions: CodexTurnCompleteEvent[] = [] + tracker.on('turn.complete', (e: CodexTurnCompleteEvent) => completions.push(e)) + + // term-1: two app-server-delimited turns. + tracker.bindTerminal({ terminalId: 'term-1', sessionId: 'session-1', reason: 'association', at: 1000 }) + tracker.onTurnStarted({ terminalId: 'term-1', at: 1100 }) + tracker.onTurnCompleted({ terminalId: 'term-1', at: 1200 }) + tracker.onTurnStarted({ terminalId: 'term-1', at: 2100 }) + tracker.onTurnCompleted({ terminalId: 'term-1', at: 2200 }) + // term-2: one turn. + tracker.bindTerminal({ terminalId: 'term-2', sessionId: 'session-2', reason: 'association', at: 1000 }) + tracker.onTurnStarted({ terminalId: 'term-2', at: 3100 }) + tracker.onTurnCompleted({ terminalId: 'term-2', at: 3200 }) + + expect(completions).toEqual([ + { terminalId: 'term-1', sessionId: 'session-1', at: 1200, completionSeq: 1 }, + { terminalId: 'term-1', sessionId: 'session-1', at: 2200, completionSeq: 2 }, + { terminalId: 'term-2', sessionId: 'session-2', at: 3200, completionSeq: 1 }, + ]) + expect(completions.map((c) => c.completionSeq)).toEqual([1, 2, 1]) + expect(JSON.stringify(tracker.listLatestCompletions())).toBe( + '[{"terminalId":"term-1","at":2200,"completionSeq":2},' + + '{"terminalId":"term-2","at":3200,"completionSeq":1}]', + ) + }) +}) + +describe('OpencodeActivityTracker turn-completion snapshot', () => { + afterEach(() => { + vi.useRealTimers() + }) + + it('scripted turns: exact completionSeq sequence and byte-identical listLatestCompletions()', async () => { + vi.useFakeTimers() + const encoder = new TextEncoder() + // Two busy→idle turns for the same owned session on one SSE stream. + const sseEvents = [ + { type: 'server.connected', properties: {} }, + { type: 'session.status', properties: { sessionID: 'session-oc', status: { type: 'busy' } } }, + { type: 'session.idle', properties: { sessionID: 'session-oc' } }, + { type: 'session.status', properties: { sessionID: 'session-oc', status: { type: 'busy' } } }, + { type: 'session.idle', properties: { sessionID: 'session-oc' } }, + ] + const fetchImpl = vi.fn(async (input: RequestInfo | URL) => { + const url = String(input) + if (url.endsWith('/global/health')) { + return new Response(JSON.stringify({ ok: true }), { headers: { 'content-type': 'application/json' } }) + } + if (url.endsWith('/event')) { + return new Response(new ReadableStream({ + start(controller) { + for (const event of sseEvents) { + controller.enqueue(encoder.encode(`data: ${JSON.stringify(event)}\n\n`)) + } + controller.close() + }, + }), { headers: { 'content-type': 'text/event-stream' } }) + } + if (url.endsWith('/session/status')) { + return new Response(JSON.stringify({}), { headers: { 'content-type': 'application/json' } }) + } + throw new Error(`Unexpected URL: ${url}`) + }) + + // Fixed clock so every observation timestamp is deterministic (byte-exact). + const tracker = new OpencodeActivityTracker({ + fetchImpl: fetchImpl as typeof fetch, + random: () => 0, + now: () => 4000, + }) + const completions: Array<{ terminalId: string; sessionId: string; at: number; completionSeq: number }> = [] + tracker.on('association.requested', (payload) => tracker.confirmSessionAssociation(payload)) + tracker.on('turn.complete', (payload) => completions.push(payload)) + + tracker.trackTerminal({ terminalId: 'term-oc', endpoint: { hostname: '127.0.0.1', port: 43123 } }) + await vi.advanceTimersByTimeAsync(0) + + expect(completions).toEqual([ + { terminalId: 'term-oc', sessionId: 'session-oc', at: 4000, completionSeq: 1 }, + { terminalId: 'term-oc', sessionId: 'session-oc', at: 4000, completionSeq: 2 }, + ]) + expect(completions.map((c) => c.completionSeq)).toEqual([1, 2]) + expect(JSON.stringify(tracker.listLatestCompletions())).toBe( + '[{"terminalId":"term-oc","at":4000,"completionSeq":2}]', + ) + + tracker.dispose() + }) +}) + +describe('AmplifierActivityTracker turn-completion snapshot', () => { + beforeEach(() => { + vi.useFakeTimers() + vi.setSystemTime(0) + }) + afterEach(() => { + vi.useRealTimers() + }) + + it('scripted events-driven turns: exact completionSeq sequence and byte-identical listLatestCompletions()', () => { + const tracker = new AmplifierActivityTracker() + const completions: AmplifierTurnCompleteEvent[] = [] + tracker.on('turn.complete', (e: AmplifierTurnCompleteEvent) => completions.push(e)) + + // Events-driven turn (prompt:complete is the single boundary). + tracker.trackTerminal({ terminalId: 't1', at: 1000 }) + tracker.bindSession({ terminalId: 't1', sessionId: 's-1', at: 1000 }) + tracker.applyLifecycle('t1', { kind: 'turn.began', at: new Date(2000).toISOString() }) + tracker.applyLifecycle('t1', { kind: 'turn.completed', at: new Date(5000).toISOString() }) + + // Exit + re-track: the ledger is NOT reset — the next completion continues the + // sequence (monotonic per terminalId across terminal lifetimes). + tracker.noteExit({ terminalId: 't1' }) + tracker.trackTerminal({ terminalId: 't1', at: 6000 }) + + // Second turn: PTY submit is provisional; the lifecycle records own the boundary. + tracker.noteInput({ terminalId: 't1', data: '\r', at: 7000 }) + tracker.applyLifecycle('t1', { kind: 'turn.began', at: new Date(7100).toISOString() }) + tracker.applyLifecycle('t1', { kind: 'turn.completed', at: new Date(9000).toISOString() }) + + expect(completions).toEqual([ + { terminalId: 't1', sessionId: 's-1', at: 5000, completionSeq: 1 }, + { terminalId: 't1', at: 9000, completionSeq: 2 }, + ]) + expect(completions.map((c) => c.completionSeq)).toEqual([1, 2]) + expect(JSON.stringify(tracker.listLatestCompletions())).toBe( + '[{"terminalId":"t1","at":9000,"completionSeq":2}]', + ) + }) +})