diff --git a/docs/plans/2026-09-27-claude-proxy-gap-marker.md b/docs/plans/2026-09-27-claude-proxy-gap-marker.md new file mode 100644 index 000000000..8494e1bd3 --- /dev/null +++ b/docs/plans/2026-09-27-claude-proxy-gap-marker.md @@ -0,0 +1,335 @@ +# Say where the Claude live feed lost proxy events (#1381) + +Size: standard plan. The change crosses repos (claude-code-headless, then the +app). The cause is known; the gap's placement has a real design choice. + +## Outcome + +When the proxy transport loses generations of events +(claude-code-headless#64 `transport-gap`), the Claude feed stops showing +the surviving chunks as one continuous answer. +- The turn that was streaming across the gap is sealed. +- The feed shows a DURABLE row among the conversation, where the loss + began: **"Part of this response was not captured (HH:MM:SS–HH:MM:SS)"**. + - The row stays after later turns. + - It comes back whenever the conversation's feed is rebuilt within this + app run. It does not survive an app restart (decision 5, #1445). +- The saved transcript (JSONL, Claude's own file) is untouched and still + fills in the full message as usual. + +## Evidence (verified 2026-09-27, do not re-derive) + +- **Today nothing consumes it.** + - Package `ProxyServer.pollEventsOnce` emits + `transport-gap {lostGenerations}`. + - `ClaudeSession.attachProxyServer` re-emits it as + `proxy-transport-gap`. + - `SessionManager` records the `claude.proxy_transport_gap` incident + and re-emits it, with no listener. + - Proxy `event`s go to `headless.handleProxyTransportEvent` → + `ClaudeProxyAdapter.handleTransportEvent`, the live streaming turn. +- **The gap is emitted at the wrong position.** `pollEventsOnce` emits + `transport-gap` BEFORE every line of the poll, but + `EventsFileTail.poll()` returns: + - the held generation's tail; + - then, on adopting a new live generation, `settleBelow` (which reads + `.1` if still readable and counts the rest as lost); + - then the new live lines. + + So the lost span sits between the old tail and the `.1`/live lines. One + poll can settle twice: the first adoption, then a rotation. Emitting + first would make the app seal before it applies the old tail. A pre-gap + `request` would then create a flow AFTER the seal, whose chunks were + lost, and it would stream corrupted with no marker. +- **The seal mechanism already exists.** + - `ClaudeProxyAdapter.reapStaleActiveFlow(state, interruption)` publishes + `turn_stopped {interruption}` + `finishTurn` + phase idle, and deletes + the flow. + - Chunks for a flow the adapter no longer tracks are ignored + (`onChunk`: `if (!state) return`). + - `sealFlowsSilentSince` (#963) and `onTransportError` (#1040) use it + with `'system-suspended'` and `'transport-error'`. +- **App template: #1040 (`'transport-error'`, commits 4e799727 and + b58bada3).** The interruption flows through: + - `foldEvent.ts` (`turn_stopped` copies `interruption`); + - `state.ts` (`SemanticTurn.interruption`); + - `collectLedgerInput.ts` (`transportInterruptedTurnId` statics key); + - `rendering/model/types.ts`; + - `ledgerFeedItems.ts`, `feed/model/renderModel.ts`, and `Feed.tsx` + (the `MarkerRow` "Interrupted before the response finished"); + - `rendering/observations/local.ts`, `replay/redact.ts` (the + `interruption` key is allowed, as a closed enum), and + `replay/invariants.ts`. + + The phone shares this pipeline. +- **Recordings.** Real Claude mitm events live in + `~/.config/agent-code/proxy/**/proxy-events.jsonl` (`flow_id`, + `chunk_b64`). They are private conversation content, so they are used + only to calibrate frame shapes. No rotated `.1` files exist locally, + because the packaged app predates #64's rotation. The package's adapter + tests use synthetic SSE frames in the recorded shape + (`ClaudeProxyAdapter.clientDisconnect.test.ts`), and so will these. + +## Decisions (3 and 5 are OWNER-APPROVED; the rest are rulings) + +1. **Wording:** superseded by decision 5. +2. **What gets sealed:** every flow the adapter is tracking at the gap + point. + - Streaming flows are sealed with the new interruption + `'transport-gap'` (marker). + - Tracked non-streaming flows (a request seen, no chunk yet) are + forgotten without a marker, because their first chunks may be in the + lost span. A decoder that starts mid-SSE would mis-assemble blocks. + - **Cost:** the rest of such a response does not stream live; the + JSONL row still lands. + - Alternative rejected: resetting the SSE parser and continuing. The + lost frames include `content_block_start`/`stop`, so block + assembly after the gap is unreliable. +3. **The spinner after a seal:** the phase goes idle for the remainder of + that one response, even though Claude may still be streaming it. The + next request (the next tool round-trip) starts a new flow and a new + phase. Same trade as #1040. OWNER-APPROVED (B6 proxy, 2026-09-27): + option A. +4. **Where the gap is placed:** at its true position in the line order, + with a package change. We don't approximate it app-side. +5. **How long the row stays: OWNER-APPROVED (B6 proxy, 2026-09-27, q119 + as amended by q120), verbatim:** "Option B, bounded to the + main-process lifetime; restart durability needs a dedicated store and is + an owner decision (1445), related to #1235." + - The row is a durable FEED-HISTORY row, not a work-slot marker (a + work-slot marker would vanish once the agent worked again). + - It survives later turns in this app run, and every feed rebuild + within it. It does not survive an app restart: that is #1445. + - The row reads **"Part of this response was not captured + (HH:MM:SS–HH:MM:SS)"**, which supersedes the decision-1 wording. It is + one row kind, adds no new UI surface, and uses the existing muted + MarkerRow styling. + - Tried and withdrawn: reseeding prior runs' rows from the + AppRunJournal's `claude.proxy_transport_gap` incidents. B6 (q120) + ruled the journal forensic, lossy and without a conversation id; it + stays a diagnostic copy only. + - Ruling: the row is held by MAIN, in memory, per provider + CONVERSATION (`TransportGapLedger`). It rides the conversation's + initial history chunk, the load every feed rebuild makes: a window + reload, an agent reload or crash respawn, a resume in another pane, + live or exited. + - Not on disk: the owner removed on-disk feed rows once (#1235 ghost + log). The always-on `claude.proxy_transport_gap` incident is the + on-disk record. + - Bounded: the newest 50 gaps per conversation, and 500 + conversations (least recently recorded evicted). + - Lost on an app (main process) restart, per the approved answer + above (#1445). + - Ruling: the span is app-clock time, from `since` (when the tail's + previous poll STARTED reading; its finish time was the first version, + corrected in claude-code-headless#69 review a) to `until` + (when the gap was detected). Wire events carry no timestamp, and the + lost events were written inside that window. Cost if wrong: the + window is wider than the true loss, never narrower. + - Ruling: the #1040-style work-slot marker from 91b05b03 is withdrawn + (one row kind). The fold still keeps `interruption: 'transport-gap'` + on the turn, so the sealed turn reads as cut off rather than + finished. + +## Change + +### Package (claude-code-headless#69; app pointer bump after merge) + +- `EventsFilePoll` gains `gaps: Array<{ index: number; lostGenerations: + number }>`. + - `index` = how many of the poll's lines were written before the loss. + `settleBelow` records `out.lines.length` at entry. + - `lostGenerations` stays as the total (API compatible). +- `ProxyServer.pollEventsOnce` emits lines and `transport-gap` interleaved + at each gap's index. + - The payload is `TransportGap = { lostGenerations, since, until }` + (app-clock ms): `since` = when the previous poll completed (null + before the first), `until` = now. It is exported. + - The console.warn is unchanged. +- `ClaudeProxyAdapter.sealFlowsForTransportGap()` (public, synchronous): + - a streaming turn → `reapStaleActiveFlow(state, 'transport-gap')`; + - a flow with a first chunk but no turn yet → phase idle; + - a turn that already stopped (awaiting its tool) keeps its phase; + - every tracked flow is then forgotten. +- `SemanticTurnStoppedEvent.interruption` adds `'transport-gap'`. +- API.md updated. + +### App + +- `ClaudeSession.proxyGapHandler` calls + `this.headless?.proxy?.sealFlowsForTransportGap()` BEFORE re-emitting + the `TransportGap`. +- `SessionManager`: + - on a gap: records the always-on incident (now with `since`/`until`) + and appends a record to `TransportGapLedger`, keyed by the session's + provider conversation id (`getNativeConversationId`; with none yet + the record is live-only); + - emits `proxy-transport-gap {sessionId, gap: TransportGapRecord}`; + - `getTransportGaps(conversationId)`; + - the ledger is NOT one of the caches cleared with the process. +- Wire: + - `TransportGapRecord` lives in `@shared/types/session`; + - `SessionHistoryChunk.transportGaps?`; + - `SessionTransportGapEvent`; + - the tap channel `transport-gap` (the desktop's `session:transport-gap`, + and an additive remote protocol / phone wire channel); + - `SessionFeed.onSessionTransportGap` on all four implementations. +- `session:load-initial-history` returns the conversation's gaps, only + when there are any. +- Renderer: + - `runtime.transportGaps`, fed by the live subscription (only onto an + existing pane) and by `initialHistory.ts`, merged by record id + (`mergeTransportGaps`); + - `collectTransportGaps` makes one `provider-notice`-owner candidate per + gap, contentKind `transport-gap`, at `since ?? until`, riding the + notice candidates; + - the feed item `transport-gap`, rendered as the existing muted + `MarkerRow` (`transportGapSentence`); + - `foldEvent` keeps `interruption: 'transport-gap'` on the sealed turn. +- The submodule pointer is bumped to #69's merge commit, with a lockfile + resync if needed. + +## Tests (fail-first; each would fail before its fix) + +- **Package:** + - `eventsFileTail.test.ts`: the gap position, no positions when nothing + is lost, and the ProxyServer emit order plus `since`/`until`; + - `ClaudeProxyAdapter.transportGap.test.ts` (5). +- **App:** + - `claudeSession.suspension.test.ts`: event → seal → re-emit → event; + - `sessionManager.proxyGap.test.ts`: the incident and record, held for + the conversation across a respawn, not following the pane into a new + conversation; + - `transportGapLedger.test.ts`: the bounds; + - `transportGapRow.test.ts`: the REAL adapter → fold → ledger → view + bridge (seal, placement, persistence after later turns, rebuild merge, + the sentence); + - `initialHistory.renderer.test.tsx`: the loader restores gaps into a + rebuilt runtime, once; + - `useIpcSubscriptions.renderer.test.tsx`: the live event is held once + per id and ignored for a pane that is gone. + +## Verification + +- Package: `tsc` and vitest. +- App: `npx tsc -b` and the scoped vitest runs. +- Boundary: no live gap can be produced here. It needs >= 1 GiB of proxy + traffic through a stalled poller, and the app is never launched. The + path is pinned from the package event to the rendered row by tests at + each boundary. + +## Out of scope + +- Codex's proxy (`codex-headless` responsesProxy) has no rotation or gap + contract. +- The 1.2–1.4 GB unrotated `proxy-events.jsonl` files under + `~/.config/agent-code/proxy` come from the packaged app predating #64. + That is noted for the manager, not fixed here. + +## Coordination + +claude-code-headless#67 (another worker's, for #1380) also edits +`src/proxy/proxyServer.ts`, in different hunks (`startUnlocked`, options). +The two app pointer bumps must land in sequence. This was reported to the +manager before any package code was written, and the manager cleared it. + +HOLD (steering q119): #1442 stays out of integration until #69 merges. +Then: repoint the submodule to the merge commit, resync the lockfile if +needed, run exact-head CI, and run three independent reviews. + +Residual surfaces outside this PR, filed: +- #1443: the phone does not paint the row yet; +- #1444: recordings do not capture the channel. + +## Execution notes + +- Package: claude-code-headless#69 (`8be9a7a` on `fix/proxy-gap-position`) + - `EventsFilePoll.gaps`, the in-order `transport-gap` with + `{since, until}`, `sealFlowsForTransportGap`, and the `'transport-gap'` + interruption; + - the full suite passes 205/205; + - two mutations are caught (all gaps emitted first; a stopped turn + sealed too). +- Ruling (superseded an earlier draft that held gaps per session and reseeded them over an IPC like conditions; the Change section now describes the result): main's + `TransportGapLedger` is keyed by the provider CONVERSATION id, and the + records ride `session:load-initial-history` (`SessionHistoryChunk.transportGaps`). + - Every feed rebuild goes through that load, whether the window reloads, + the agent reloads or respawns after a crash, or the conversation is + resumed in another pane, and whether the pane is live or not. + Conditions-style reseeding would only cover live backends. + - A new conversation in the same pane (Claude /clear) does not inherit + the old row. + - With no conversation id yet, the row is live-only. This can't happen + in practice: a gap needs >= 1 GiB of the session's traffic. + - Cost if wrong: none found. It is one optional chunk field. +- Ruling: no new RenderOwner (the model says adding one needs plan review). + - The row is a `provider-notice`-owner candidate with contentKind + `transport-gap`, riding the notice candidates, so the ledger input + keeps its shape and ordering follows the notice contract ("status + follows equal-time conversation"). + - It is placed at `since ?? until`. A gap older than every loaded entry + shows at the top of the window instead of being withheld. +- Ruling: the phone (remote client) relays the channel but does not paint + the row yet. The phone keeps its own TranscriptStore, and wiring it is a + separate surface. Follow-up issue to file. +- Ruling: the time text uses 24-hour HH:MM:SS from Date getters, not + `toLocaleTimeString`, so the one visible sentence can be tested. +- Tests: package 205/205. App: + - ClaudeSession seal order; + - SessionManager: incident, record, held for the conversation across a + respawn, not following /clear; + - the ledger bounds; + - the end-to-end row (real adapter → fold → ledger → view bridge): + placement, persistence after later turns, the rebuild merge, and the + sentence; + - the loader restoring gaps into a rebuilt runtime; + - the live subscription. + - Mutations caught: bridge drop, missing candidates, the fold dropping + the interruption, history ingest dropping the gaps. + +## Hold lifted: pointer bumped to claude-code-headless#69's merge (0928344e) +- #69 merged after its own round 1. Reviewers a and b found real defects, fixed fail-first there: + - `since` is now the previous poll's START time, a true lower bound; + - a request-only flow is kept until its first post-gap chunk, and streams if that chunk opens with `message_start`; + - the phase owner is sealed last, so a concurrent seal no longer clears an `awaiting-tool` phase. +- The app calls only `sealFlowsForTransportGap()`, whose signature did not change, so no app code changes. +- No lockfile resync: `package.json` and `package-lock.json` have no `claude-code-headless` entry (the app resolves it through a path alias), and the package's own manifests are unchanged. +- `npx tsc -b` is clean. The Claude provider, main sessions and renderer workspace-hook suites pass 739/739. + +## Review round 1, reviewer a (FIX-BEFORE-MERGE), each fix fail-first +- **Blocker: the durable row was never kept for a real Claude session.** + - Cause: `getNativeConversationId` asks `session.getProviderSessionId()`, which `ClaudeSession` does not implement. The manager test's fake implemented it, so every real gap took the live-only path and vanished on the first reload. + - Fix: `SessionManager.claudeConversationIds` records the `sessionId` of every committed Claude JSONL entry. That is the same value the renderer sends back as `providerSessionId`, and it follows `/clear`. The resume id is the fallback before the respawned tailer emits anything. The map is cleared at teardown. + - Ruling: do not add `getProviderSessionId` to `ClaudeSession`. It would change what backend snapshots and spawn results report for every Claude pane. Cost if wrong: a second place knows the Claude conversation id. + - The test fake now matches the real session: no getter, only transcript entries. +- **Major: two gaps of one poll shared a live-only id.** `gap-live--` collided, because one poll has one `until`, and the renderer's id merge hid a lost span. Live-only ids are now a sequence. +- Mutations, each red: + - no entry capture (2); + - no resume fallback; + - an id built from `until`; + - no teardown clear. +- The spawn-time clear I added first was redundant with teardown (its mutation survived), so it was removed. + +## Review round 1, reviewer b (MERGE-READY), minors fixed fail-first +- **The live feed had no cap; main keeps 50.** An open pane painted every gap, while the same pane after a reload painted main's newest 50. `TRANSPORT_GAPS_PER_CONVERSATION` now lives in `@shared/types/session`; the ledger and `mergeTransportGaps` both keep the newest that many. Removing the renderer cap: 1 red. +- **Survivor: the tap's flush before a transport-gap row.** Now pinned in `sessionFeedTap.test.ts`: a buffered semantic event reaches every sink before the row. Removing the flush: 1 red. +- **Suspicions left as stated:** + - a `history-boundary` reset keeps `transportGaps`; no path was found where a Claude pane switches conversation in place and gets that reset; + - a gap before the first committed entry is keyed by the resume id when there is one, and is otherwise live-only. The 1 GiB threshold makes it unreachable in practice, and b's 215-fixture count found no counterexample. + +## Review round 1, reviewer c (FIX-BEFORE-MERGE), each fixed fail-first +- **Major: the IPC delivery of the rows was untested.** `session:load-initial-history`'s `transportGaps` attach is the one link from main to a rebuilt feed. `session.test.ts` now drives it through the real preload call, with a real `TransportGapLedger`: the chunk carries the conversation's rows, and a conversation that lost nothing gets the chunk with no key. Returning the plain chunk: 1 red. +- **Minor: the notice-cache invalidation on the gaps slice.** A reused adapter (one per pane, as `useLedgerFeedItems` keeps it) must paint a gap that arrives alone. Dropping the gaps identity check: 1 red. +- **Minor: the real row was never rendered.** `Feed.transportGap.renderer.test.tsx` renders the real `Feed` with the ledger's items and finds the sentence. `return null` for the row: 1 red. +- **Minor: stale `since` docs.** `TransportGapRecord`'s doc and decision 5 now say the previous poll's START. +- **Already fixed in 5d4502d2 (review b):** the tap flush pin and the shared renderer cap. +- **Residuals, stated:** + - `mergeTransportGaps`' sort: a near-equivalent mutant, because the feed re-derives order from timestamps; + - the package's forgotten no-turn flow (P7): a package test gap in merged claude-code-headless#69, not this PR's code; + - c's three suspicions, which c did not rank above notes. + +## Merge of origin/main after #1450 (a real conflict) +- `initialHistory.ts`: both branches changed the same import line (`mergeTransportGaps` here, `worktreesForAttribution` from #1450). Both kept. +- `initialHistory.renderer.test.tsx`: both added a `describe` block at the end. Both kept. +- Main's `claude-code-headless` is `1cfa8c92` (#68's merge), an ancestor of this PR's `0928344e`, so the bump is a fast-forward. +- `npx tsc -b` clean. The workspace, feed, session-runtime, rendering, main-session and Claude suites pass 2315/2315. diff --git a/packages/claude-code-headless b/packages/claude-code-headless index 1cfa8c92b..0928344ea 160000 --- a/packages/claude-code-headless +++ b/packages/claude-code-headless @@ -1 +1 @@ -Subproject commit 1cfa8c92b70e15c860e07e26d3db94a3e0c652e7 +Subproject commit 0928344ea670249165a79ead2c4b47190da004ca diff --git a/src/main/ipc/session.test.ts b/src/main/ipc/session.test.ts index ebaab076b..8467e9bc3 100644 --- a/src/main/ipc/session.test.ts +++ b/src/main/ipc/session.test.ts @@ -28,6 +28,18 @@ vi.mock('@main/window/windowRegistry.js', () => ({ })) // The sub-agent watcher polls real directories; nothing here is about fleets. +// #1442 review c: the durable gap rows cross main -> renderer on `session:load-initial-history`, +// and nothing pinned that handler. The transcript read itself is historyLoader's (tested there); +// here it returns one fixed chunk so the test is about what the handler adds to it. +const history = vi.hoisted(() => ({ chunk: null as null | Record })) +vi.mock('@main/sessions/historyLoader.js', async importOriginal => { + const actual = await importOriginal() + return { + ...actual, + loadInitialHistoryChunk: async (...args: Parameters) => + history.chunk ? { ...history.chunk } : actual.loadInitialHistoryChunk(...args), + } +}) vi.mock('@main/subagents/index.js', () => ({ SubAgentWatcherManager: class { observeParentEntry() {} stop() {} stopAll() {} } })) const { registerSessionIpc, classifySpawnFailure } = await import('./session.js') @@ -73,6 +85,27 @@ it('transports generated-task draft protection from preload through main without expect(deliverPromptToAgent).toHaveBeenCalledExactlyOnceWith('s1', 'Restart the server', undefined, undefined, undefined, { requireEmptyNativeComposer: true }) }) +describe('a conversation\'s transport-gap rows on the initial history chunk (#1381)', () => { + it('rides the chunk for the conversation that lost data, and only for it', async () => { + const { TransportGapLedger } = await import('@main/sessions/transportGapLedger.js') + const ledger = new TransportGapLedger() + const held = ledger.record('conv-1', { since: 1_000, until: 2_000, lostGenerations: 1 }) + history.chunk = { entries: [{ type: 'user' }], hasMore: false } + try { + registerSessionIpc({ getTransportGaps: (id: string) => ledger.list(id) } as never, {} as never, { flushCommitted: () => {} }) + // Through the real preload call, as a feed rebuild makes it. + const withGap = await sessionApi.loadInitialHistory({ kind: 'claude', cwd: '/p', providerSessionId: 'conv-1' }) + expect(withGap).toEqual({ entries: [{ type: 'user' }], hasMore: false, transportGaps: [held] }) + // A conversation that lost nothing gets the chunk byte-identical, with no key at all. + const without = await sessionApi.loadInitialHistory({ kind: 'claude', cwd: '/p', providerSessionId: 'conv-2' }) + expect(without).toEqual({ entries: [{ type: 'user' }], hasMore: false }) + expect('transportGaps' in without).toBe(false) + } finally { + history.chunk = null + } + }) +}) + describe('recovered renderer screen seed', () => { it.each([ { ok: true, destroyed: false, available: true, sends: 1 }, diff --git a/src/main/ipc/session.ts b/src/main/ipc/session.ts index 94c137474..33702a28d 100644 --- a/src/main/ipc/session.ts +++ b/src/main/ipc/session.ts @@ -584,10 +584,16 @@ export function registerSessionIpc( limit?: number }, ) => { - return await loadInitialHistoryChunk({ + const chunk = await loadInitialHistoryChunk({ ...params, limit: params.limit ?? 120, }) + // #1381: the conversation's durable "not captured" rows ride the initial chunk, the one + // request every feed rebuild makes (window reload, agent reload, resume elsewhere). Omitted + // when there are none, so the chunk is byte-identical for every conversation that lost + // nothing. + const transportGaps = manager.getTransportGaps(params.providerSessionId) + return transportGaps.length > 0 ? { ...chunk, transportGaps: [...transportGaps] } : chunk }, ) diff --git a/src/main/remote/protocol/messages.ts b/src/main/remote/protocol/messages.ts index a5edc6a2a..51169bd3a 100644 --- a/src/main/remote/protocol/messages.ts +++ b/src/main/remote/protocol/messages.ts @@ -242,6 +242,9 @@ export type OutboundFrame = // every other v2 frame: a phone bundle that predates them finds no // listener set for the channel and drops the frame. | 'transcript-diagnostic' + // #1381: a durable "not captured" row's record. Additive, dropped by + // phone bundles with no listener. + | 'transport-gap' | 'provider-session-changed' | 'semantic-event' | 'conditions' diff --git a/src/main/sessionManager.proxyGap.test.ts b/src/main/sessionManager.proxyGap.test.ts index 2be1d10fd..26372fb61 100644 --- a/src/main/sessionManager.proxyGap.test.ts +++ b/src/main/sessionManager.proxyGap.test.ts @@ -47,7 +47,17 @@ vi.mock('@main/storage/feedDebugLog.js', () => ({ forgetFeedDebugSession: vi.fn(), })) +// Shaped like the REAL ClaudeSession (#1442 review a): it has NO getProviderSessionId. An earlier +// fake implemented one, so every test passed while a real Claude gap always took the live-only +// path and its row vanished on the first reload. Claude announces its conversation only through +// its transcript: every JSONL entry carries `sessionId`, and the file is `.jsonl`. That +// is also where the renderer takes the pane's providerSessionId from. class FakeAgentSession extends EventEmitter { + /** A committed transcript entry of `conversationId`, as the Claude tailer emits it. */ + entry(conversationId: string): void { + this.emit('jsonl-entry', { type: 'user', sessionId: conversationId, uuid: `u-${conversationId}-${this.listenerCount('jsonl-entry')}` }, `/home/.claude/projects/p/${conversationId}.jsonl`) + } + async start(): Promise { this.emit('started', { projectDir: '/tmp/project' }) } @@ -78,17 +88,95 @@ describe('a Claude proxy transport gap', () => { manager.on('proxy-transport-gap', gap => { gaps.push(gap) }) await manager.recover({ sessionId: 's1', kind: 'claude', cwd: '/tmp/project' }) - session.emit('proxy-transport-gap', { lostGenerations: 3 }) + session.emit('proxy-transport-gap', { lostGenerations: 3, since: 1_000, until: 9_000 }) - expect(gaps).toEqual([{ sessionId: 's1', lostGenerations: 3 }]) + const record = { id: expect.any(String), since: 1_000, until: 9_000, lostGenerations: 3 } + expect(gaps).toEqual([{ sessionId: 's1', gap: record }]) expect(incidents).toContainEqual(expect.objectContaining({ kind: 'claude.proxy_transport_gap', - context: { sessionId: 's1', lostGenerations: 3 }, + context: { sessionId: 's1', lostGenerations: 3, since: 1_000, until: 9_000 }, })) }) + // #1381 option B (owner-approved by B6): the gap is a DURABLE feed row. A renderer that reloads + // rebuilds its feed from main, and an agent reload respawns under the same id — the record must + // survive both, which is why it is not one of the caches that die with the process. + it('is held for its conversation, surviving the respawn an agent reload does', async () => { + const { SessionManager } = await import('./sessionManager') + const first = new FakeAgentSession() + const second = new FakeAgentSession() + createSession.mockImplementationOnce(() => first).mockImplementationOnce(() => second) + const journal = { recordIncident: vi.fn(), record: vi.fn(), recordError: vi.fn() } + const manager = new SessionManager(null, null, journal as never) + + expect(manager.getTransportGaps('conv-1')).toEqual([]) + await manager.recover({ sessionId: 's1', kind: 'claude', cwd: '/tmp/project' }) + first.entry('conv-1') + first.emit('proxy-transport-gap', { lostGenerations: 1, since: null, until: 5_000 }) + first.emit('exit', { exitCode: 0 }) + // An agent reload resumes the same conversation (`--resume conv-1`). The gap can land before + // the respawned tailer has emitted any entry: the resume id is the conversation then. + await manager.recover({ sessionId: 's1', kind: 'claude', cwd: '/tmp/project', resumeSessionId: 'conv-1' }) + second.emit('proxy-transport-gap', { lostGenerations: 2, since: 6_000, until: 7_000 }) + + const held = manager.getTransportGaps('conv-1') + expect(held.map(gap => [gap.since, gap.until, gap.lostGenerations])).toEqual([[null, 5_000, 1], [6_000, 7_000, 2]]) + expect(new Set(held.map(gap => gap.id)).size).toBe(2) + expect(manager.getTransportGaps('s1')).toEqual([]) + }) + + // A gap is a fact about one conversation. A pane that moves to a new conversation (Claude /clear) + // must not carry the old conversation's row into it. + it('does not follow the pane into a new conversation', async () => { + const { SessionManager } = await import('./sessionManager') + const session = new FakeAgentSession() + createSession.mockImplementationOnce(() => session) + const manager = new SessionManager(null, null, { recordIncident: vi.fn(), record: vi.fn(), recordError: vi.fn() } as never) + await manager.recover({ sessionId: 's1', kind: 'claude', cwd: '/tmp/project' }) + session.entry('conv-1') + session.emit('proxy-transport-gap', { lostGenerations: 1, since: 1, until: 2 }) + // Claude /clear: the same process starts writing a new conversation's transcript. + session.entry('conv-2') + session.emit('proxy-transport-gap', { lostGenerations: 1, since: 3, until: 4 }) + expect(manager.getTransportGaps('conv-1').map(gap => gap.until)).toEqual([2]) + expect(manager.getTransportGaps('conv-2').map(gap => gap.until)).toEqual([4]) + }) + // Focused review of #1376 (c): a replaced session's late gap must not be recorded against the // session id its successor now owns. + // A fresh conversation spawned into the same pane (no resume) must not inherit the previous + // process's conversation before its own transcript has said anything. + it('does not key a fresh spawn\'s gap to the pane\'s previous conversation', async () => { + const { SessionManager } = await import('./sessionManager') + const first = new FakeAgentSession() + const second = new FakeAgentSession() + createSession.mockImplementationOnce(() => first).mockImplementationOnce(() => second) + const manager = new SessionManager(null, null, { recordIncident: vi.fn(), record: vi.fn(), recordError: vi.fn() } as never) + await manager.recover({ sessionId: 's1', kind: 'claude', cwd: '/tmp/project' }) + first.entry('conv-1') + first.emit('exit', { exitCode: 0 }) + await manager.recover({ sessionId: 's1', kind: 'claude', cwd: '/tmp/project' }) + second.emit('proxy-transport-gap', { lostGenerations: 1, since: 1, until: 2 }) + expect(manager.getTransportGaps('conv-1')).toEqual([]) + }) + + // #1442 review a: two lost spans in ONE poll share the poll's `until`. With no conversation id + // yet, the live-only ids were `gap-live--`, so the renderer's id merge kept one + // row and hid the other lost span. + it('gives two live-only gaps of one poll distinct ids', async () => { + const { SessionManager } = await import('./sessionManager') + const session = new FakeAgentSession() + createSession.mockImplementationOnce(() => session) + const manager = new SessionManager(null, null, { recordIncident: vi.fn(), record: vi.fn(), recordError: vi.fn() } as never) + const ids: string[] = [] + manager.on('proxy-transport-gap', ({ gap }: { gap: { id: string } }) => { ids.push(gap.id) }) + await manager.recover({ sessionId: 's1', kind: 'claude', cwd: '/tmp/project' }) + session.emit('proxy-transport-gap', { lostGenerations: 1, since: 1_000, until: 1_234 }) + session.emit('proxy-transport-gap', { lostGenerations: 1, since: 1_000, until: 1_234 }) + expect(ids).toHaveLength(2) + expect(new Set(ids).size).toBe(2) + }) + it('ignores a gap from a session that has been replaced', async () => { const { SessionManager } = await import('./sessionManager') const first = new FakeAgentSession() diff --git a/src/main/sessionManager.ts b/src/main/sessionManager.ts index d4019a441..b45d5b31a 100644 --- a/src/main/sessionManager.ts +++ b/src/main/sessionManager.ts @@ -51,6 +51,10 @@ import { updateToolPaths } from '@main/setup/setupState.js' import { forgetFeedDebugSession } from '@main/storage/feedDebugLog.js' import { TerminalReplayBuffer } from '@main/sessions/terminalReplayBuffer.js' import { ScreenFrameGate } from '@main/sessions/screenFrameGate.js' +import { TransportGapLedger } from '@main/sessions/transportGapLedger.js' +import type { SessionTransportGapEvent } from '@shared/sessionFeed/types.js' +import type { TransportGapRecord } from '@shared/types/session.js' +import type { TransportGap } from 'claude-code-headless' import type { ConditionCustomAction, ProviderConditionSnapshot, @@ -195,7 +199,8 @@ type ManagerEvents = { observation?: AgentTranscriptObservationMetadata }] 'jsonl-error': [{ sessionId: string; error: Error }] - 'proxy-transport-gap': [{ sessionId: string; lostGenerations: number }] + /** A durable feed row's record (#1381); also held for reseeds (getTransportGaps). */ + 'proxy-transport-gap': [SessionTransportGapEvent] /** Durable-history generation boundary (grok). Never completion or idle; * consumers apply renderer/session-runtime/historyBoundary.ts decisions. */ 'history-boundary': [{ sessionId: string; type: 'reset' | 'caught-up'; generation: number; snapshotByteLength: number; byteOffset?: number; complete?: boolean; file: string }] @@ -572,7 +577,7 @@ export type ResolveConditionResult = /** The one Claude-only event SessionManager subscribes to (ClaudeSessionEvents declares it). */ type ProxyGapSource = { - on(event: 'proxy-transport-gap', listener: (gap: { lostGenerations: number }) => void): unknown + on(event: 'proxy-transport-gap', listener: (gap: TransportGap) => void): unknown } export class SessionManager extends EventEmitter { @@ -665,6 +670,10 @@ export class SessionManager extends EventEmitter { // See screenFrameGate.ts — drops spinner-only repaints before they fan out. private readonly screenFrameGate = new ScreenFrameGate() private readonly lastConditionsSnapshot = new Map() + // #1381: NOT one of the generation-owned caches above — the durable gap rows + // must survive the respawn an agent reload does under the same id. See + // TransportGapLedger for the lifetime and bounds. + private readonly transportGaps = new TransportGapLedger() private readonly lastInputReadiness = new Map() // WHY this is one manager-global sequence instead of a bounded per-id map: // a persisted pane may reuse its stable local id after arbitrarily many @@ -691,6 +700,27 @@ export class SessionManager extends EventEmitter { // every history chunk, and the phone's TranscriptStore discards a chunk // whose file disagrees with the file its live frames carry. private readonly lastTranscriptFile = new Map() + /** + * The Claude conversation each live Claude session is writing (#1381, #1442 review a), from the + * `sessionId` every committed JSONL entry carries. It is the key TransportGapLedger holds a gap + * under, and the renderer sends the SAME value back as `providerSessionId` when it rebuilds a + * feed (it captures it from the same entries). + * + * WHY a map here and not `getProviderSessionId()`: ClaudeSession does not implement that getter, + * and adding it would change what backend snapshots and spawn results report for every Claude + * pane, well outside this fix. The first version relied on the getter anyway; its test fake + * implemented it, so every real Claude gap silently took the live-only path and its row was gone + * after the first reload. + * + * WHY entries and not the spawn's `--session-id`/`--resume` id alone: a `/clear` moves the same + * process to a new conversation, and only its transcript says so. The resume id is only the + * fallback before the respawned tailer has emitted anything. Cleared at teardown with the other + * per-process caches, so a fresh conversation spawned into the same pane never inherits the + * previous one's key (pinned in sessionManager.proxyGap.test.ts). + */ + private readonly claudeConversationIds = new Map() + /** Distinct ids for live-only gap rows (see the proxy-transport-gap handler). */ + private liveTransportGapSequence = 0 private readonly codexCandidateObservationEdges = new Map< string, { @@ -1123,6 +1153,7 @@ export class SessionManager extends EventEmitter { this.screenFrameGate.forget(sessionId) this.lastConditionsSnapshot.delete(sessionId) this.lastTranscriptFile.delete(sessionId) + this.claudeConversationIds.delete(sessionId) this.codexCandidateObservationEdges.delete(sessionId) this.codexAttachmentObservationState.delete(sessionId) this.lastInputReadiness.delete(sessionId) @@ -3315,6 +3346,10 @@ export class SessionManager extends EventEmitter { if (!ownsEntry()) return this.markActivity(sessionId) this.lastTranscriptFile.set(sessionId, file) + if (kind === 'claude') { + const conversationId = (entry as { sessionId?: unknown } | null)?.sessionId + if (typeof conversationId === 'string' && conversationId) this.claudeConversationIds.set(sessionId, conversationId) + } if (kind === 'codex' && observation) { const rollout = entry && typeof entry === 'object' ? entry as unknown as Record @@ -3385,15 +3420,31 @@ export class SessionManager extends EventEmitter { // hole in the live Claude feed is never silent. // Claude-only (its ClaudeSessionEvents declares it; other providers have no proxy tail), so it // is subscribed through that type rather than widening every provider's event map. - if (kind === 'claude') (session as unknown as ProxyGapSource).on('proxy-transport-gap', (gap: { lostGenerations: number }) => { + // #1381: and held as a durable feed row (option B, owner-approved by B6): the event carries + // the record, and getTransportGaps answers a renderer that reloads and rebuilds its feed. + if (kind === 'claude') (session as unknown as ProxyGapSource).on('proxy-transport-gap', (gap: TransportGap) => { if (!ownsEntry()) return this.journal?.recordIncident({ kind: 'claude.proxy_transport_gap', severity: 'warn', reason: 'events_deleted_unread', - context: { sessionId, lostGenerations: gap.lostGenerations }, + context: { sessionId, lostGenerations: gap.lostGenerations, since: gap.since, until: gap.until }, }) - this.emit('proxy-transport-gap', { sessionId, lostGenerations: gap.lostGenerations }) + const fields = { since: gap.since, until: gap.until, lostGenerations: gap.lostGenerations } + // Held per CONVERSATION (see TransportGapLedger and claudeConversationIds). With no + // conversation id yet the row is live-only: there is no history to rebuild it from. In + // practice the id is known, since a gap needs >= 1 GiB of this session's proxy traffic, + // long after its transcript exists. + const conversationId = this.getNativeConversationId(sessionId) + ?? this.claudeConversationIds.get(sessionId) + ?? this.spawnInfo.get(sessionId)?.resumeSessionId + ?? null + // A live-only id is a sequence, never `until`: two lost spans of one poll share the + // poll's `until`, and the renderer merges rows by id (#1442 review a). + const record = conversationId + ? this.transportGaps.record(conversationId, fields) + : { id: `gap-live-${++this.liveTransportGapSequence}`, ...fields } + this.emit('proxy-transport-gap', { sessionId, gap: record }) }) session.on('transcript-diagnostic', (diagnostic: unknown) => { if (!ownsEntry()) return @@ -5760,6 +5811,14 @@ export class SessionManager extends EventEmitter { return this.lastScreenSnapshot.get(sessionId) ?? null } + /** Every proxy-transport gap still held for this provider conversation + * (#1381), oldest first, for a feed being rebuilt from its history. Empty + * when it never lost any — an honest answer, because the ledger is the only + * record main keeps and nothing but its bounds ever drops one. */ + getTransportGaps(conversationId: string): readonly TransportGapRecord[] { + return this.transportGaps.list(conversationId) + } + getProcessStateSnapshot(sessionId: string): AgentProcessState | null { return this.lastProcessState.get(sessionId) ?? null } diff --git a/src/main/sessions/sessionFeedTap.test.ts b/src/main/sessions/sessionFeedTap.test.ts index e0b41cdec..cb76c72f1 100644 --- a/src/main/sessions/sessionFeedTap.test.ts +++ b/src/main/sessions/sessionFeedTap.test.ts @@ -100,6 +100,20 @@ describe('SessionFeedTap', () => { expect(remote).toEqual(expected) }) + // #1442 review b: the transport-gap row follows the seal it marks. The seal's semantic events + // may still sit in the coalescing window, so the tap flushes them first; dropping that flush left + // every test green while a sink could learn where the row goes before the turn was cut. + it('delivers buffered semantic events before a transport-gap row', () => { + const { manager, tap } = makeTap() + const seen = record(tap) + manager.emit('semantic-event', textDelta('half an answ')) + manager.emit('proxy-transport-gap', { sessionId: 'pane', gap: { id: 'gap-1', since: 1, until: 2, lostGenerations: 1 } }) + expect(seen).toEqual([ + { channel: 'semantic-event', kind: 'text_delta' }, + { channel: 'transport-gap', kind: '' }, + ]) + }) + it('keeps a throwing sink from costing another sink its delivery, and raises where a listener would', () => { // One coalescer flush fans out to every sink in one loop. A remote failure // must not strand the desktop's copy, and it must surface where a diff --git a/src/main/sessions/sessionFeedTap.ts b/src/main/sessions/sessionFeedTap.ts index 7ecbf8dcf..64704fbe9 100644 --- a/src/main/sessions/sessionFeedTap.ts +++ b/src/main/sessions/sessionFeedTap.ts @@ -63,6 +63,7 @@ export type SessionFeedTapChannel = | 'jsonl-error' | 'history-boundary' | 'transcript-diagnostic' + | 'transport-gap' | 'provider-session-changed' | 'semantic-event' | 'conditions' @@ -218,6 +219,15 @@ export class SessionFeedTap { this.emit('history-boundary', payload) }) on('transcript-diagnostic', payload => this.emit('transcript-diagnostic', payload)) + // #1381: a durable feed row. The seal it follows (turn_stopped with + // interruption 'transport-gap') is a semantic event still in the 100 ms + // window, so flush that first: every sink then learns the turn was cut + // before it learns where the row goes. Never coalesced: each record is a + // row of its own, not state to keep current. + on('proxy-transport-gap', payload => { + this.semanticEvents.flush(payload.sessionId) + this.emit('transport-gap', payload) + }) on('provider-session-changed', payload => { // An ordering fact like history-boundary: rows of the OLD session still // buffered must land before the identity moves, so both windows flush diff --git a/src/main/sessions/transportGapLedger.test.ts b/src/main/sessions/transportGapLedger.test.ts new file mode 100644 index 000000000..47066c6d7 --- /dev/null +++ b/src/main/sessions/transportGapLedger.test.ts @@ -0,0 +1,36 @@ +import { describe, expect, it } from 'vitest' + +import { PER_CONVERSATION_CAP, CONVERSATION_CAP, TransportGapLedger } from './transportGapLedger.js' + +// #1381: the durable gap rows' store. Bounded so a closed pane's records (kept +// on purpose, see the module comment) can never grow without limit. +describe('TransportGapLedger', () => { + it('keeps each session\'s records in order with ids unique across sessions', () => { + const ledger = new TransportGapLedger() + const a = ledger.record('s1', { since: 1, until: 2, lostGenerations: 1 }) + const b = ledger.record('s2', { since: 3, until: 4, lostGenerations: 1 }) + const c = ledger.record('s1', { since: 5, until: 6, lostGenerations: 2 }) + expect(ledger.list('s1')).toEqual([a, c]) + expect(ledger.list('s2')).toEqual([b]) + expect(new Set([a.id, b.id, c.id]).size).toBe(3) + }) + + it('keeps only the newest records of one session', () => { + const ledger = new TransportGapLedger() + for (let i = 0; i < PER_CONVERSATION_CAP + 3; i += 1) ledger.record('s1', { since: i, until: i + 1, lostGenerations: 1 }) + const held = ledger.list('s1') + expect(held).toHaveLength(PER_CONVERSATION_CAP) + expect(held[0]!.since).toBe(3) + }) + + it('evicts the session that recorded least recently past the session cap', () => { + const ledger = new TransportGapLedger() + for (let i = 0; i < CONVERSATION_CAP; i += 1) ledger.record(`s${i}`, { since: i, until: i, lostGenerations: 1 }) + // s0 records again, so s1 is now the least recent and is the one evicted. + ledger.record('s0', { since: 0, until: 0, lostGenerations: 1 }) + ledger.record('new', { since: 0, until: 0, lostGenerations: 1 }) + expect(ledger.list('s0')).toHaveLength(2) + expect(ledger.list('s1')).toEqual([]) + expect(ledger.list('new')).toHaveLength(1) + }) +}) diff --git a/src/main/sessions/transportGapLedger.ts b/src/main/sessions/transportGapLedger.ts new file mode 100644 index 000000000..d4e3d62f9 --- /dev/null +++ b/src/main/sessions/transportGapLedger.ts @@ -0,0 +1,67 @@ +import { TRANSPORT_GAPS_PER_CONVERSATION, type TransportGapRecord } from '@shared/types/session.js' + +/** + * Where main keeps the proxy-transport gaps a session's feed must show (#1381). + * + * WHY main holds them at all: the owner-approved call (B6 proxy, 2026-09-27, + * option B) is a feed-HISTORY row — "data loss is never hidden" — bounded to + * the main-process lifetime (q119/q120; restart durability is #1445). The + * renderer's semantic state is rebuilt on every window reload, and the gap is + * not in the transcript (the JSONL is Claude's own file and never saw our + * transport), so the only process that outlives a renderer reload and saw the + * gap is main. It hands them out with the conversation's initial history chunk + * (`session:load-initial-history`), which every feed rebuild goes through. + * + * WHY in memory and not on disk: the approved lifetime is this app run. + * Surviving a restart needs a dedicated store, which is an owner decision + * (#1445, related to the #1235 ghost-log removal). The always-on + * `claude.proxy_transport_gap` incident is a DIAGNOSTIC copy only — B6 (q120) + * ruled it must not be used to reseed rows (forensic, lossy, no conversation + * id). + * + * WHY keyed by the provider CONVERSATION id (Claude's session id), not the + * pane: a gap is a fact about what we saw of that conversation. Keyed so, the + * row comes back wherever the conversation's feed is rebuilt — a window + * reload, an agent reload or crash respawn (same conversation), a resume in + * another pane — and it does NOT follow a pane into a new conversation (a + * Claude /clear), where its time position would sit among unrelated rows. + * Never cleared by the session's process for the same reason; a finished + * conversation's handful of records lingers until quit, and the bounds below + * keep that finite. + * + * Bounds: the newest PER_CONVERSATION_CAP gaps per conversation (a gap needs + * >= 1 GiB of proxy traffic through a stalled poller, so even the cap is far + * beyond a real conversation), and CONVERSATION_CAP conversations, evicting the one + * that recorded least recently. + */ +// Shared with the renderer's merge so a live and a rebuilt feed paint the same rows. +export const PER_CONVERSATION_CAP = TRANSPORT_GAPS_PER_CONVERSATION +export const CONVERSATION_CAP = 500 + +export class TransportGapLedger { + // Map iteration order is insertion order; a conversation is re-inserted on + // every record, so the first key is always the least recently recorded. + private readonly byConversation = new Map() + // One sequence for the whole ledger, so ids are unique across conversations: + // the renderer de-duplicates a history rebuild's records against the live + // ones it already holds by id. + private sequence = 0 + + record(conversationId: string, gap: Omit): TransportGapRecord { + this.sequence += 1 + const entry: TransportGapRecord = { id: `gap-${this.sequence}`, ...gap } + const list = this.byConversation.get(conversationId) ?? [] + this.byConversation.delete(conversationId) + const next = [...list, entry].slice(-PER_CONVERSATION_CAP) + this.byConversation.set(conversationId, next) + if (this.byConversation.size > CONVERSATION_CAP) { + const oldest = this.byConversation.keys().next().value + if (oldest !== undefined) this.byConversation.delete(oldest) + } + return entry + } + + list(conversationId: string): readonly TransportGapRecord[] { + return this.byConversation.get(conversationId) ?? [] + } +} diff --git a/src/preload/api/session.ts b/src/preload/api/session.ts index 659e94f5e..8b8ad43b0 100644 --- a/src/preload/api/session.ts +++ b/src/preload/api/session.ts @@ -16,6 +16,7 @@ import type { SessionJsonlEntriesEvent, SessionJsonlErrorEvent, SessionTranscriptDiagnosticEvent, + SessionTransportGapEvent, SessionAgentPtyDataEvent, SessionScreenEvent, SessionSemanticEvent, @@ -259,6 +260,8 @@ export const sessionApi = { * say so. */ onSessionTranscriptDiagnostic: (cb: (e: SessionTranscriptDiagnosticEvent) => void): Unsub => subscribe('session:transcript-diagnostic', cb), + onSessionTransportGap: (cb: (e: SessionTransportGapEvent) => void): Unsub => + subscribe('session:transport-gap', cb), /** Raw PTY bytes for terminal sessions. Claude sessions do NOT * emit on this channel — they use screen/jsonl-entry instead. */ diff --git a/src/preload/api/types.ts b/src/preload/api/types.ts index 3b55b05bb..8c98f3d2d 100644 --- a/src/preload/api/types.ts +++ b/src/preload/api/types.ts @@ -64,6 +64,7 @@ export type { SessionJsonlEntriesEvent, SessionJsonlErrorEvent, SessionTranscriptDiagnosticEvent, + SessionTransportGapEvent, SessionConditionsEvent, SessionProcessStateEvent, SubAgentToolCall, diff --git a/src/providers/claude/runtime/claudeSession.suspension.test.ts b/src/providers/claude/runtime/claudeSession.suspension.test.ts index 46d42f896..41af9cf7b 100644 --- a/src/providers/claude/runtime/claudeSession.suspension.test.ts +++ b/src/providers/claude/runtime/claudeSession.suspension.test.ts @@ -62,6 +62,7 @@ describe('ClaudeSession proxy wiring', () => { const session = new ClaudeSession() const proxy = new EventEmitter() const handleProxyTransportEvent = vi.fn() + const sealFlowsForTransportGap = vi.fn() const internals = session as unknown as { proxyServer: unknown headless: unknown @@ -69,9 +70,9 @@ describe('ClaudeSession proxy wiring', () => { detachProxyServer(): void } internals.proxyServer = proxy - internals.headless = { handleProxyTransportEvent } + internals.headless = { handleProxyTransportEvent, proxy: { sealFlowsForTransportGap } } internals.attachProxyServer() - return { session, proxy, handleProxyTransportEvent, detach: () => internals.detachProxyServer() } + return { session, proxy, handleProxyTransportEvent, sealFlowsForTransportGap, detach: () => internals.detachProxyServer() } } it('forwards every proxy event to the adapter', () => { @@ -85,8 +86,24 @@ describe('ClaudeSession proxy wiring', () => { const { session, proxy } = wired() const gaps: unknown[] = [] session.on('proxy-transport-gap', gap => { gaps.push(gap) }) - proxy.emit('transport-gap', { lostGenerations: 2 }) - expect(gaps).toEqual([{ lostGenerations: 2 }]) + const gap = { lostGenerations: 2, since: 1_000, until: 5_000 } + proxy.emit('transport-gap', gap) + expect(gaps).toEqual([gap]) + }) + + // #1381: the flows that were streaming across the lost span are missing frames. The adapter is + // sealed at the gap's place in the event order — before the re-emit (SessionManager's durable + // row follows the seal) and before the next post-gap event reaches it. + it('seals the adapter at the gap, before the re-emit and before any post-gap event', () => { + const { session, proxy, handleProxyTransportEvent, sealFlowsForTransportGap } = wired() + const order: string[] = [] + sealFlowsForTransportGap.mockImplementation(() => { order.push('seal') }) + handleProxyTransportEvent.mockImplementation(() => { order.push('event') }) + session.on('proxy-transport-gap', () => { order.push('re-emit') }) + proxy.emit('event', { kind: 'response-chunk', flow_id: 1 }) + proxy.emit('transport-gap', { lostGenerations: 1, since: 1, until: 2 }) + proxy.emit('event', { kind: 'response-chunk', flow_id: 1 }) + expect(order).toEqual(['event', 'seal', 're-emit', 'event']) }) it('detaches both channels', () => { diff --git a/src/providers/claude/runtime/claudeSession.ts b/src/providers/claude/runtime/claudeSession.ts index eb6760db3..24dd3c4f3 100644 --- a/src/providers/claude/runtime/claudeSession.ts +++ b/src/providers/claude/runtime/claudeSession.ts @@ -39,6 +39,7 @@ import type { ProxyServer, ResumePromptState, SemanticEvent, + TransportGap, TrustDialogState, } from 'claude-code-headless' @@ -97,7 +98,7 @@ export type ClaudeSessionEvents = { // Declared for the provider-neutral AgentSession contract. Claude currently // emits no transcript-discovery diagnostics, so this event never fires. 'transcript-diagnostic': [unknown] - 'proxy-transport-gap': [{ lostGenerations: number }] + 'proxy-transport-gap': [TransportGap] // Optional status: the spinner verb ("Cogitating…", "Cascading…", // …) so the renderer can label its activity indicator with what CC // is actually doing rather than a generic "thinking…" placeholder. @@ -169,7 +170,7 @@ export class ClaudeSession extends EventEmitter { // proxy shutdown path drop the emitter isn't enough — the closure // captures `this.headless` and delays GC of the session object. private proxyEventHandler: ((ev: unknown) => void) | null = null - private proxyGapHandler: ((gap: { lostGenerations: number }) => void) | null = null + private proxyGapHandler: ((gap: TransportGap) => void) | null = null private exited = false /** Gate for the committed `tool_result` bridge. False until the * JSONL tailer's initial replay has quiesced (250 ms without a new @@ -1168,6 +1169,12 @@ export class ClaudeSession extends EventEmitter { * nothing but a main-process console line — the "every event exactly once, or an explicit gap" * contract stopped at the package boundary. The gap is re-emitted as `proxy-transport-gap`, which * SessionManager records as an always-on incident for this session. + * + * WHY the adapter is sealed FIRST (#1381): whatever was streaming across the lost span is + * missing frames, and the next `event` (the package now emits the gap exactly between the events + * written before and after the loss) would otherwise be stitched onto it. Sealing is synchronous, + * so the turn's `turn_stopped {interruption: 'transport-gap'}` is published before any post-gap + * event reaches the adapter, and before SessionManager records the durable feed row. */ private attachProxyServer(): void { if (!this.proxyServer) return @@ -1178,7 +1185,10 @@ export class ClaudeSession extends EventEmitter { >[0], ) } - this.proxyGapHandler = gap => { this.emit('proxy-transport-gap', gap) } + this.proxyGapHandler = gap => { + this.headless?.proxy?.sealFlowsForTransportGap() + this.emit('proxy-transport-gap', gap) + } this.proxyServer.on('event', this.proxyEventHandler) this.proxyServer.on('transport-gap', this.proxyGapHandler) } diff --git a/src/remote-client/src/WebSocketSessionFeed.ts b/src/remote-client/src/WebSocketSessionFeed.ts index 05ca018b5..2d048135b 100644 --- a/src/remote-client/src/WebSocketSessionFeed.ts +++ b/src/remote-client/src/WebSocketSessionFeed.ts @@ -7,6 +7,7 @@ import type { SessionJsonlEntriesEvent, SessionJsonlErrorEvent, SessionTranscriptDiagnosticEvent, + SessionTransportGapEvent, SessionInputReadinessEvent, SessionProcessStateEvent, SessionScreenEvent, @@ -117,6 +118,7 @@ export class WebSocketSessionFeed implements SessionFeed { 'jsonl-error': new Set(), 'history-boundary': new Set(), 'transcript-diagnostic': new Set(), + 'transport-gap': new Set(), 'provider-session-changed': new Set(), 'semantic-event': new Set(), conditions: new Set(), @@ -280,6 +282,13 @@ export class WebSocketSessionFeed implements SessionFeed { onSessionHistoryBoundary(cb: (e: SessionHistoryBoundaryEvent) => void): Unsub { return this.sub('history-boundary', cb) } + /** Relayed like every tap channel (#1381). The phone's TranscriptStore does + * not render the durable gap row yet — tracked as a follow-up — so a phone + * bundle subscribes nothing and the frame is dropped, as for any additive + * channel. */ + onSessionTransportGap(cb: (e: SessionTransportGapEvent) => void): Unsub { + return this.sub('transport-gap', cb) + } /** * Relayed since #1177 for the same reason as the diagnostic above; main * flushes the OLD session's buffered rows before it crosses (see diff --git a/src/remote-client/src/wire.ts b/src/remote-client/src/wire.ts index 820ec482b..f97e22c60 100644 --- a/src/remote-client/src/wire.ts +++ b/src/remote-client/src/wire.ts @@ -54,6 +54,9 @@ export type FeedChannel = // #1177: relayed since the phone sinks from the same main-side tap as the // desktop. Unknown to older desktops, which simply never send them. | 'transcript-diagnostic' + // #1381: a durable "not captured" row's record. The phone does not paint it + // yet (follow-up); older desktops never send it. + | 'transport-gap' | 'provider-session-changed' | 'semantic-event' | 'conditions' diff --git a/src/renderer/src/features/feed/ledger/ledgerFeedItems.test.ts b/src/renderer/src/features/feed/ledger/ledgerFeedItems.test.ts index 4bfc10648..210cad6b5 100644 --- a/src/renderer/src/features/feed/ledger/ledgerFeedItems.test.ts +++ b/src/renderer/src/features/feed/ledger/ledgerFeedItems.test.ts @@ -84,6 +84,8 @@ const shape = (i: FeedRenderItem): string => { switch (i.type) { case 'provider-notice': return `notice:${i.key}` + case 'transport-gap': + return `transport-gap:${i.key}` case 'entry': return `entry:${typeof i.entry.uuid === 'string' ? i.entry.uuid : '?'}` case 'absorbed-entry': diff --git a/src/renderer/src/features/feed/ledger/ledgerFeedItems.ts b/src/renderer/src/features/feed/ledger/ledgerFeedItems.ts index 9153c08da..e36e20d91 100644 --- a/src/renderer/src/features/feed/ledger/ledgerFeedItems.ts +++ b/src/renderer/src/features/feed/ledger/ledgerFeedItems.ts @@ -244,6 +244,12 @@ export function ledgerToFeedItems( sessionRunId: c.sessionRunId, order: orderAt(items.length, 'content') }) continue } + // #1381: a durable "not captured" row. Same shortcut as the notice above — + // the candidate carries the one validated record the row paints. + if (c.transportGap) { + items.push({ type: 'transport-gap', key: c.id, gap: c.transportGap, order: orderAt(items.length, 'content') }) + continue + } switch (c.sourcePlane) { case 'committed': case 'local-submit': diff --git a/src/renderer/src/features/feed/ledger/transportGapRow.test.ts b/src/renderer/src/features/feed/ledger/transportGapRow.test.ts new file mode 100644 index 000000000..a6079d3b2 --- /dev/null +++ b/src/renderer/src/features/feed/ledger/transportGapRow.test.ts @@ -0,0 +1,156 @@ +import { describe, expect, it } from 'vitest' + +import type { Entry } from '@shared/types/transcript' +import type { TransportGapRecord } from '@shared/types/session' +import { createLedgerInputAdapter, type RuntimeLedgerSlices } from '@renderer/rendering/adapter/collectLedgerInput' +import { createSessionLedger } from '@renderer/rendering/model/ledger' +import { ledgerToFeedItems } from '@renderer/features/feed/ledger/ledgerFeedItems' +import { ledgerFeedContextFromRuntime } from '@renderer/features/feed/ledger/ledgerFeedItems' +import { transportGapSentence } from '@renderer/features/feed/lib/transportGapText' +import { emptyRuntime, mergeTransportGaps, type SessionRuntime } from '@renderer/session-runtime/state' +import { TRANSPORT_GAPS_PER_CONVERSATION } from '@shared/types/session' +import { chunk, messageStart, mountClaudePane, request, thinkingDelta, thinkingStart } from '@renderer/session-runtime/semantic/testing/proxyPaneDrivers' + +// #1381, option B (OWNER-APPROVED, B6 proxy 2026-09-27): when the proxy events +// transport loses a span, the feed shows a DURABLE row saying so — placed where +// the loss began among the conversation's rows, kept after later turns, and +// back when the feed is rebuilt from its history chunk. "Data loss is never +// hidden." +// +// Drives the REAL Claude proxy adapter into the renderer's fold (the same +// drivers the #963 sleep tests use), and the REAL ledger input adapter, +// ownership ledger and view bridge Feed renders from. Frame content is +// synthetic in the recorded shape; entry shapes are the committed Claude ones +// the ledger tests use. + +const T = 1_700_000_000_000 +const iso = (ms: number) => new Date(ms).toISOString() +const userEntry = (uuid: string, ms: number, text: string) => + ({ uuid, type: 'user', timestamp: iso(ms), permissionMode: 'default', message: { role: 'user', content: text } }) as unknown as Entry +const assistantEntry = (uuid: string, msgId: string, ms: number, text: string) => + ({ uuid, type: 'assistant', timestamp: iso(ms), message: { id: msgId, role: 'assistant', content: text } }) as unknown as Entry + +const GAP: TransportGapRecord = { id: 'gap-1', since: T + 10_000, until: T + 40_000, lostGenerations: 2 } + +function feedItems(runtime: SessionRuntime) { + const slices: RuntimeLedgerSlices = { + provider: 'claude', + sessionId: 's1', + entries: runtime.entries, + semanticCurrent: runtime.semantic.currentTurn, + semanticHistory: runtime.semantic.history, + transportGaps: runtime.transportGaps, + ghosts: runtime.ghosts, + streamPhase: runtime.streamPhase, + lastJsonlEntryAtMs: runtime.lastJsonlEntryAt, + } + const ledger = createSessionLedger()(createLedgerInputAdapter()(slices).input) + return ledgerToFeedItems(ledger, ledgerFeedContextFromRuntime(runtime, 'claude')).items +} + +const shape = (runtime: SessionRuntime): string[] => feedItems(runtime).map(item => + item.type === 'entry' ? `entry:${String(item.entry.uuid)}` : item.type) + +describe('a lost proxy span in the Claude feed (#1381)', () => { + it('seals the turn that was streaming across it, and the fold keeps why', () => { + const pane = mountClaudePane() + request(pane.adapter, 1) + chunk(pane.adapter, 1, [messageStart('msg_cut'), thinkingStart(0), thinkingDelta(0)]) + pane.adapter.sealFlowsForTransportGap() + // Post-gap frames of the same response are dropped, not stitched on. + chunk(pane.adapter, 1, [thinkingDelta(0)]) + + expect(pane.reducer.stops).toEqual([expect.objectContaining({ interruption: 'transport-gap' })]) + const turn = pane.reducer.pane.semantic.currentTurn ?? pane.reducer.pane.semantic.history.at(-1) + expect(turn?.interruption).toBe('transport-gap') + expect(pane.reducer.pane.phase.streamPhase).toBe('idle') + }) + + it('paints one durable row where the loss began, among the conversation', () => { + const runtime: SessionRuntime = { + ...emptyRuntime(), + entries: [userEntry('u1', T, 'build it'), assistantEntry('a1', 'msg_a1', T + 60_000, 'done')], + transportGaps: [GAP], + } + expect(shape(runtime)).toEqual(['entry:u1', 'transport-gap', 'entry:a1']) + const row = feedItems(runtime).find(item => item.type === 'transport-gap') + expect(row).toMatchObject({ gap: GAP }) + }) + + it('stays after later turns complete', () => { + const runtime: SessionRuntime = { + ...emptyRuntime(), + entries: [ + userEntry('u1', T, 'build it'), + assistantEntry('a1', 'msg_a1', T + 60_000, 'done'), + userEntry('u2', T + 120_000, 'now test it'), + assistantEntry('a2', 'msg_a2', T + 180_000, 'tested'), + ], + transportGaps: [GAP], + } + expect(shape(runtime)).toEqual(['entry:u1', 'transport-gap', 'entry:a1', 'entry:u2', 'entry:a2']) + }) + + it('comes back when the feed is rebuilt from its history chunk, once', () => { + // Live: the event delivered the record. Rebuild (a window reload): the runtime + // starts empty and the initial history chunk carries what main held. + const live = mergeTransportGaps(emptyRuntime().transportGaps, [GAP]) + const rebuilt = mergeTransportGaps(emptyRuntime().transportGaps, [GAP]) + expect(rebuilt).toEqual([GAP]) + // The same record arriving by both paths is one row, not two. + expect(mergeTransportGaps(live, [GAP])).toBe(live) + const both = mergeTransportGaps(live, [GAP, { ...GAP, id: 'gap-2', since: T + 90_000, until: T + 95_000 }]) + expect(both.map(gap => gap.id)).toEqual(['gap-1', 'gap-2']) + }) + + // #1442 review b: main keeps the newest 50 per conversation, so the live feed must too, or an + // open pane and the same pane after a reload paint different rows. + it('keeps the newest gaps up to the same cap as main, live and rebuilt alike', () => { + let live = emptyRuntime().transportGaps + for (let i = 1; i <= TRANSPORT_GAPS_PER_CONVERSATION + 3; i += 1) { + live = mergeTransportGaps(live, [{ id: `gap-${i}`, since: T + i * 1_000, until: T + i * 1_000 + 500, lostGenerations: 1 }]) + } + expect(live).toHaveLength(TRANSPORT_GAPS_PER_CONVERSATION) + expect(live[0]!.id).toBe('gap-4') + expect(live.at(-1)!.id).toBe(`gap-${TRANSPORT_GAPS_PER_CONVERSATION + 3}`) + }) + + // #1442 review c: the pane keeps ONE adapter across renders (useLedgerFeedItems), and its notice + // cache is keyed on slice identities. A gap that arrives while nothing else changes (the sealed + // turn was the conversation's last) must still paint at once, not at the next entry or reload. + it('paints a gap that arrives alone on a pane whose adapter is reused', () => { + const adapter = createLedgerInputAdapter() + const ledger = createSessionLedger() + const runtime: SessionRuntime = { ...emptyRuntime(), entries: [userEntry('u1', T, 'hi')] } + const slicesOf = (r: SessionRuntime): RuntimeLedgerSlices => ({ + provider: 'claude', sessionId: 's1', entries: r.entries, + semanticCurrent: r.semantic.currentTurn, semanticHistory: r.semantic.history, + transportGaps: r.transportGaps, ghosts: r.ghosts, streamPhase: r.streamPhase, lastJsonlEntryAtMs: r.lastJsonlEntryAt, + }) + const paint = (r: SessionRuntime) => ledgerToFeedItems(ledger(adapter(slicesOf(r)).input), ledgerFeedContextFromRuntime(r, 'claude')).items + expect(paint(runtime).some(item => item.type === 'transport-gap')).toBe(false) + // Only the gaps slice moves; entries and semantic state keep their identity. + const withGap: SessionRuntime = { ...runtime, transportGaps: mergeTransportGaps(runtime.transportGaps, [GAP]) } + expect(paint(withGap).some(item => item.type === 'transport-gap')).toBe(true) + }) + + it('a runtime with no gaps paints no row', () => { + const runtime: SessionRuntime = { ...emptyRuntime(), entries: [userEntry('u1', T, 'hi')] } + expect(shape(runtime)).toEqual(['entry:u1']) + }) +}) + +describe('transportGapSentence', () => { + // Local-time Date parts, so the expectation holds in any time zone. + const at = (h: number, m: number, s: number) => new Date(2026, 8, 27, h, m, s).getTime() + + it('says the window the lost output was written in', () => { + expect(transportGapSentence({ since: at(14, 2, 11), until: at(14, 3, 40) })) + .toBe('Part of this response was not captured (14:02:11–14:03:40)') + }) + + it('says only the end when the loss came before the first poll', () => { + expect(transportGapSentence({ since: null, until: at(9, 5, 0) })) + .toBe('Part of this response was not captured (before 09:05:00)') + }) +}) diff --git a/src/renderer/src/features/feed/ledger/useLedgerFeedItems.ts b/src/renderer/src/features/feed/ledger/useLedgerFeedItems.ts index b0b87d811..89bf0982c 100644 --- a/src/renderer/src/features/feed/ledger/useLedgerFeedItems.ts +++ b/src/renderer/src/features/feed/ledger/useLedgerFeedItems.ts @@ -107,6 +107,7 @@ export function useLedgerFeedItems( semanticCurrent: runtime.semantic.currentTurn, semanticHistory: runtime.semantic.history, semanticErrors: runtime.semantic.errors, + transportGaps: runtime.transportGaps, ghosts: runtime.ghosts, streamPhase: runtime.streamPhase, lastJsonlEntryAtMs: runtime.lastJsonlEntryAt, @@ -136,6 +137,7 @@ export function useLedgerFeedItems( runtime.semantic.currentTurn, runtime.semantic.history, runtime.semantic.errors, + runtime.transportGaps, runtime.ghosts, runtime.streamPhase, runtime.streamPhasePendingToolName, diff --git a/src/renderer/src/features/feed/lib/transportGapText.ts b/src/renderer/src/features/feed/lib/transportGapText.ts new file mode 100644 index 000000000..e84bfc4c0 --- /dev/null +++ b/src/renderer/src/features/feed/lib/transportGapText.ts @@ -0,0 +1,29 @@ +import type { TransportGapRecord } from '@shared/types/session' + +/** + * The durable "not captured" row's one sentence (#1381; wording from the + * owner-approved decision, B6 proxy 2026-09-27: "part of this response was + * not captured: