Skip to content

fix(claude): a feed row, kept for the app run, where the proxy transport lost live output - #1442

Merged
Juliusolsson05 merged 14 commits into
integration/batch-2026-09-27-sfrom
fix/claude-proxy-gap-marker
Sep 27, 2026
Merged

Juliusolsson05 merged 14 commits into
integration/batch-2026-09-27-sfrom
fix/claude-proxy-gap-marker

Conversation

@Juliusolsson05

@Juliusolsson05 Juliusolsson05 commented Sep 27, 2026 •

Copy link
Copy Markdown
Owner

Fixes #1381

OWNER-APPROVED (B6 proxy, 2026-09-27; q119 amended by q120), recorded 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 gap row survives later turns in this app run, but not an app restart (see #1445). Within the run it also comes back whenever the conversation's feed is rebuilt: a window reload, an agent reload or crash respawn, or a resume in another pane. It is bounded to 50 gaps per conversation and 500 conversations. The journal's claude.proxy_transport_gap incident stays a diagnostic copy only.

Decision 3 (the spinner after a seal): option A, OWNER-APPROVED (B6 proxy, 2026-09-27). The phase goes idle at the seal, the #1040/#963 rule: the feed never claims work it has no live evidence of. The next request, or the transcript row, brings the indicator back.

Uses claude-code-headless#69, merged. The submodule points at its merge commit 0928344e. There is no lockfile to resync: the app resolves the package through a path alias.

What was wrong

  • claude-code-headless#64 reports proxy events rotated away unread as transport-gap. The app recorded an incident and re-emitted it, but nothing consumed that.
  • The live Claude turn stitched post-gap frames onto a pre-gap answer, and the user saw one continuous response.
  • The package also emitted the gap before the whole poll's lines instead of at the loss, so reacting to it correctly was impossible.

Fix

  • Package (feat(dispatch): attach-all in grid mode + project terminal setting #69):
    • the gap is emitted in order, with an app-clock window {since, until};
    • ClaudeProxyAdapter.sealFlowsForTransportGap() stops a streaming turn with interruption: 'transport-gap', and keeps a tool-awaiting turn's phase, even alongside a concurrent seal;
    • it forgets the flows that streamed, so post-gap frames are never stitched on;
    • a flow that has seen only its request is kept, and streams if its first post-gap chunk opens with message_start;
    • since is the previous poll's start time, a true lower bound (feat(dispatch): attach-all in grid mode + project terminal setting #69 round 1).
  • ClaudeSession: seals the adapter at the gap, before re-emitting it and before any post-gap event.
  • SessionManager:
    • records each gap in TransportGapLedger, per provider conversation, in memory, 50 per conversation and 500 conversations;
    • emits {sessionId, gap};
    • the always-on incident now carries since/until.
  • Delivery:
    • session:transport-gap goes through the session feed tap, so the remote protocol and phone wire get it as an additive channel;
    • session:load-initial-history returns the conversation's gaps in SessionHistoryChunk.transportGaps. That load is the one every feed rebuild makes: a window reload, an agent reload or crash respawn, or a resume in another pane.
  • Renderer:
    • runtime.transportGaps merges the live and history paths by record id;
    • collectTransportGaps makes one provider-notice-owner candidate per gap, placed at since ?? until (no new render owner);
    • one feed item, transport-gap, rendered as the existing muted MarkerRow: "Part of this response was not captured (HH:MM:SS–HH:MM:SS)";
    • the fold keeps interruption: 'transport-gap' on the sealed turn.

Rulings (see the plan's Execution notes)

Tests (fail-first)

  • Package: see feat(dispatch): attach-all in grid mode + project terminal setting #69 (merged after its own round 1 and verification). At 0928344e this PR's npx tsc -b is clean, and the Claude provider, main sessions and workspace-hook suites pass 739/739.
  • claudeSession.suspension.test.ts: the order is event → seal → re-emit → event.
  • sessionManager.proxyGap.test.ts:
    • incident plus record;
    • held for the conversation across a respawn under the same id;
    • does not follow the pane into a new conversation.
  • transportGapLedger.test.ts: order, unique ids, both bounds.
  • transportGapRow.test.ts, driving the REAL adapter → fold → ledger → view bridge:
    • seal and interruption;
    • the row between the entries around the loss;
    • it stays after later turns;
    • rebuild merge without duplicates;
    • the sentence.
  • initialHistory.renderer.test.tsx: the real 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.
  • Mutations caught:
    • the bridge dropping the row (2 red);
    • no gap candidates (2 red);
    • the fold dropping the interruption (1 red);
    • history ingest dropping gaps (1 red).
  • npx tsc -b clean. Scoped suites pass 912/912 across 117 files: feed, rendering, session-runtime, sessionFeed, main sessions, Claude runtime, remote, remote-client, and the two hook tests.

Boundary and follow-ups

Both are separate surfaces from #1381's desktop feed. #1381 closes with this PR, and these two stay open.

Review round 1 (a FIX, b MERGE-READY, c FIX), each fixed fail-first

  • Keying (a, blocker): ClaudeSession has no getProviderSessionId, so every real gap was live-only and gone on reload; the test fake hid it. The manager now keys each gap by the sessionId of the Claude conversation's committed JSONL entries, the same value the renderer sends back. It falls back to the resume id before the first entry, and is cleared at teardown. The fake now matches the real session (899a9118).
  • Live-only ids are a sequence, not until, so two gaps of one poll are two rows (899a9118).
  • One cap (TRANSPORT_GAPS_PER_CONVERSATION, 50) is shared by main and the live feed (5d4502d2).
  • Pinned:
    • the tap's flush before the row (5d4502d2);
    • the session:load-initial-history attach, through the real preload call;
    • a reused adapter's repaint for a gap that arrives alone;
    • the real Feed row (3079e3d0).
  • Each fix's mutation is red. See the disposition comment and the plan's round-1 sections.

Merge with main

🤖 Generated with Claude Code

Juliusolsson05 and others added 5 commits September 27, 2026 07:45
#1381)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…#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 <noreply@anthropic.com>
… B6)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The owner-approved call (B6 proxy, 2026-09-27) is a DURABLE feed-history
row, and 'one row kind'. The work-slot marker from 91b05b0 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 <noreply@anthropic.com>
…utput (#1381)

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 <noreply@anthropic.com>
…119)

Outcome, decision 5, Change and Tests now state the per-conversation ledger
carried by the initial history chunk and the durable row; the #69 hold and
the filed residuals (#1443, #1444) are recorded.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Juliusolsson05 and others added 2 commits September 27, 2026 08:24
#1445)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…option A

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@Juliusolsson05

Copy link
Copy Markdown
Owner Author

OWNER-APPROVED (B6 proxy, 2026-09-27; answers file w3-answers-1628): decision 3 is option A. After a transport gap seals a streaming turn, the working indicator goes idle for the rest of that one response. This is the #1040/#963 rule: the feed never claims work it has no live evidence of. The next request, or the transcript row, brings it back.

This was the only UNCONFIRMED item on #1442. The row's lifetime (the app run, not restarts; #1445) is also owner-approved, and quoted verbatim in the body.

🤖 Generated with Claude Code

Juliusolsson05 and others added 4 commits September 27, 2026 13:41
… hold lifted)

#69 merged after its own round 1 (since is a poll-start lower bound; a
request-only flow that provably starts after the gap streams; the phase owner
is sealed last). The app API is unchanged; no lockfile entry to resync.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…transcript; distinct live-only ids (#1442 review a)

ClaudeSession has no getProviderSessionId, so getNativeConversationId was
always null for Claude: every real gap took the live-only path and its row
vanished on the first reload (the test fake implemented the getter). The
manager now records each committed Claude entry's sessionId, the same value
the renderer sends back as providerSessionId, with the resume id as the
fallback; cleared at teardown. Live-only ids are a sequence: two gaps of one
poll share its until and were merged away.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…s flush before a gap row (#1442 review b)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…int and the real Feed row; fix since docs (#1442 review c)

- session:load-initial-history's transportGaps attach, through the real
  preload call and a real ledger (returning the plain chunk: red).
- A gap arriving alone repaints a reused adapter (no gaps identity check: red).
- The real Feed renders the row's sentence (return null for it: red).
- TransportGapRecord and decision 5 say since = the previous poll's start.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@Juliusolsson05

Copy link
Copy Markdown
Owner Author

Round 1 disposition (head 3079e3d0)

Verdicts: a FIX-BEFORE-MERGE, b MERGE-READY, c FIX-BEFORE-MERGE. Every fix is fail-first.

Finding Disposition
a (blocker): no real Claude gap was ever held for its conversation. ClaudeSession has no getProviderSessionId, and the test fake implemented one, so every real gap took the live-only path and vanished on reload. Fixed (899a9118). See the details below the table.
a (major): two gaps of one poll shared a live-only id (…-<until>), so the renderer's merge hid one Fixed (899a9118). Live-only ids are a sequence.
b (minor): the live feed had no cap; main keeps 50 Fixed (5d4502d2). One TRANSPORT_GAPS_PER_CONVERSATION in shared types, used by the ledger and mergeTransportGaps (1 red).
b/c (survivor): the tap's flush before the row Pinned (5d4502d2). A buffered semantic event reaches the sink before the row (1 red).
c (major): the IPC delivery of the rows (session:load-initial-history) was untested Pinned (3079e3d0). Driven through the real preload call with a real ledger: the rows ride the chunk, and a conversation that lost nothing gets no key. Returning the plain chunk is 1 red.
c (minor): the notice-cache gaps check Pinned (3079e3d0). A reused adapter repaints for a gap that arrives alone (1 red).
c (minor): the real row was never rendered Pinned (3079e3d0). The real Feed renders the sentence; returning null for the row is 1 red.
c (minor): stale since docs Fixed (3079e3d0). The type doc and decision 5 now say the previous poll's START.
Residuals Stated in the plan's round-1 sections.

The blocker fix in detail:

  • Keying: the manager records the sessionId of each committed Claude entry, the same value the renderer sends back as providerSessionId. It follows /clear.
  • Fallback: the resume id, before the first entry. The value is cleared at teardown.
  • Ruling: no getProviderSessionId on ClaudeSession, because it would change backend-snapshot and spawn reporting for every Claude pane.
  • Tests: the fake now matches the real session. Four mutations are each red.

The residuals:

  • the mergeTransportGaps sort is a near-equivalent mutant;
  • the package's no-turn forget (P7) is a test gap in merged claude-code-headless#69;
  • b's and c's suspicions are unranked, reasons recorded.

Checks: npx tsc -b clean. The main IPC, sessions, feed and rendering suites pass 438/438.

Next: a capped verification pass for a and c.

🤖 Generated with Claude Code

@Juliusolsson05

Copy link
Copy Markdown
Owner Author

OWNER-APPROVED: both owner-held decisions in this PR were approved by B6 as owner proxy on 2026-09-27. This comment re-records them in the merge gate's format; nothing new is decided here.

  1. Gap-row lifetime (decision 5). Source: temp/manager/assign/w3-q119.md, the q120 amendment. Verbatim: "Option B, bounded to the main-process lifetime; restart durability needs a dedicated store and is an owner decision (1445), related to refactor: remove the on-disk ghost log #1235." Restart durability stays open as feat(feed): a transport-gap marker that survives an app restart (follow-up to #1381 / #1442) #1445.
  2. Spinner after a seal (decision 3). Source: temp/manager/assign/w3-answers-1628.md, item 1. Verbatim: "fix(claude): a feed row, kept for the app run, where the proxy transport lost live output #1442 spinner: option A (your recommendation), OWNER-APPROVED (B6 proxy, 2026-09-27)."

🤖 Generated with Claude Code

…marker

# Conflicts:
#	src/renderer/src/workspace/hook/actions/initialHistory.renderer.test.tsx
#	src/renderer/src/workspace/hook/actions/initialHistory.ts
@Juliusolsson05 Juliusolsson05 changed the title fix(claude): a durable feed row where the proxy transport lost live output fix(claude): a feed row, kept for the app run, where the proxy transport lost live output Sep 27, 2026
@Juliusolsson05
Juliusolsson05 changed the base branch from main to integration/batch-2026-09-27-s September 27, 2026 21:59
@Juliusolsson05
Juliusolsson05 merged commit ab0c59e into integration/batch-2026-09-27-s Sep 27, 2026
2 checks passed
@Juliusolsson05
Juliusolsson05 deleted the fix/claude-proxy-gap-marker branch September 27, 2026 21:59
Juliusolsson05 added a commit that referenced this pull request Sep 27, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

class:C3-silent-failure The app knows it failed and does not say sev:P3 Minor type:bug Something works wrong

Projects

None yet

Development

Successfully merging this pull request may close these issues.

claude feed: show a missing-span marker when the proxy transport reports a gap

1 participant