Skip to content

fix(proxy): place a transport gap where the loss was, and seal what it cut - #69

Merged
Juliusolsson05 merged 5 commits into
mainfrom
fix/proxy-gap-position
Sep 27, 2026
Merged

Juliusolsson05 merged 5 commits into
mainfrom
fix/proxy-gap-position

Conversation

@Juliusolsson05

@Juliusolsson05 Juliusolsson05 commented Sep 27, 2026 •

Copy link
Copy Markdown
Owner

Refs Juliusolsson05/agent-code#1381. The app PR bumps this package and renders a durable "Part of this response was not captured (HH:MM:SS–HH:MM:SS)" feed row. The row is OWNER-APPROVED (B6 proxy, 2026-09-27): option B.

Problem

#64 reports generations deleted unread as transport-gap, but at the wrong place.

  • ProxyServer.pollEventsOnce emitted the gap before every line of the poll. The lost span actually sits after the held generation's unread tail and before .1/live, and one poll can settle twice.
  • A consumer that reacts to the gap therefore applied pre-gap events after reacting. The adapter could not react at all: no API existed, so the app only logged an incident. The live turn silently stitched post-gap frames onto a pre-gap answer.

Fix

  • EventsFilePoll.gaps: Array<{ index, lostGenerations }> says where each lost span sat. settleBelow records out.lines.length at entry, and lostGenerations stays as the total.
  • ProxyServer emits events and transport-gap in written order. The payload is now TransportGap = { lostGenerations, since, until }. That is the app-clock window the lost events were written in (wire events carry no timestamp): since = the previous poll STARTED reading, until = the gap was seen. Review a: the previous poll's finish time could postdate a loss that landed mid-poll. TransportGap is exported.
  • ClaudeProxyAdapter.sealFlowsForTransportGap() (synchronous):
    • a streaming turn is stopped with the new interruption: 'transport-gap';
    • a flow with a first chunk but no turn gives back its spinner;
    • a turn that already stopped (awaiting its tool) keeps its phase, the same rule as onTransportError;
    • every flow that streamed is then forgotten, so post-gap frames of a cut flow are ignored and a new request streams normally;
    • a flow that has seen only its request is kept until its first post-gap chunk (review b). If that chunk opens with message_start, even split across chunks, the response is whole and streams; one that begins mid-SSE forgets the flow. Held bytes are replayed raw, so a split multi-byte character survives;
    • the phase owner is sealed last (review b), so an awaiting-tool owner is not handed over to a concurrent streaming flow whose seal would publish idle.
  • API.md: the transport-gap event, the new method, and the interruption row. The last also gained the missing 'transport-error'.

Tests (fail-first; each failed before the fix)

  • eventsFileTail.test.ts:

    • "says where in the batch the lost generations sat": the real tail, held tail a2, then b lost, c at .1, d live, giving gaps: [{index: 1, lostGenerations: 1}];
    • "reports no gap positions in a run that lost nothing";
    • the ProxyServer test now asserts event:1, event:2, gap, event:4, event:5 and the since/until window.
  • ClaudeProxyAdapter.transportGap.test.ts (5):

    • seal and idle;
    • the rest of a sealed flow ignored;
    • a request-only flow forgotten while a new request streams;
    • a tool-awaiting turn keeps its phase;
    • no-op when nothing is tracked.

    The frames are synthetic in the recorded shape. Real mitm recordings are private conversation content; same convention as the client-disconnect test.

  • Mutations are caught: emitting all gaps first (1 red); sealing a stopped turn too (1 red).

  • Round 1 (a, b: FIX; c: MERGE-READY), each fix fail-first:

    • eventsFileTail.test.ts:
      • a real one-poll, two-settle tail run: two gaps at indexes 0 and 3. It kills a's survivors: a gap index clamped to 1, and only the first gap emitted;
      • a ProxyServer run on a controlled clock: since is at or before a loss that landed mid-poll, and both gaps are emitted in place;
      • a first-poll gap has since: null (c).
    • ClaudeProxyAdapter.transportGap.test.ts (10):
      • a request-only flow whose response starts after the gap streams; one that begins mid-SSE is forgotten;
      • a message_start split across chunks, including a split é, streams intact;
      • awaiting-tool survives a concurrent seal;
      • every concurrent flow is sealed;
      • a first-chunk flow's spinner is returned. The last two kill b's and c's survivors.
    • Mutations, each red:
      • since taken at the poll's finish;
      • first gap only;
      • a gap index clamped to 1;
      • owner not sealed last;
      • no idle for a first-chunk flow;
      • first flow only;
      • request-only flows forgotten;
      • a string round trip of held bytes.
    • EVENT_SPEC.md now lists every interruption (c).
  • npm run typecheck and npm run test:contract clean; npx vitest run --project core passes.

