Skip to content

Read every JSONL record, name the call the dialog holds, and forget a Session that ended (#98) - #102

Merged
Simon-AI-coding merged 1 commit into
mainfrom
issue-98-claude-lane-defects
Aug 26, 2026
Merged

Simon-AI-coding merged 1 commit into
mainfrom
issue-98-claude-lane-defects

Conversation

@Simon-AI-coding

Copy link
Copy Markdown
Owner

Closes #98. Three defects in #75's merged Claude-lane code (37cf18e), all inside adapters/agent/; none touches a seam or the locked types. Sequenced after #76 as the issue required — #101 is in.

Provenance

Base e70179a
Standards review 6609a4ca-5a9e-4bd4-9a68-9f8d8fa57635 — 1 hard violation + 3 judgment items, all fixed (next: fix and stop)
Spec review f33e936b-4d11-45a3-903f-2bacbc056751 — 1 finding, CLOSED on the one re-review its next named; no RETAINED/REOPENED
Acceptance not run — the requester asked for no unified real-machine acceptance on this one
Gates the four Python run: lines of .github/workflows/ci.yml green (ruff check, ruff format --check, pytest: 1,394 passed). No Swift changed, so the shell gates are untouched.

Each of the three tests the issue asked for was verified red on main and green here, plus a fourth from the spec review and one Codex-side test.

1. _parse split the JSONL on Unicode line separators

str.splitlines also breaks on U+2028, U+2029 and U+0085, and JSON.stringify — which writes these files — leaves all three raw inside a string. A record carrying one was cut in two, neither half parsed, and the record was dropped whole. The docstring's "costs itself and nothing else" then costs a tool_result, which leaves the call it closes outstanding — a permanent false PERMISSION.

Re-measured on 2026-08-26, as the issue asked, over the 200 most recent transcripts in ~/.claude/projects: 4 files losing 31 records between them (1, 2, 13 and 15). The issue's reviewer reported 2 files and 24 records; the machine has moved on since. After the fix those four read 276, 352, 116 and 578 records.

JSONL is newline-delimited, so the split is on "\n" and nothing else.

2. stopped_on paired the transcript's tool with the dialog's approval_id without checking they were the same call

One assistant message may carry several tool_use blocks. The transcript reading is held up on the newest outstanding one; the dialog is parked on whichever call the far side actually stopped to ask about; and the approval_id beside them belongs to the dialog's. Announcing the record's pick would name one tool while the verdict the user gives goes to another — an Approval carrying their authority to a call they were never shown.

The hook payload is the authority on what is parked (advisor, 2026-08-26). _announced_as implements the ruling in three branches:

dialog tool_name record tool_name announced
absent anything the record's, with the dialog filling a gap (#75, unchanged)
present the same the dialog's name, the record's detail
present different or absent the dialog's name and the dialog's detail

The third row's "or absent" is the spec review's one finding. Naming no tool is not agreeing with the dialog about which call it is: a record that could not read a tool's name could still summarise its input, and that summary describes whichever call the record was held up on. The rest of #75's precedence is untouched — a question still wins outright.

One existing #75 test changed, and it had to. test_a_permission_read_from_the_record_keeps_its_fields_and_gains_the_handle parked a dialog naming Bash behind a record naming Edit and asserted Edit won. That is the defect, stated as a test. It now parks a dialog naming the same call — the ordinary case — and still asserts the record's fuller detail stands.

3. forget_session had no caller

It is not on the AgentAdapter seam, and Bridge Core's _session_ended only marks state. On an engine that starts at login, every Session that ever registered kept its channel address, its Reply Window and its parsed transcript for the life of the process — records of files measured at 186 MB on this machine.

Root-cause fix, per the issue: no seam method, no timer. The adapter that emits SessionEnded forgets the target itself, on both lanes, and before the sink sees the event, so it is never holding a route to a Session it has already declared ended.

  • Claude: _emit is the one funnel every event on this lane passes through, window.py:281 included.
  • Codex: both emitters now go through one _drop_and_report_ended, so a third cannot be written that forgets to. thread/closed/thread/deleted also gives the connection back — it was opened to watch a thread that no longer exists. _connection_lost already dropped its thread; this makes that the shared path rather than a coincidence.

Codex's TurnCache needed nothing: retain() already bounds it to the daemon's roster on every discover (codex/discovery.py:238). Codex's own forget_session still has no caller and is left alone — it is the public "stop watching without declaring death" verb, and deleting a public method is not what the issue asked for.

Not in scope, as the issue ruled

url in SUMMARY_FIELDS, the synchronous whole-file read on the loop, and a PERMISSION with no approval_id being announced (#77's policy) — all unchanged.

Standards review, for the record

The hard violation was in the commit message, not the code: the newline split restores a behaviour legacy already had, so CLAUDE.md's rule wants it labelled ported, and the first draft attached "dropped, because …" to the error taxonomy instead. Verified in the reference implementation and corrected: legacy split on byte newlines only, in both readers (legacy@1d32845:bridge/transcript.py:2795-2812 and :1936-1961). The three judgment items were naming: _named_by → _announced_as, _let_go_of → _drop_and_report_ended, and Codex's _retrying → _background, whose contents stopped being only retries the moment a connection hand-back was spawned into it.

🤖 Generated with Claude Code

… Session that ended (#98)

Three defects in #75's merged Claude-lane code (`37cf18e`), all inside the
adapters and none touching a seam or the locked types.

**`_parse` split the JSONL on Unicode line separators.** `str.splitlines` also
breaks on U+2028, U+2029 and U+0085, and `JSON.stringify` — which writes these
files — leaves all three raw inside a string. A record carrying one was cut in
two, neither half parsed, and the record was dropped whole; a dropped
`tool_result` leaves the call it closes outstanding, which reads as a permanent
false `PERMISSION`. Re-measured on 2026-08-26 over the 200 most recent
transcripts on this machine: 4 files losing 31 records between them. JSONL is
newline-delimited, so the split is on `"\n"` and nothing else.

**`stopped_on` paired the transcript's tool with the dialog's `approval_id`
without checking they were the same call.** One assistant message may carry
several `tool_use` blocks: the transcript reading is held up on the newest
outstanding one, the dialog is parked on whichever the far side stopped to ask
about, and the handle belongs to the dialog's. Announcing the record's pick
beside it would name one tool and carry the user's verdict to another. The hook
payload is now the authority on what is parked (advisor, 2026-08-26): its
`tool_name` wins, and the record's `detail` is kept only while the record named
the *same* tool — naming none is not agreeing, because a record that could not
read a tool's name could still summarise its input. #75's precedence is
otherwise unchanged: a question wins outright, and a dialog that named no tool
contradicts nothing and leaves fill-a-gap-never-overwrite standing.

**`forget_session` had no caller**, so nothing ever evicted a dead Session's
channel address, window or parsed transcript — records of files measured at
186 MB on this machine, held for the life of a login-start daemon. No seam
method and no timer: the adapter that emits `SessionEnded` forgets the target
itself, on both lanes, before the sink sees the event. On the Codex side both
emitters now go through one `_drop_and_report_ended`, so a third cannot be
written that forgets to.

## Against legacy (ADR 0010)

- **Splitting on `"\n"` and nothing else: ported.** Legacy split on byte
  newlines and only those, in both of its readers — the forward one iterates a
  file object opened `newline=""` (`legacy@1d32845:bridge/transcript.py:
  2795-2812`), the reverse one searches for `b"\n"` (`:1936-1961`) — so neither
  can break on U+2028. The behaviour is legacy's; v2 lost it when the read was
  re-expressed with `splitlines()`, and this restores it. What stays **dropped**
  is the error taxonomy around it, with the rest of #74's durable state: legacy
  raised on a line that would not parse, and v2 skips it.
- **Pairing the announcement with the dialog: no legacy counterpart.** Legacy
  had no second source to rank a transcript against. The `approval_id` and the
  parked dialog are v2's, from a hook legacy did not have — it scraped the tool
  name out of an English Notification sentence
  (`legacy@1d32845:bridge/daemon.py:143-145,213-223,2049-2051`) and had no
  handle to mis-pair. #75 recorded that whole route as **adapted**; this repairs
  the adaptation.
- **Evicting a Session that ended: no legacy counterpart.** Legacy kept no
  per-Session transcript cache on this lane at all — it read a Claude transcript
  only when a stop asked it to, on demand
  (`legacy@1d32845:bridge/daemon.py:2115-2167`) — so it had nothing to evict and
  no eviction to port. The cache is v2's, and so was the leak.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@Simon-AI-coding
Simon-AI-coding merged commit da89dbd into main Aug 26, 2026
4 checks passed
@Simon-AI-coding
Simon-AI-coding deleted the issue-98-claude-lane-defects branch August 26, 2026 08:39
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.

Claude lane: JSONL split on U+2028, approval_id paired with the wrong tool, and dead Sessions never evicted

1 participant