Skip to content

Bug-hunt round 34: Ctrl+C self-exit misdiagnosis, dead API, doc gaps - #51

Merged
REPPL merged 9 commits into
mainfrom
bughunt-34
Aug 7, 2026
Merged

Bug-hunt round 34: Ctrl+C self-exit misdiagnosis, dead API, doc gaps#51
REPPL merged 9 commits into
mainfrom
bughunt-34

Conversation

@REPPL

@REPPL REPPL commented Aug 7, 2026

Copy link
Copy Markdown
Owner

Round 34 of the autonomous bug-hunt loop (state: issue #24).

This round's hunt ran concurrently with another session's round 33 (merged as #49), both starting from the same last-recorded-round read of the state issue. Rebased onto #49's merge and renumbered to 34; the one overlapping nitpick (an AGENTS.md gate-list wording fix) is dropped below as a duplicate of #49's own fix.

Confirmed substantive (1)

record's <-ctx.Done() shutdown branch misdiagnosed (or silently swallowed) a recorder that self-exited just before the interrupt.

internal/record/record.go's <-ctx.Done() case called finaliseOutputs on every child unconditionally, unlike the sibling anyExit branch (fixed in round 32, PR #48), which excludes a self-exited child from the sweep before stopAll runs. When a recorder's done channel closed in the (empirically measured, sub-millisecond) window before the interrupt reached the select, that recorder fell through to finaliseOutputs exactly like a normally-stopped one:

  • with no artefact, it was diagnosed via classifyMissingOutput's "stayed blocked on the permission prompt" narrative — disproved by its own exit, with the real exit status never surfaced (internal/record/tcc.go:116-129);
  • with a partial artefact (e.g. a mid-session device loss leaving a truncated audio.wav), finaliseOutputs saw usable data, appended no problem, and Run returned nil — a truncated recording silently presented as a clean, complete session.

Both failure shapes were empirically reproduced during verification (200/200 and 60/60 trials against the pre-fix code), and the defect directly contradicts the documented contract at docs/reference/cli.md:127-130 and the code's own stated invariant (internal/record/record.go, the comment above the anyExit branch's exclusion logic).

Fix: factored the pre-stopAll early-exit sampling both branches need into a shared sampleEarlyExits helper (internal/record/record.go), used by both select cases, so a recorder that exits on its own gets the same classifyRecorderExit diagnosis regardless of which case observes the exit. Two new regression tests (internal/record/record_test.go: TestCtrlCDiagnosesRecorderThatSelfExitedWithNoOutput, TestCtrlCDiagnosesRecorderThatSelfExitedWithPartialOutput) were watched to fail against the pre-fix code and pass after. CHANGELOG.md gains the matching entry, the sibling of round 32's for the recorder-exit path.

An adversarial CI failure (and a matching post-hoc review) caught that the partial-output test made both select cases ready by construction, so it asserted on opts.Log alone when Go's non-deterministic case choice can route the diagnosis to the returned error instead — fixed to check both. The same review caught that removing analyze.Validate (below) had orphaned its sole caller-side helper, atPositions; removed that too, along with its now-dangling comment naming Validate.

Confirmed nitpicks (4)

  • docs/reference/session-directory.md's words omission-reasons row didn't mention that a word with empty, whitespace-only, or invisible-only text is also dropped (internal/transcribe/transcribe.go:529-537), unlike the equivalent text-row rule documented one row up. Round 31 split-discarded the identical finding: one refuter called the drop rule pre-existing with no staleness fresh from round 30, the other found it survives on the row's own precedent of documenting the equivalent text-row cause. This round's two independent refuters, examining it fresh, both reached the surviving refuter's conclusion.
  • The same page's timeline.jsonl field table had no Required column and did not state t's (or a speech payload's t1's) ±1e9-second magnitude bound, which internal/timeline/timeline.go enforces (refused by report/analyze) — the same bound the transcript.jsonl table documents for t0/t1 three sections above.
  • The same page's utt-003 example trimmed the illustrated utterance text to start mid-sentence ("Now I expect…") while keeping the untrimmed fixture's t0 and word times, leaving the first shown word 1.6s after its own t0. Round 30 refuted a claim about this same example's abbreviated text against the fuller fixture sentence (the page's examples are established illustrative reductions, and no findings.jsonl quote cites utt-003) — but that reasoning doesn't reach this narrower defect: the abbreviation itself is fine, the self-contradiction between the shown t0 and the shown first word's time is the actual gap. Restored the opening words so they agree with t0 again, matching real merge output's integer formatting ("t":16, not "t":16.0) rather than transcript.jsonl's rounded literal.
  • internal/analyze/validate.go's exported Validate function had zero callers anywhere in the module — Ingest uses the unexported validate/indexTimeline pair directly, and internal/review calls analyze.Load, not Validate. Being under internal/, it cannot have external consumers either. Removed, along with the now-unused errors import and the orphaned atPositions helper noted above.