Coordination

#67 also edits src/proxy/proxyServer.ts and API.md, in different hunks. The two app pointer bumps land in sequence.

🤖 Generated with Claude Code

Juliusolsson05 and others added 4 commits September 27, 2026 07:58
…t cut (agent-code#1381)

EventsFileTail reports WHERE each lost span sat in its batch (gaps[].index);
ProxyServer emits transport-gap between the events written before and after
it, with the app-clock window {since, until} the lost events were written
in. ClaudeProxyAdapter.sealFlowsForTransportGap() stops a streaming turn as
interruption 'transport-gap', keeps a tool-awaiting turn's phase, and
forgets every tracked flow so post-gap frames are never stitched on.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…; seal the phase owner last (cch#69 review b)

- A flow that saw only its request is no longer forgotten on a gap: its
  first post-gap chunk decides. An opening message_start (even split across
  chunks) streams normally; a mid-SSE start forgets it. Held bytes are
  replayed raw, so a split multi-byte character survives.
- The phase owner is sealed last, so an awaiting-tool owner is no longer
  handed over to a concurrent streaming flow whose seal published idle.
- Pins both of review b's surviving mutations (idle for a first-chunk flow,
  every concurrent flow sealed). Five mutations, each red.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…gaps in one poll (cch#69 review a)

since was the previous poll's FINISH time, which could postdate a loss that
landed while that poll was still reading, so since..until could be narrower
than the loss. It is now the time before that poll read the file.

Pins review a's two surviving mutations with a real one-poll, two-settle
tail run (a gap index clamped to 1, and only the first gap emitted), plus the
since lower bound at the ProxyServer. Three mutations, each red.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…a first-poll gap (cch#69 review c)

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

Copy link
Copy Markdown
Owner Author

Round 1 disposition (head d325558)

Verdicts: a FIX-BEFORE-MERGE, b FIX-BEFORE-MERGE, c MERGE-READY.

Finding Disposition
a (major): since was the previous poll's FINISH time, which could postdate a loss that landed mid-poll Fixed (a3c335e). since is now when the previous poll started reading. A ProxyServer test on a controlled clock puts the loss inside the previous poll; it failed with expected 200 ≤ 150.
a (survivors): a gap index clamped to 1; only the first gap emitted Pinned (a3c335e). A real one-poll, two-settle tail run: rotations land at the poll's own stat(), giving gaps at indexes 0 and 3. The ProxyServer test emits a, gap, c, d, gap, f, g. Each mutation is 1 red.
b (blocker): a request-only flow lost its intact post-gap response Fixed (5977906). The flow is kept until its first post-gap chunk: an opening message_start (even split across chunks) streams, and a mid-SSE start forgets the flow. The held prefix is replayed as raw bytes; a test splits a chunk inside é, and a string round trip would corrupt it.
b (major): a concurrent seal cleared a stopped turn's awaiting-tool Fixed (5977906). The phase owner is sealed last. Pinned with b's sequence.
b/c (survivors): no idle for a first-chunk flow; only the first flow sealed Pinned (5977906). Each is 1 red.
c1: the since test was racy Superseded: the new controlled-clock test is deterministic.
c2: spinner return untested Pinned (above).
c3: multi-gap and since: null untested Pinned (above), plus a first-poll since: null test (d325558).
c4: EVENT_SPEC.md lagged two interruptions Fixed (d325558).

Checks: typecheck, contract and vitest --project core all pass. Next: a capped verification pass for a and b.

🤖 Generated with Claude Code

@Juliusolsson05

Copy link
Copy Markdown
Owner Author

B6 gate record: GATE PASS #69 (b8aabae, 0 behind main after the #68 merge, checks green, reviews OK). Merging with --merge.

@Juliusolsson05
Juliusolsson05 merged commit 0928344 into main Sep 27, 2026
6 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant