Bug-hunt round 32: unmergeable-once-wrapped capture records, dual-recorder misdiagnosis - #48
Merged
Conversation
… record POST /api/interactions and transcribe checked only a captured record's own JSONL line length against the 4 MiB limit. merge re-frames each record into a larger timeline entry (a src/id/payload envelope, timeline.BuildEntries) that session.WriteJSONL checks again at merge time — so a record within the raw line limit could still wrap into an entry over it, get durably persisted at 204/exit 0, and then be permanently unreadable by merge, report, and analyze, with no CLI-level repair. WriteJSONL's encoder also HTML-escaped <, >, and & into six-byte \uXXXX sequences while compactLine (the capture-side line canonicaliser) does not, so escaping alone could inflate a record's wrapped entry up to sixfold — pulling the failure window from a ~30-byte sliver at the very top of the range down to any record from roughly 700 KiB up. New timeline.EventEntry/SpeechEntry build the entry a record becomes, and session.EncodedLen measures it exactly as WriteJSONL will; both capture paths check that measurement before accepting a record. WriteJSONL's own encoders no longer escape HTML, matching compactLine's existing behaviour. Assisted-by: Claude:claude-sonnet-5
When a recorder exited on its own, Run excluded only that single child (if c != dead) from the missing-output sweep, on the reasoning that its own exit already disproves classifyMissingOutput's stayed-blocked-on- the-prompt narrative. That exclusion covered exactly one child: if a second recorder also exited on its own at the same moment — a TCC-denied microphone alongside a screen recorder whose ffmpeg build lacks a needed codec, say — it still fell through to classifyMissingOutput, reporting a recorder that had demonstrably exited as still blocked on a permission prompt, with its real exit status never surfaced. Run now samples every child whose done channel is already closed the moment the exit is observed, before stopAll's SIGINT reaches the others (after which a live recorder's clean shutdown becomes indistinguishable from a self-exit). Every such child is excluded from the sweep and gets its own classifyRecorderExit diagnosis instead. Assisted-by: Claude:claude-sonnet-5
Assisted-by: Claude:claude-sonnet-5
Adversarial review of the round's own PR caught a regression the fix for finding 2 introduced: the sibling early-exited child's atStartup was sampled after stopAll/stopDemo ran, reopening the exact "slow stop poisons the start-up classification" bug dead's own atStartup is deliberately sampled before stopAll to avoid (see the comment and TestRunClassifiesStartupExitDespiteSlowStop). A start-up TCC denial on the non-dead recorder was reported as an unexpected mid-session device fault whenever the demo shutdown or the other child's stop grace ran long. Both reviewers reproduced it independently. Fixed by sampling atStartup for every early-exited child in the same pre-stopAll loop that detects them, alongside a new regression test. Also folds in the reviews' non-blocking findings: a garbled eventIDGrowthMargin rationale comment, "permanently unreadable" overstating the failure mechanism (merge refuses to *write* the timeline entry; report/analyze never see one, they don't "refuse" it), and the docs' "internal id and time-scale envelope" gloss naming the one component that doesn't cause the overflow, replaced with an accurate description linking the documented timeline.jsonl schema. Assisted-by: Claude:claude-sonnet-5
Assisted-by: Claude:claude-sonnet-5
This was referenced Aug 7, 2026
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.
Round 32 of the autonomous bug-hunt loop (state tracked on #24).
Confirmed findings (substantive, 2)
1. A captured record could pass its own JSONL line-length check and still be permanently unreadable once
mergewraps it.internal/demo/demo.go'sPOST /api/interactionshandler andinternal/transcribe/transcribe.go's writer each checked only a record's own encoded size against the 4 MiBsession.MaxJSONLLinelimit. Butmergere-frames every accepted record into a timeline entry (internal/timeline/timeline.go'sBuildEntries: asrc/id/payloadenvelope, plus the time field rebased from epoch-ms to relative seconds) beforesession.WriteJSONLchecks that line against the same limit. The two checks measured different byte strings against the same bound, so a record within the raw line limit could still wrap into an entry over it.Reproduced end-to-end: a 4,194,303-byte interaction posted to a live
demoserver got204and was durably persisted tointeractions.jsonl; the followingmergefailed withline 1 of the output encodes to 4194334 bytes, over the 4194304-byte JSONL line limitand kept failing on every re-run —reportandanalyzethen failed too (timeline.jsonlnever exists), with no CLI-level repair. The same gap exists on the speech side:transcribecould write atranscript.jsonlits ownmergeimmediately refuses.A second, independent contributor made the window far wider than the envelope bytes alone:
session.WriteJSONL's encoder HTML-escaped<,>, and&into six-byte\uXXXXsequences by default, whilecompactLine(the capture-side line canonicaliser) does not escape at all. A record whose content happened to carry many of those three characters could see its wrapped entry inflate up to sixfold — pulling the practical failure window down from a ~30–40 byte sliver at the very top of the raw range to any record from roughly 700 KiB upward.Fix: new
timeline.EventEntry/timeline.SpeechEntrybuild the exact entry a record becomes, and newsession.EncodedLenmeasures a value exactly asWriteJSONLwill. Both capture paths now check that measurement, not just the record's own size, before accepting it.WriteJSONL's two encoders (the pre-flight check pass and the final writer) both now setSetEscapeHTML(false), matchingcompactLine's existing non-escaping behaviour and closing the escaping-amplification gap at its root.Reachability: the bundled demo page truncates captured
text/valueto 40/80 characters, so it cannot trigger this on its own — it needs a custom instrumented client posting near the documented 4 MiB cap (a supported, documented integration path — seedocs/how-to/instrument-your-own-app.md), or a hand-edited/exchanged session. Both refuters independently confirmed the mechanism, reproduced it end-to-end, and found it slightly worse than initially reported (the escaping amplification); see.abcd/work/DECISIONS.md's round 32 entry.2.
record -videomisdiagnosed a second recorder that also exited on its own.When a recorder exits before
recordasks it to stop,internal/record/record.go'sRunexcludes that one child (if c != dead) from the missing-output sweep, because its own exit disprovesclassifyMissingOutput's "stayed blocked on the permission prompt" narrative. The exclusion covered exactly one child. When a second recorder also exits on its own at the same moment — e.g. a TCC-denied microphone alongside a screen recorder whoseffmpegbuild lacks a needed codec — it still fell through toclassifyMissingOutput, reporting a recorder that had demonstrably exited as still blocked on a permission prompt, with its real exit status never surfaced at all.Both refuters reproduced this deterministically (both recorders'
donechannels closed beforeRunwas even invoked, removing the scheduling race): one run printed "screen capture failed to start — check the ffmpeg output" for the recorderanyExit'sselectpicked, and, in the same run, "the recorder stayed blocked on the permission prompt" for the other recorder — which had also already exited.Fix:
Runnow samples every child whosedonechannel is already closed the instant the exit is observed — beforestopAll'sSIGINTreaches the others, after which a live recorder's own clean shutdown becomes indistinguishable from a self-exit. Every early-exited child is excluded from the sweep and gets its ownclassifyRecorderExitdiagnosis printed to the operator's log; the single childanyExit'sselecthappened to pick remains the command's one returned error, as before.Considered and rejected (2 nitpicks, both refuted)
.github/workflows/ci.yml'scheck-job comment (not the file header) omitting the gofmt, ldflags, and installer steps from its own prose. Refuted by both reviewers: gofmt was already present in the job when the comment was first written (so its omission was never staleness), and the comment was rewritten in round 15 — after the installer steps already existed — without naming them either. Read in context, it is a rationale note about why the job is Ubuntu-only and single-named, not an enumeration; the file header ten lines above, which is an enumeration, was already fixed for exactly this reason in round 26..github/workflows/release.yml's header omitting the install-e2e smoke test, the pre/post-publish version asserts, and the no-branch-commit tripwire. Refuted by both reviewers: the header states the tripwire as an explicit guarantee ("nothing is pushed to any branch"), the post-publish attestation verify — which the finding didn't even name — is equally unnamed and equally present since the file's first commit, and a "fix" would mean duplicating the fuller per-step comments already present ~200 lines below.Not re-raised:
docs/reference/session-directory.md'swordsrow omission-cause gap, already adjudicated and discarded on a split refuter verdict in round 31.Verification
gofmt -l .,go build,go vet ./...,go test ./...,go test -race ./...all pass.merge/reportonexamples/sample-session) passes.TestOversizedInteractionIsRefusedNotPersisted's new subtest andTestWriteJSONLDoesNotEscapeHTML/TestEncodedLenMatchesWriteJSONLfor finding 1's demo/session halves,TestCheckEntriesFitRefusesUnmergeableEntryfor its transcribe half, andTestEarlyRecorderExitDoesNotDoubleDiagnoseWithTwoRecordersfor finding 2.docs/reference/cli.mdanddocs/how-to/instrument-your-own-app.mdupdated to describe the new, more precise 413 condition.Adversarial review outcome
Two independent post-hoc reviews of the full diff (correctness; docs accuracy) both returned BLOCK on the same defect, found independently: the second early-exited recorder's
atStartupclassification (added for finding 2) was sampled afterstopAll/stopDemoran, reopening the exact "slow stop poisons the start-up classification" bugdead's ownatStartupsampling — andTestRunClassifiesStartupExitDespiteSlowStop— already guard against. A start-up TCC denial on the non-deadrecorder could be reported as an unexpected mid-session device fault whenever the demo shutdown or the other child's stop grace ran long.Fixed in a follow-up commit:
atStartupis now sampled for every early-exited child in the same pre-stopAllloop that detects them, with a new regression test (TestEarlyRecorderExitTwoRecordersClassifiesStartupExitDespiteSlowDemoStop) that fails before the fix and passes after. The reviews' non-blocking findings (a garbledeventIDGrowthMargincomment, "permanently unreadable" overstating finding 1's failure mechanism, and the docs' envelope description naming the one component that doesn't cause the overflow) are folded into the same commit. CI is green on the fixed commit.Per the loop's merge gate, a BLOCK from either reviewer means this PR stays open for a human to merge rather than auto-merging, even though the raised issue is now fixed and re-verified — this round did not re-run the adversarial reviews after the fix.
Assisted-by: Claude:claude-sonnet-5