Considered and refuted

  • A claimed round-32 regression desynchronising timeline.jsonl from analyze's emitted analysis request via HTML-escaping (internal/session/session.go's jsonlEncoder vs internal/analyze/emit.go's plain json.Marshal): round 32's jsonlEncoder (SetEscapeHTML(false)) did newly desynchronise the two artefacts' own on-disk/in-source encoding of <, >, and & — before it, WriteJSONL escaped like emit.go still does. But EmitRequest re-decodes the timeline and re-marshals, so WriteJSONL's encoder choice never reaches the request an operator or model is actually shown: one refuter built HEAD and HEAD~1 and proved EmitRequest's output byte-identical across round 32's change. Split verdict, discarded.
  • An inverted transcription segment span (whisperx.go/whispercpp.go accepting end < start): the downstream t1→t0 clamp is a documented, deliberate fallback (docs/reference/session-directory.md:70, internal/timeline/timeline.go), so no real misbehaviour survives.
  • A CI cross-compile comment's CGO-flag mismatch (.github/workflows/ci.yml's comment vs its actual Build step flags): real discrepancy, no behavioural consequence — the repo has no cgo-conditional source.
  • docs/reference/cli.md:152's "ingest reads timeline.jsonl only" phrasing, read against ingest's own findings.jsonl verdict-guard scan: the same paragraph states that guard explicitly four sentences later; no reader is misled.
  • docs/reference/session-directory.md's manifest.json example omitting optional fields present in the real fixture: both omitted fields are marked optional in the table directly above; not misleading.
  • AGENTS.md's go test ./... gate-list wording — this round's own hunt independently found and would have fixed the same issue Bug-hunt round 33: unbounded total-size JSONL reads, two nitpicks #49 already fixed; dropped as a duplicate during the rebase rather than re-fixed.

Verification

Every finding faced two independent adversarial refuters before being included above; only findings both refuters failed to kill were fixed. All four sweep dimensions (code, docs-vs-functionality, infrastructure, internal doc consistency) also surfaced several items that survived one but not both refuters — those are omitted per the loop's "when in doubt, discard" rule and not itemised here. Two full rounds of post-fix adversarial review (correctness; docs accuracy) also ran against this PR's own diff, catching and fixing the flaky-test/dead-code issues above and an earlier draft of this body's own inaccuracies.

Gates

go build, gofmt -l ., go vet ./..., go test ./..., go test -race ./... (including 1000+ stress iterations of the two new regression tests), the pipeline smoke (merge + report against examples/sample-session), and sh -n install.sh && bash -n install.sh all pass on the branch head, rebased onto #49's merge with no merge conflicts.

.abcd/work/DECISIONS.md and CHANGELOG.md gain the round's entries in this PR.

REPPL added 9 commits August 7, 2026 13:43
record's <-ctx.Done() shutdown branch called finaliseOutputs on every
child unconditionally, unlike the sibling anyExit branch (fixed in
round 32), which excludes a self-exited child from the sweep. When a
recorder's done channel closed in the sub-millisecond window before
the interrupt reached the select, that recorder fell through to
finaliseOutputs and was either misdiagnosed via classifyMissingOutput
("stayed blocked on the permission prompt") when it captured nothing,
or silently exited 0 with a truncated recording presented as a clean
session when it left partial data.

