From d1cebdb3997fbbf45705b12e9b394271309a9e6f Mon Sep 17 00:00:00 2001 From: Julius Olsson Date: Sun, 27 Sep 2026 07:45:21 -0700 Subject: [PATCH 01/13] docs(claude): plan a feed marker where the proxy transport lost events (#1381) Co-Authored-By: Claude Opus 5.5 --- .../2026-09-27-claude-proxy-gap-marker.md | 178 ++++++++++++++++++ 1 file changed, 178 insertions(+) create mode 100644 docs/plans/2026-09-27-claude-proxy-gap-marker.md 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..381f2d03b --- /dev/null +++ b/docs/plans/2026-09-27-claude-proxy-gap-marker.md @@ -0,0 +1,178 @@ +# 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 muted marker at that turn: **"Some live output was not + captured"**. +- 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 (defaults taken; UNCONFIRMED until the manager or owner says otherwise) + +1. **Wording:** "Some live output was not captured" (the issue's own + example). It claims nothing about the transcript, which may well be + complete. UNCONFIRMED. +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. UNCONFIRMED. +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. + +## Change + +### Package (claude-code-headless, its own PR; app pointer bump after merge) + +- `EventsFilePoll` gains `gaps: Array<{ index: number; lostGenerations: + number }>`. + - `index` = the count of `lines` delivered before the lost span. + - `lostGenerations` stays as the total (API compatible). + - `settleBelow` records `out.lines.length` at entry, which is the + position after the old tail and before `.1`/live. +- `ProxyServer.pollEventsOnce` emits lines and `transport-gap` interleaved + at each gap's index. The payload stays `{ lostGenerations }`, per gap. + The console.warn is unchanged. +- `ClaudeProxyAdapter.sealFlowsForTransportGap()` (public, synchronous): + - streaming flows → `reapStaleActiveFlow(state, 'transport-gap')`; + - every other tracked flow is dropped. +- `SemanticTurnStoppedEvent.interruption` adds `'transport-gap'`. +- API.md updated. + +### App + +- `ClaudeSession.proxyGapHandler` calls + `this.headless?.proxy?.sealFlowsForTransportGap()` BEFORE re-emitting. + The seal is synchronous, so the `turn_stopped` lands before any + post-gap event. +- Renderer: `'transport-gap'` is added beside `'transport-error'` at every + #1040 touchpoint: + - `foldEvent`; + - `SemanticTurn.interruption`; + - `collectLedgerInput` (`gapInterruptedTurnId`, its own statics key); + - the model types; + - ledger items; + - the render model; + - `Feed.tsx` (a `MarkerRow` with the sentence above); + - observations; + - the redact enum and invariants. +- The submodule pointer is bumped to the merged package commit, with a + lockfile resync. + +## Tests (fail-first; each would fail before its fix) + +- Package `eventsFileTail` test: a tail holding generation 0 with unread + lines, then two rotations with the middle generation deleted. `poll()` + returns the old tail lines, then `gaps: [{ index: , + lostGenerations: 1 }]`, then the live lines. Before the fix there is no + `gaps` field. +- Package `proxyServer` test: the emitted `event` / `transport-gap` order + equals the line order with the gap between them. Before the fix, the + gap is emitted first. +- Package adapter test: + - a mid-stream flow plus a gap gives `turn_stopped {interruption: + 'transport-gap'}`, `finishTurn` and phase idle; + - its later chunks produce nothing; + - a request-only flow is forgotten. + + Before the fix there is no such method. +- App `claudeSession` test: a proxy `transport-gap` seals before the + re-emit (order pinned). +- App renderer: `foldEvent` keeps `'transport-gap'`, and the ledger/feed + item renders the sentence. Pinned the way #1040's + `ledgerFeedItems.test.ts` does it. + +## 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. From 91b05b03c55e52c69da8126ce84a7cd298cdc496 Mon Sep 17 00:00:00 2001 From: Julius Olsson Date: Sun, 27 Sep 2026 07:49:02 -0700 Subject: [PATCH 02/13] feat(feed): a work-slot marker for a turn sealed by a lost proxy span (#1381) Adds the 'transport-gap' interruption beside #1040's 'transport-error' at every renderer touchpoint (turn state, ledger input statics key, lifecycle candidate, feed item, Feed row, invariants) with the sentence 'Some live output was not captured'. Inert until the fold keeps the value, which needs the package's interruption union (next). The three spelled-out marker checks in ledgerFeedItems become one predicate. Co-Authored-By: Claude Opus 5.5 --- .../2026-09-27-claude-proxy-gap-marker.md | 18 ++++++++++++ .../feed/ledger/ledgerFeedItems.test.ts | 2 ++ .../features/feed/ledger/ledgerFeedItems.ts | 29 +++++++++++++++---- .../src/features/feed/model/renderModel.ts | 10 +++++++ src/renderer/src/features/feed/ui/Feed.tsx | 13 +++++++++ .../rendering/adapter/collectLedgerInput.ts | 14 +++++++-- src/renderer/src/rendering/model/types.ts | 5 ++++ .../src/rendering/observations/local.ts | 19 ++++++++++++ .../src/rendering/replay/invariants.ts | 1 + src/renderer/src/session-runtime/state.ts | 7 +++-- 10 files changed, 109 insertions(+), 9 deletions(-) diff --git a/docs/plans/2026-09-27-claude-proxy-gap-marker.md b/docs/plans/2026-09-27-claude-proxy-gap-marker.md index 381f2d03b..439db92aa 100644 --- a/docs/plans/2026-09-27-claude-proxy-gap-marker.md +++ b/docs/plans/2026-09-27-claude-proxy-gap-marker.md @@ -90,6 +90,24 @@ the surviving chunks as one continuous answer. phase. Same trade as #1040. UNCONFIRMED. 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 marker stays:** #963 and #1040's markers are WORK-SLOT + lifecycle candidates (`collectLifecycleCandidates`). They show only + while the pane is idle and the sealed turn is the newest semantic turn, + and they give way to the work chip as soon as the agent works again. For + an Esc (#1040) the turn is over, so the marker stays. A gap usually hits + mid-turn, and the next tool round-trip starts a new message within + seconds, so a work-slot marker may only flash. + - **Default (A):** the work-slot marker, the #1040 template. It is + cheap, consistent, and visible whenever the gap ends the visible + activity. The always-on `claude.proxy_transport_gap` incident is the + durable record. + - **Alternative (B):** a row anchored in the feed history at the sealed + turn, which survives later turns. The ledger would then need to + render interruption markers for archived turns, not only the newest. + That is a larger change to how archived semantic turns give way to + JSONL rows. + - A is UNCONFIRMED and asked of the manager. B is a follow-up if + wanted. ## Change diff --git a/src/renderer/src/features/feed/ledger/ledgerFeedItems.test.ts b/src/renderer/src/features/feed/ledger/ledgerFeedItems.test.ts index 4bfc10648..e509f83d5 100644 --- a/src/renderer/src/features/feed/ledger/ledgerFeedItems.test.ts +++ b/src/renderer/src/features/feed/ledger/ledgerFeedItems.test.ts @@ -100,6 +100,8 @@ const shape = (i: FeedRenderItem): string => { return `sleep-interruption:${i.key}` case 'transport-interruption': return `transport-interruption:${i.key}` + case 'gap-interruption': + return `gap-interruption:${i.key}` case 'empty': return 'empty' } diff --git a/src/renderer/src/features/feed/ledger/ledgerFeedItems.ts b/src/renderer/src/features/feed/ledger/ledgerFeedItems.ts index 9153c08da..8853f7130 100644 --- a/src/renderer/src/features/feed/ledger/ledgerFeedItems.ts +++ b/src/renderer/src/features/feed/ledger/ledgerFeedItems.ts @@ -95,6 +95,21 @@ export function ledgerFeedContextFromRuntime( } } +/** The work-slot interruption markers (#963 sleep, #1040 dead socket, #1381 + * lost transport span): phase facts of the process plane that paint in the + * work slot and carry no provider content. One predicate because the three + * checks below used to spell the list out each time, and the third marker + * would otherwise have to be remembered in every copy — missing it in the + * empty-repair test paints "waiting for Claude…" over a sealed answer. */ +const INTERRUPTION_MARKER_TYPES = new Set([ + 'sleep-interruption', + 'transport-interruption', + 'gap-interruption', +]) +function isWorkSlotItem(item: FeedRenderItem): boolean { + return item.type === 'work' || INTERRUPTION_MARKER_TYPES.has(item.type) +} + function orderAt(index: number, phase: FeedRenderItemOrder['phase']): FeedRenderItemOrder { return { phase, timeMs: null, sequence: index, source: 'ledger' } } @@ -320,6 +335,12 @@ export function ledgerToFeedItems( key: c.id, order: orderAt(items.length, 'work'), }) + } else if (c.contentKind === 'gap-interruption') { + items.push({ + type: 'gap-interruption', + key: c.id, + order: orderAt(items.length, 'work'), + }) } else { items.push({ type: 'empty', @@ -345,12 +366,10 @@ export function ledgerToFeedItems( // upstream data decision—Feed receives an explicit empty item and never // filters a selected row itself. const hasPaintedContent = items.some(item => - item.type !== 'absorbed-entry' && item.type !== 'empty' && item.type !== 'work' - && item.type !== 'sleep-interruption' && item.type !== 'transport-interruption', + item.type !== 'absorbed-entry' && item.type !== 'empty' && !isWorkSlotItem(item), ) if (!hasPaintedContent && !items.some(item => item.type === 'empty')) { - const workIndex = items.findIndex(item => - item.type === 'work' || item.type === 'sleep-interruption' || item.type === 'transport-interruption') + const workIndex = items.findIndex(isWorkSlotItem) const insertionIndex = workIndex < 0 ? items.length : workIndex items.splice(insertionIndex, 0, { type: 'empty', @@ -361,7 +380,7 @@ export function ledgerToFeedItems( // Keep the order metadata truthful after inserting before a work item. for (let index = insertionIndex + 1; index < items.length; index += 1) { const item = items[index] - const phase = item.type === 'work' || item.type === 'sleep-interruption' || item.type === 'transport-interruption' + const phase = isWorkSlotItem(item) ? 'work' : item.type === 'empty' ? 'empty' : 'content' item.order = orderAt(index, phase) diff --git a/src/renderer/src/features/feed/model/renderModel.ts b/src/renderer/src/features/feed/model/renderModel.ts index b43b7a665..dcdebbb66 100644 --- a/src/renderer/src/features/feed/model/renderModel.ts +++ b/src/renderer/src/features/feed/model/renderModel.ts @@ -121,6 +121,13 @@ export type FeedRenderItem = key: string order: FeedRenderItemOrder } + | { + /** The newest turn was sealed because the proxy events transport lost a + * span of its chunks (#1381). Same slot, its own sentence. */ + type: 'gap-interruption' + key: string + order: FeedRenderItemOrder + } | { type: 'empty' key: string @@ -153,6 +160,8 @@ function labelForItem(item: FeedRenderItem, provider: AgentProvider): string { return 'interrupted while asleep' case 'transport-interruption': return 'interrupted before the response finished' + case 'gap-interruption': + return 'some live output was not captured' case 'empty': return `waiting for ${getRendererProviderCapabilities(provider).name}…` } @@ -172,6 +181,7 @@ function slotForItem(item: FeedRenderItem): DebugVisibleRow['slot'] { case 'work': case 'sleep-interruption': case 'transport-interruption': + case 'gap-interruption': return 'work' case 'empty': return 'empty' diff --git a/src/renderer/src/features/feed/ui/Feed.tsx b/src/renderer/src/features/feed/ui/Feed.tsx index f5ec71a41..5fc33e4be 100644 --- a/src/renderer/src/features/feed/ui/Feed.tsx +++ b/src/renderer/src/features/feed/ui/Feed.tsx @@ -1170,6 +1170,19 @@ function FeedImpl({ ) + case 'gap-interruption': + // #1381: events between the proxy and the app were LOST (the poller + // stalled through the addon's rotations, claude-code-headless#64), so + // the live text above skipped part of this answer. The wording claims + // only that: the saved transcript is Claude's own file and may well be + // complete, and the gap says nothing about why the stream ended. + return ( + +
+ Some live output was not captured +
+
+ ) case 'empty': return (
Led streamPhaseIdle: boolean sleepInterruptedTurnId: string | null transportInterruptedTurnId: string | null + gapInterruptedTurnId: string | null provider: AgentProviderKind candidates: readonly RenderCandidate[] } | null = null @@ -382,17 +383,25 @@ export function createLedgerInputAdapter(): (slices: RuntimeLedgerSlices) => Led newestTurn?.interruption === 'transport-error' && newestTurn.endedAt !== null ? newestTurn.turnId : null + // #1381: and for a turn sealed because the proxy events transport lost a + // span of its chunks. Its own key for the same reasons as the two above. + const gapInterruptedTurnId = + newestTurn?.interruption === 'transport-gap' && newestTurn.endedAt !== null + ? newestTurn.turnId + : null if ( !staticsCache || staticsCache.streamPhaseIdle !== streamPhaseIdle || staticsCache.sleepInterruptedTurnId !== sleepInterruptedTurnId || staticsCache.transportInterruptedTurnId !== transportInterruptedTurnId || + staticsCache.gapInterruptedTurnId !== gapInterruptedTurnId || staticsCache.provider !== provider ) { staticsCache = { streamPhaseIdle, sleepInterruptedTurnId, transportInterruptedTurnId, + gapInterruptedTurnId, provider, candidates: collectLifecycleCandidates({ provider, @@ -400,6 +409,7 @@ export function createLedgerInputAdapter(): (slices: RuntimeLedgerSlices) => Led streamPhaseIdle, sleepInterruptedTurnId, transportInterruptedTurnId, + gapInterruptedTurnId, }), } } diff --git a/src/renderer/src/rendering/model/types.ts b/src/renderer/src/rendering/model/types.ts index b4ae3077c..3585309f7 100644 --- a/src/renderer/src/rendering/model/types.ts +++ b/src/renderer/src/rendering/model/types.ts @@ -74,6 +74,11 @@ export type RenderContentKind = * an Esc interrupt, a proxy timeout, an upstream failure. The provider * cannot tell those apart, so the row says only that it was cut off. */ | 'transport-interruption' + /** The same marker for a turn the proxy adapter sealed because the events + * transport LOST a span of its chunks (#1381, claude-code-headless#64 + * `transport-gap`). The live text on screen skipped part of the answer; the + * saved transcript is a separate file and is not implied to be incomplete. */ + | 'gap-interruption' | 'empty' | 'unknown' diff --git a/src/renderer/src/rendering/observations/local.ts b/src/renderer/src/rendering/observations/local.ts index 46188741f..ad6bf6462 100644 --- a/src/renderer/src/rendering/observations/local.ts +++ b/src/renderer/src/rendering/observations/local.ts @@ -77,6 +77,9 @@ export function collectLifecycleCandidates(params: { sleepInterruptedTurnId?: string | null /** The same, for a turn whose stream died before it finished (#1040). */ transportInterruptedTurnId?: string | null + /** The same, for a turn sealed because the proxy events transport lost a + * span of its chunks (#1381). */ + gapInterruptedTurnId?: string | null }): RenderCandidate[] { const out: RenderCandidate[] = [] if (!params.streamPhaseIdle) { @@ -122,6 +125,22 @@ export function collectLifecycleCandidates(params: { timestampMs: null, sequence: 1, }) + } else if (params.gapInterruptedTurnId) { + // #1381: the turn was sealed because events were LOST between the proxy and + // the app (a stalled poller outran the addon's rotation). Checked last: a + // turn carries exactly one interruption, so the order only matters for + // stating it — sleep and a dead socket are explanations of why the stream + // ended, a gap is only a statement that we did not see all of it. + out.push({ + id: `gap-interruption:${params.gapInterruptedTurnId}`, + owner: 'work', + provider: params.provider, + sourcePlane: 'process', + sessionId: params.sessionId, + contentKind: 'gap-interruption', + timestampMs: null, + sequence: 1, + }) } return out } diff --git a/src/renderer/src/rendering/replay/invariants.ts b/src/renderer/src/rendering/replay/invariants.ts index 70164d106..5301e72e2 100644 --- a/src/renderer/src/rendering/replay/invariants.ts +++ b/src/renderer/src/rendering/replay/invariants.ts @@ -45,6 +45,7 @@ function isLifecycleRow(row: RenderRow): boolean { // toggles with the same phase edge, so it is lifecycle, not content. return kind === 'work' || kind === 'empty' || kind === 'sleep-interruption' || kind === 'transport-interruption' + || kind === 'gap-interruption' } export type InvariantKind = diff --git a/src/renderer/src/session-runtime/state.ts b/src/renderer/src/session-runtime/state.ts index 14ffa623b..3cde0ad04 100644 --- a/src/renderer/src/session-runtime/state.ts +++ b/src/renderer/src/session-runtime/state.ts @@ -295,8 +295,11 @@ export type SemanticLiveTurn = { * `system-suspended`: the machine slept mid-stream (#963). * `transport-error`: the stream's socket died before the message ended — * an Esc interrupt, a proxy timeout, an upstream failure (#1040). The - * provider cannot tell those apart, so neither does this. */ - interruption?: 'system-suspended' | 'transport-error' + * provider cannot tell those apart, so neither does this. + * `transport-gap`: the proxy events transport LOST a span of this turn's + * chunks (claude-code-headless#64 rotated them away unread), so what the + * live view assembled is not the whole answer (#1381). */ + interruption?: 'system-suspended' | 'transport-error' | 'transport-gap' } export type SemanticFlow = { From 153f4e2d08b063ee11becbbc6374d39c4905176a Mon Sep 17 00:00:00 2001 From: Julius Olsson Date: Sun, 27 Sep 2026 07:54:43 -0700 Subject: [PATCH 03/13] docs(claude): #1381 is option B, a durable gap row (owner-approved by B6) Co-Authored-By: Claude Opus 5.5 --- .../2026-09-27-claude-proxy-gap-marker.md | 105 +++++++++++------- 1 file changed, 66 insertions(+), 39 deletions(-) diff --git a/docs/plans/2026-09-27-claude-proxy-gap-marker.md b/docs/plans/2026-09-27-claude-proxy-gap-marker.md index 439db92aa..4c3dffb46 100644 --- a/docs/plans/2026-09-27-claude-proxy-gap-marker.md +++ b/docs/plans/2026-09-27-claude-proxy-gap-marker.md @@ -67,11 +67,9 @@ the surviving chunks as one continuous answer. tests use synthetic SSE frames in the recorded shape (`ClaudeProxyAdapter.clientDisconnect.test.ts`), and so will these. -## Decisions (defaults taken; UNCONFIRMED until the manager or owner says otherwise) +## Decisions (5 is OWNER-APPROVED; 3 is still UNCONFIRMED; the rest are rulings) -1. **Wording:** "Some live output was not captured" (the issue's own - example). It claims nothing about the transcript, which may well be - complete. UNCONFIRMED. +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 @@ -90,24 +88,31 @@ the surviving chunks as one continuous answer. phase. Same trade as #1040. UNCONFIRMED. 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 marker stays:** #963 and #1040's markers are WORK-SLOT - lifecycle candidates (`collectLifecycleCandidates`). They show only - while the pane is idle and the sealed turn is the newest semantic turn, - and they give way to the work chip as soon as the agent works again. For - an Esc (#1040) the turn is over, so the marker stays. A gap usually hits - mid-turn, and the next tool round-trip starts a new message within - seconds, so a work-slot marker may only flash. - - **Default (A):** the work-slot marker, the #1040 template. It is - cheap, consistent, and visible whenever the gap ends the visible - activity. The always-on `claude.proxy_transport_gap` incident is the - durable record. - - **Alternative (B):** a row anchored in the feed history at the sealed - turn, which survives later turns. The ledger would then need to - render interruption markers for archived turns, not only the newest. - That is a larger change to how archived semantic turns give way to - JSONL rows. - - A is UNCONFIRMED and asked of the manager. B is a follow-up if - wanted. +5. **How long the marker stays: OWNER-APPROVED (B6 proxy, 2026-09-27): + option B**, a DURABLE feed-history row (temp/manager/assign/w3-1381-decision.md). + The owner's rule is that data loss is never hidden. A work-slot marker + (option A) would vanish once the agent worked again, so it would hide + the loss from anyone reviewing later. The row reads **"Part of this + response was not captured (HH:MM:SS–HH:MM:SS)"** and supersedes the + decision-1 wording. It persists after later turns and survives a + renderer reload. It is one row kind, adds no new UI surface, and uses + the existing muted MarkerRow styling. + - Ruling: the row is held by MAIN in memory, per session. It is not + written to disk: main outlives a renderer reload, which is the + rebuild the decision names. On-disk feed rows are what the owner + removed in #1235 (the ghost log). The always-on + `claude.proxy_transport_gap` incident is already the on-disk record. + Cost if wrong: after an app restart, the row is gone (the incident + stays). + - Ruling: the span is app-clock time, from `since` (when the tail was + last caught up, i.e. the previous poll that completed) 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 @@ -120,8 +125,10 @@ the surviving chunks as one continuous answer. - `settleBelow` records `out.lines.length` at entry, which is the position after the old tail and before `.1`/live. - `ProxyServer.pollEventsOnce` emits lines and `transport-gap` interleaved - at each gap's index. The payload stays `{ lostGenerations }`, per gap. - The console.warn is unchanged. + at each gap's index. The payload becomes `{ lostGenerations, since, + until }` (app-clock ms): `since` = when the previous poll completed (the + tail was caught up then; null before the first), and `until` = now. The + console.warn is unchanged. - `ClaudeProxyAdapter.sealFlowsForTransportGap()` (public, synchronous): - streaming flows → `reapStaleActiveFlow(state, 'transport-gap')`; - every other tracked flow is dropped. @@ -133,18 +140,34 @@ the surviving chunks as one continuous answer. - `ClaudeSession.proxyGapHandler` calls `this.headless?.proxy?.sealFlowsForTransportGap()` BEFORE re-emitting. The seal is synchronous, so the `turn_stopped` lands before any - post-gap event. -- Renderer: `'transport-gap'` is added beside `'transport-error'` at every - #1040 touchpoint: - - `foldEvent`; - - `SemanticTurn.interruption`; - - `collectLedgerInput` (`gapInterruptedTurnId`, its own statics key); - - the model types; - - ledger items; - - the render model; - - `Feed.tsx` (a `MarkerRow` with the sentence above); - - observations; - - the redact enum and invariants. + post-gap event. The re-emit carries `{ lostGenerations, since, until }`. +- `SessionManager`: + - on a gap, also appends a record to a bounded, per-session, + in-memory list (`TransportGapRecord = { id, since, until, + lostGenerations }`; the cap is the newest 50); + - emits `transport-gap {sessionId, gap}`; + - clears the list with the session's other cached state + (`cleanupSessionState`); + - `getTransportGaps(sessionId)` answers the reseed. +- IPC: + - `session:transport-gap`, forwarded by the session feed tap like + `session:conditions`, so the phone gets it too; + - `session:reseed-transport-gaps(sessionIds)`, mirroring + `session:reseed-conditions`: the owning window gets every held record + re-sent on the live channel. + - Preload and `SessionFeed.onSessionTransportGap` are added to both + transports. +- Renderer: + - the runtime holds `transportGaps: TransportGapRecord[]`, de-duplicated + by `id` because a reseed replays records already held; + - `collectLedgerInput` turns each record into a committed-plane + candidate with `timestampMs = since ?? until`, ordered among entries + like a provider notice; + - one row kind, `transport-gap`, rendered as the existing muted + `MarkerRow`: "Part of this response was not captured (HH:MM:SS–HH:MM:SS)"; + - `foldEvent` keeps `interruption: 'transport-gap'`, with the model + types, invariants and redact to match; + - the reseed runs where conditions are reseeded. - The submodule pointer is bumped to the merged package commit, with a lockfile resync. @@ -167,9 +190,13 @@ the surviving chunks as one continuous answer. Before the fix there is no such method. - App `claudeSession` test: a proxy `transport-gap` seals before the re-emit (order pinned). -- App renderer: `foldEvent` keeps `'transport-gap'`, and the ledger/feed - item renders the sentence. Pinned the way #1040's - `ledgerFeedItems.test.ts` does it. +- App `SessionManager`: a gap is recorded, bounded, returned by + `getTransportGaps`, and cleared with the session. +- App end to end (the `turnClockAcrossSleep.test.ts` harness, driving the + REAL package adapter into the reducer and feed items): a gap mid-stream + seals the turn, and the durable row sits at its time among the entries. + It STAYS after a later turn completes, and it is back after the runtime + is rebuilt from a reseed (the reload). ## Verification From 2d058197b267f614f74555d6bdfe3a5baa936d7f Mon Sep 17 00:00:00 2001 From: Julius Olsson Date: Sun, 27 Sep 2026 07:58:52 -0700 Subject: [PATCH 04/13] revert: withdraw the #1381 work-slot marker (option B supersedes it) The owner-approved call (B6 proxy, 2026-09-27) is a DURABLE feed-history row, and 'one row kind'. The work-slot marker from 91b05b03 would give way to the work chip as soon as the agent worked again, which hides the loss. Co-Authored-By: Claude Opus 5.5 --- .../feed/ledger/ledgerFeedItems.test.ts | 2 -- .../features/feed/ledger/ledgerFeedItems.ts | 29 ++++--------------- .../src/features/feed/model/renderModel.ts | 10 ------- src/renderer/src/features/feed/ui/Feed.tsx | 13 --------- .../rendering/adapter/collectLedgerInput.ts | 14 ++------- src/renderer/src/rendering/model/types.ts | 5 ---- .../src/rendering/observations/local.ts | 19 ------------ .../src/rendering/replay/invariants.ts | 1 - src/renderer/src/session-runtime/state.ts | 7 ++--- 9 files changed, 9 insertions(+), 91 deletions(-) diff --git a/src/renderer/src/features/feed/ledger/ledgerFeedItems.test.ts b/src/renderer/src/features/feed/ledger/ledgerFeedItems.test.ts index e509f83d5..4bfc10648 100644 --- a/src/renderer/src/features/feed/ledger/ledgerFeedItems.test.ts +++ b/src/renderer/src/features/feed/ledger/ledgerFeedItems.test.ts @@ -100,8 +100,6 @@ const shape = (i: FeedRenderItem): string => { return `sleep-interruption:${i.key}` case 'transport-interruption': return `transport-interruption:${i.key}` - case 'gap-interruption': - return `gap-interruption:${i.key}` case 'empty': return 'empty' } diff --git a/src/renderer/src/features/feed/ledger/ledgerFeedItems.ts b/src/renderer/src/features/feed/ledger/ledgerFeedItems.ts index 8853f7130..9153c08da 100644 --- a/src/renderer/src/features/feed/ledger/ledgerFeedItems.ts +++ b/src/renderer/src/features/feed/ledger/ledgerFeedItems.ts @@ -95,21 +95,6 @@ export function ledgerFeedContextFromRuntime( } } -/** The work-slot interruption markers (#963 sleep, #1040 dead socket, #1381 - * lost transport span): phase facts of the process plane that paint in the - * work slot and carry no provider content. One predicate because the three - * checks below used to spell the list out each time, and the third marker - * would otherwise have to be remembered in every copy — missing it in the - * empty-repair test paints "waiting for Claude…" over a sealed answer. */ -const INTERRUPTION_MARKER_TYPES = new Set([ - 'sleep-interruption', - 'transport-interruption', - 'gap-interruption', -]) -function isWorkSlotItem(item: FeedRenderItem): boolean { - return item.type === 'work' || INTERRUPTION_MARKER_TYPES.has(item.type) -} - function orderAt(index: number, phase: FeedRenderItemOrder['phase']): FeedRenderItemOrder { return { phase, timeMs: null, sequence: index, source: 'ledger' } } @@ -335,12 +320,6 @@ export function ledgerToFeedItems( key: c.id, order: orderAt(items.length, 'work'), }) - } else if (c.contentKind === 'gap-interruption') { - items.push({ - type: 'gap-interruption', - key: c.id, - order: orderAt(items.length, 'work'), - }) } else { items.push({ type: 'empty', @@ -366,10 +345,12 @@ export function ledgerToFeedItems( // upstream data decision—Feed receives an explicit empty item and never // filters a selected row itself. const hasPaintedContent = items.some(item => - item.type !== 'absorbed-entry' && item.type !== 'empty' && !isWorkSlotItem(item), + item.type !== 'absorbed-entry' && item.type !== 'empty' && item.type !== 'work' + && item.type !== 'sleep-interruption' && item.type !== 'transport-interruption', ) if (!hasPaintedContent && !items.some(item => item.type === 'empty')) { - const workIndex = items.findIndex(isWorkSlotItem) + const workIndex = items.findIndex(item => + item.type === 'work' || item.type === 'sleep-interruption' || item.type === 'transport-interruption') const insertionIndex = workIndex < 0 ? items.length : workIndex items.splice(insertionIndex, 0, { type: 'empty', @@ -380,7 +361,7 @@ export function ledgerToFeedItems( // Keep the order metadata truthful after inserting before a work item. for (let index = insertionIndex + 1; index < items.length; index += 1) { const item = items[index] - const phase = isWorkSlotItem(item) + const phase = item.type === 'work' || item.type === 'sleep-interruption' || item.type === 'transport-interruption' ? 'work' : item.type === 'empty' ? 'empty' : 'content' item.order = orderAt(index, phase) diff --git a/src/renderer/src/features/feed/model/renderModel.ts b/src/renderer/src/features/feed/model/renderModel.ts index dcdebbb66..b43b7a665 100644 --- a/src/renderer/src/features/feed/model/renderModel.ts +++ b/src/renderer/src/features/feed/model/renderModel.ts @@ -121,13 +121,6 @@ export type FeedRenderItem = key: string order: FeedRenderItemOrder } - | { - /** The newest turn was sealed because the proxy events transport lost a - * span of its chunks (#1381). Same slot, its own sentence. */ - type: 'gap-interruption' - key: string - order: FeedRenderItemOrder - } | { type: 'empty' key: string @@ -160,8 +153,6 @@ function labelForItem(item: FeedRenderItem, provider: AgentProvider): string { return 'interrupted while asleep' case 'transport-interruption': return 'interrupted before the response finished' - case 'gap-interruption': - return 'some live output was not captured' case 'empty': return `waiting for ${getRendererProviderCapabilities(provider).name}…` } @@ -181,7 +172,6 @@ function slotForItem(item: FeedRenderItem): DebugVisibleRow['slot'] { case 'work': case 'sleep-interruption': case 'transport-interruption': - case 'gap-interruption': return 'work' case 'empty': return 'empty' diff --git a/src/renderer/src/features/feed/ui/Feed.tsx b/src/renderer/src/features/feed/ui/Feed.tsx index 5fc33e4be..f5ec71a41 100644 --- a/src/renderer/src/features/feed/ui/Feed.tsx +++ b/src/renderer/src/features/feed/ui/Feed.tsx @@ -1170,19 +1170,6 @@ function FeedImpl({
) - case 'gap-interruption': - // #1381: events between the proxy and the app were LOST (the poller - // stalled through the addon's rotations, claude-code-headless#64), so - // the live text above skipped part of this answer. The wording claims - // only that: the saved transcript is Claude's own file and may well be - // complete, and the gap says nothing about why the stream ended. - return ( - -
- Some live output was not captured -
-
- ) case 'empty': return (
Led streamPhaseIdle: boolean sleepInterruptedTurnId: string | null transportInterruptedTurnId: string | null - gapInterruptedTurnId: string | null provider: AgentProviderKind candidates: readonly RenderCandidate[] } | null = null @@ -383,25 +382,17 @@ export function createLedgerInputAdapter(): (slices: RuntimeLedgerSlices) => Led newestTurn?.interruption === 'transport-error' && newestTurn.endedAt !== null ? newestTurn.turnId : null - // #1381: and for a turn sealed because the proxy events transport lost a - // span of its chunks. Its own key for the same reasons as the two above. - const gapInterruptedTurnId = - newestTurn?.interruption === 'transport-gap' && newestTurn.endedAt !== null - ? newestTurn.turnId - : null if ( !staticsCache || staticsCache.streamPhaseIdle !== streamPhaseIdle || staticsCache.sleepInterruptedTurnId !== sleepInterruptedTurnId || staticsCache.transportInterruptedTurnId !== transportInterruptedTurnId || - staticsCache.gapInterruptedTurnId !== gapInterruptedTurnId || staticsCache.provider !== provider ) { staticsCache = { streamPhaseIdle, sleepInterruptedTurnId, transportInterruptedTurnId, - gapInterruptedTurnId, provider, candidates: collectLifecycleCandidates({ provider, @@ -409,7 +400,6 @@ export function createLedgerInputAdapter(): (slices: RuntimeLedgerSlices) => Led streamPhaseIdle, sleepInterruptedTurnId, transportInterruptedTurnId, - gapInterruptedTurnId, }), } } diff --git a/src/renderer/src/rendering/model/types.ts b/src/renderer/src/rendering/model/types.ts index 3585309f7..b4ae3077c 100644 --- a/src/renderer/src/rendering/model/types.ts +++ b/src/renderer/src/rendering/model/types.ts @@ -74,11 +74,6 @@ export type RenderContentKind = * an Esc interrupt, a proxy timeout, an upstream failure. The provider * cannot tell those apart, so the row says only that it was cut off. */ | 'transport-interruption' - /** The same marker for a turn the proxy adapter sealed because the events - * transport LOST a span of its chunks (#1381, claude-code-headless#64 - * `transport-gap`). The live text on screen skipped part of the answer; the - * saved transcript is a separate file and is not implied to be incomplete. */ - | 'gap-interruption' | 'empty' | 'unknown' diff --git a/src/renderer/src/rendering/observations/local.ts b/src/renderer/src/rendering/observations/local.ts index ad6bf6462..46188741f 100644 --- a/src/renderer/src/rendering/observations/local.ts +++ b/src/renderer/src/rendering/observations/local.ts @@ -77,9 +77,6 @@ export function collectLifecycleCandidates(params: { sleepInterruptedTurnId?: string | null /** The same, for a turn whose stream died before it finished (#1040). */ transportInterruptedTurnId?: string | null - /** The same, for a turn sealed because the proxy events transport lost a - * span of its chunks (#1381). */ - gapInterruptedTurnId?: string | null }): RenderCandidate[] { const out: RenderCandidate[] = [] if (!params.streamPhaseIdle) { @@ -125,22 +122,6 @@ export function collectLifecycleCandidates(params: { timestampMs: null, sequence: 1, }) - } else if (params.gapInterruptedTurnId) { - // #1381: the turn was sealed because events were LOST between the proxy and - // the app (a stalled poller outran the addon's rotation). Checked last: a - // turn carries exactly one interruption, so the order only matters for - // stating it — sleep and a dead socket are explanations of why the stream - // ended, a gap is only a statement that we did not see all of it. - out.push({ - id: `gap-interruption:${params.gapInterruptedTurnId}`, - owner: 'work', - provider: params.provider, - sourcePlane: 'process', - sessionId: params.sessionId, - contentKind: 'gap-interruption', - timestampMs: null, - sequence: 1, - }) } return out } diff --git a/src/renderer/src/rendering/replay/invariants.ts b/src/renderer/src/rendering/replay/invariants.ts index 5301e72e2..70164d106 100644 --- a/src/renderer/src/rendering/replay/invariants.ts +++ b/src/renderer/src/rendering/replay/invariants.ts @@ -45,7 +45,6 @@ function isLifecycleRow(row: RenderRow): boolean { // toggles with the same phase edge, so it is lifecycle, not content. return kind === 'work' || kind === 'empty' || kind === 'sleep-interruption' || kind === 'transport-interruption' - || kind === 'gap-interruption' } export type InvariantKind = diff --git a/src/renderer/src/session-runtime/state.ts b/src/renderer/src/session-runtime/state.ts index 3cde0ad04..14ffa623b 100644 --- a/src/renderer/src/session-runtime/state.ts +++ b/src/renderer/src/session-runtime/state.ts @@ -295,11 +295,8 @@ export type SemanticLiveTurn = { * `system-suspended`: the machine slept mid-stream (#963). * `transport-error`: the stream's socket died before the message ended — * an Esc interrupt, a proxy timeout, an upstream failure (#1040). The - * provider cannot tell those apart, so neither does this. - * `transport-gap`: the proxy events transport LOST a span of this turn's - * chunks (claude-code-headless#64 rotated them away unread), so what the - * live view assembled is not the whole answer (#1381). */ - interruption?: 'system-suspended' | 'transport-error' | 'transport-gap' + * provider cannot tell those apart, so neither does this. */ + interruption?: 'system-suspended' | 'transport-error' } export type SemanticFlow = { From f249ae4bb5866cc6265b38b9eababb0a6426cc27 Mon Sep 17 00:00:00 2001 From: Julius Olsson Date: Sun, 27 Sep 2026 08:17:03 -0700 Subject: [PATCH 05/13] fix(claude): a durable feed row where the proxy transport lost live output (#1381) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Option B, OWNER-APPROVED (B6 proxy, 2026-09-27): lost data is never hidden. ClaudeSession seals the adapter at the gap (claude-code-headless#69 places it in order); SessionManager records each gap per provider conversation (TransportGapLedger, in memory, bounded) and emits it; the tap forwards session:transport-gap; the initial history chunk carries the conversation's gaps so every feed rebuild gets them back. The renderer merges both paths by id and paints one muted row, 'Part of this response was not captured (HH:MM:SS–HH:MM:SS)', placed by time among the entries through the notice candidates (no new render owner). The fold keeps interruption 'transport-gap' on the sealed turn. Co-Authored-By: Claude Opus 5.5 --- .../2026-09-27-claude-proxy-gap-marker.md | 46 +++++++ packages/claude-code-headless | 2 +- src/main/ipc/session.ts | 8 +- src/main/remote/protocol/messages.ts | 3 + src/main/sessionManager.proxyGap.test.ts | 50 ++++++- src/main/sessionManager.ts | 37 +++++- src/main/sessions/sessionFeedTap.ts | 10 ++ src/main/sessions/transportGapLedger.test.ts | 36 +++++ src/main/sessions/transportGapLedger.ts | 64 +++++++++ src/preload/api/session.ts | 3 + src/preload/api/types.ts | 1 + .../runtime/claudeSession.suspension.test.ts | 25 +++- src/providers/claude/runtime/claudeSession.ts | 16 ++- src/remote-client/src/WebSocketSessionFeed.ts | 9 ++ src/remote-client/src/wire.ts | 3 + .../feed/ledger/ledgerFeedItems.test.ts | 2 + .../features/feed/ledger/ledgerFeedItems.ts | 6 + .../feed/ledger/transportGapRow.test.ts | 124 ++++++++++++++++++ .../feed/ledger/useLedgerFeedItems.ts | 2 + .../src/features/feed/lib/transportGapText.ts | 29 ++++ .../src/features/feed/model/renderModel.ts | 8 ++ src/renderer/src/features/feed/ui/Feed.tsx | 14 ++ .../features/sessionFeed/FakeSessionFeed.ts | 5 + .../features/sessionFeed/IpcSessionFeed.ts | 1 + .../rendering/adapter/collectLedgerInput.ts | 23 +++- src/renderer/src/rendering/model/types.ts | 8 ++ .../rendering/observations/transportGaps.ts | 38 ++++++ .../src/session-runtime/semantic/foldEvent.ts | 4 + src/renderer/src/session-runtime/state.ts | 37 +++++- .../actions/initialHistory.renderer.test.tsx | 25 ++++ .../workspace/hook/actions/initialHistory.ts | 7 +- .../ipc/useIpcSubscriptions.renderer.test.tsx | 41 ++++++ .../workspace/hook/ipc/useIpcSubscriptions.ts | 19 ++- .../sessionFeed/SessionFeed.contract.ts | 1 + src/shared/sessionFeed/SessionFeed.ts | 3 + src/shared/sessionFeed/types.ts | 7 + src/shared/types/session.ts | 31 +++++ 37 files changed, 721 insertions(+), 27 deletions(-) create mode 100644 src/main/sessions/transportGapLedger.test.ts create mode 100644 src/main/sessions/transportGapLedger.ts create mode 100644 src/renderer/src/features/feed/ledger/transportGapRow.test.ts create mode 100644 src/renderer/src/features/feed/lib/transportGapText.ts create mode 100644 src/renderer/src/rendering/observations/transportGaps.ts diff --git a/docs/plans/2026-09-27-claude-proxy-gap-marker.md b/docs/plans/2026-09-27-claude-proxy-gap-marker.md index 4c3dffb46..8a443f388 100644 --- a/docs/plans/2026-09-27-claude-proxy-gap-marker.md +++ b/docs/plans/2026-09-27-claude-proxy-gap-marker.md @@ -221,3 +221,49 @@ 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. + +## 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 (supersedes "held per session" and the reseed IPC above): 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. diff --git a/packages/claude-code-headless b/packages/claude-code-headless index f52fc82d9..8be9a7ae9 160000 --- a/packages/claude-code-headless +++ b/packages/claude-code-headless @@ -1 +1 @@ -Subproject commit f52fc82d9d185314992cd845dafce42a723b2248 +Subproject commit 8be9a7ae9176e36dfe37618ecd7ef42dedbc2f19 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..fd4d2b877 100644 --- a/src/main/sessionManager.proxyGap.test.ts +++ b/src/main/sessionManager.proxyGap.test.ts @@ -48,6 +48,10 @@ vi.mock('@main/storage/feedDebugLog.js', () => ({ })) class FakeAgentSession extends EventEmitter { + // The provider conversation this process is running (#1381 keys the durable gap rows by it). + conversationId: string | null = 'conv-1' + getProviderSessionId(): string | null { return this.conversationId } + async start(): Promise { this.emit('started', { projectDir: '/tmp/project' }) } @@ -78,15 +82,55 @@ 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.emit('proxy-transport-gap', { lostGenerations: 1, since: null, until: 5_000 }) + first.emit('exit', { exitCode: 0 }) + await manager.recover({ sessionId: 's1', kind: 'claude', cwd: '/tmp/project' }) + 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.emit('proxy-transport-gap', { lostGenerations: 1, since: 1, until: 2 }) + session.conversationId = '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. it('ignores a gap from a session that has been replaced', async () => { diff --git a/src/main/sessionManager.ts b/src/main/sessionManager.ts index d74cc0d27..11fe2d2f4 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 { @@ -635,6 +640,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 @@ -3353,15 +3362,25 @@ 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). With no conversation id yet the row is + // live-only: there is no history to rebuild it from. In practice the id is always known — + // a gap needs >= 1 GiB of this session's proxy traffic, long after its transcript exists. + const conversationId = this.getNativeConversationId(sessionId) + const record = conversationId + ? this.transportGaps.record(conversationId, fields) + : { id: `gap-live-${sessionId}-${gap.until}`, ...fields } + this.emit('proxy-transport-gap', { sessionId, gap: record }) }) session.on('transcript-diagnostic', (diagnostic: unknown) => { if (!ownsEntry()) return @@ -5637,6 +5656,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.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..d787bdfba --- /dev/null +++ b/src/main/sessions/transportGapLedger.ts @@ -0,0 +1,64 @@ +import 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 DURABLE feed-history row — "data loss is never hidden". 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 owner removed on-disk feed rows once + * already (the #1235 ghost log) and asked to be consulted before any come + * back. The always-on `claude.proxy_transport_gap` incident in the journal is + * the on-disk record; after an app restart the row is gone, the incident is + * not. + * + * 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. + */ +export const PER_CONVERSATION_CAP = 50 +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..9938a4f60 --- /dev/null +++ b/src/renderer/src/features/feed/ledger/transportGapRow.test.ts @@ -0,0 +1,124 @@ +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 { 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']) + }) + + 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: