Repository navigation
Read every JSONL record, name the call the dialog holds, and forget a Session that ended (#98) - #102
Merged
Merged
Conversation
… 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>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Closes #98. Three defects in #75's merged Claude-lane code (
37cf18e), all insideadapters/agent/; none touches a seam or the locked types. Sequenced after #76 as the issue required — #101 is in.Provenance
e70179a6609a4ca-5a9e-4bd4-9a68-9f8d8fa57635— 1 hard violation + 3 judgment items, all fixed (next: fix and stop)f33e936b-4d11-45a3-903f-2bacbc056751— 1 finding, CLOSED on the one re-review itsnextnamed; no RETAINED/REOPENEDrun:lines of.github/workflows/ci.ymlgreen (ruff check,ruff format --check,pytest: 1,394 passed). No Swift changed, so theshellgates are untouched.Each of the three tests the issue asked for was verified red on
mainand green here, plus a fourth from the spec review and one Codex-side test.1.
_parsesplit the JSONL on Unicode line separatorsstr.splitlinesalso breaks on U+2028, U+2029 and U+0085, andJSON.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 atool_result, which leaves the call it closes outstanding — a permanent falsePERMISSION.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_onpaired the transcript's tool with the dialog'sapproval_idwithout checking they were the same callOne assistant message may carry several
tool_useblocks. 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 theapproval_idbeside 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_asimplements the ruling in three branches:tool_nametool_namedetaildetailThe 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_handleparked a dialog namingBashbehind a record namingEditand assertedEditwon. 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 fullerdetailstands.3.
forget_sessionhad no callerIt is not on the
AgentAdapterseam, and Bridge Core's_session_endedonly 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
SessionEndedforgets 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._emitis the one funnel every event on this lane passes through,window.py:281included._drop_and_report_ended, so a third cannot be written that forgets to.thread/closed/thread/deletedalso gives the connection back — it was opened to watch a thread that no longer exists._connection_lostalready dropped its thread; this makes that the shared path rather than a coincidence.Codex's
TurnCacheneeded nothing:retain()already bounds it to the daemon's roster on everydiscover(codex/discovery.py:238). Codex's ownforget_sessionstill 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
urlinSUMMARY_FIELDS, the synchronous whole-file read on the loop, and aPERMISSIONwith noapproval_idbeing 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-2812and: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