Factor the pre-stopAll early-exit sampling into a shared
sampleEarlyExits helper used by both select cases, so a recorder that
exits on its own gets the same classifyRecorderExit diagnosis no
matter which case observes the exit.

Assisted-by: Claude:claude-sonnet-5
Three gaps against the actual code and fixture: the words
omission-reasons list did not mention that empty, whitespace-only, or
invisible-only text is dropped (transcribe.go already documents this
for segment text one row up); the timeline.jsonl field table had no
Required column and did not state t's (and payload t1's) ±1e9-second
bound, which ReadEntries enforces; and the utt-003 example trimmed the
utterance text to start at "Now" while keeping t0 and the real word
times from the untrimmed fixture, leaving the first shown word 1.6s
after its own t0. Restore the example's opening words so t0 matches
its first word again.

Assisted-by: Claude:claude-sonnet-5
Validate had zero callers anywhere in the module — ingest uses the
unexported validate/indexTimeline pair directly, and internal/review
calls analyze.Load, not Validate. Being under internal/, it cannot
have external consumers either. Drop it along with the now-unused
errors import.

Assisted-by: Claude:claude-sonnet-5
Assisted-by: Claude:claude-sonnet-5
TestCtrlCDiagnosesRecorderThatSelfExitedWithPartialOutput cancelled
the context after the child was already reaped, making both select
cases in Run ready at once. Go's select picks between ready cases
uniformly at random, not deterministically favouring ctx.Done(): when
anyExit wins instead, the diagnosis is returned as the error rather
than written to opts.Log. The test asserted on the log alone, so it
failed whenever the non-ctxEarly path fired (confirmed flaky on CI,
0/6000 locally but reproducible under load). Check both the log and
the returned error.

Assisted-by: Claude:claude-sonnet-5
atPositions' sole caller was the Validate export removed in this
branch's earlier commit; Ingest builds positioned{} literals inline
and never called it. Its doc comment also named Validate, now a
dangling reference to a deleted symbol.

Assisted-by: Claude:claude-sonnet-5
The timeline.jsonl example's word-level "Typing" entry read
{"w":"Typing","t":16.0}, but real merge output emits {"w":"Typing","t":16}
— transcript.jsonl's rounded literal, not Go's default float
formatting, which drops the trailing zero. The t field's bound
description also named ReadEntries, the internal function that first
enforces it, where the rest of the page and the sibling src row name
the user-facing commands (report, analyze) that refuse.

Assisted-by: Claude:claude-sonnet-5
The sibling entry for round 32's equivalent fix on the recorder-exit
path already exists; this branch's Ctrl+C-path fix is a matching
user-visible behaviour change and needs its own line, following the
same precedent round 31 used to back-fill round 30's.

Assisted-by: Claude:claude-sonnet-5
Two inaccuracies an adversarial review caught in the draft entry:
it claimed the timeline.jsonl/emit.go HTML-escaping asymmetry
pre-dates round 32 via session.SafeText, when round 32 demonstrably
introduced that specific asymmetry (both sides escaped before it) —
the defensible claim is narrower, that EmitRequest's own output is
unaffected because it re-decodes and re-marshals regardless. It also
framed round 31's split-discard of the words-row finding as resting
on a different question than this round's re-examination, when one
of round 31's two refuters already argued the surviving rationale
this round's refuters reached again. Also cites round 30's prior,
narrower refutation of a utt-003 finding and distinguishes it from
this round's, and records the atPositions/CHANGELOG follow-ups.

Assisted-by: Claude:claude-sonnet-5
@REPPL
REPPL merged commit ffea9be into main Aug 7, 2026
6 checks passed
@REPPL
REPPL deleted the bughunt-34 branch August 7, 2026 14:16
@REPPL REPPL mentioned this pull request Aug 7, 2026
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