Skip to content

feat(digest): trace presets name only real failures, and the pointer is measured - #36

Merged
lloydsk merged 5 commits into
mainfrom
feat/trace-digest-presets
Sep 28, 2026
Merged

lloydsk merged 5 commits into
mainfrom
feat/trace-digest-presets

Conversation

@lloydsk

@lloydsk lloydsk commented Sep 28, 2026

Copy link
Copy Markdown
Collaborator

What this changes

Two things, both about what the completion wake can honestly claim.

The presets name real failures, or nothing. js-trace now anchors on the runner's own failure markers ((fail), --- FAIL) and refuses lines that are themselves pass or captured-output records. README.md and skills/digest-config/SKILL.md both promise the presets print nothing when their shape is not recognised; before this they could name a passing test, a docstring, or a build-log warning. A wrong pointer was measurably worse than no pointer: 5/5/6 calls and 28,633 chars against a baseline's 3/4/4 and 18,760.

The pointer is honest about being a lead. The wake's hedge - "any failure named here is one failure, not the whole story" - is what makes it safe, and measurement says it also makes it cost: with a correct pointer, sessions ran 5/5/8 calls and 33,799 chars, more searching than a baseline with nothing to verify. That is the finding in #33, not a defect in this diff.

Review

The wake text is seen by every session, so the wording change deserves a read rather than a skim: extension/digestPresets.ts, and the state line in extension/index.ts.

Known limits, stated rather than implied

Checks

tsc --noEmit clean; 248 pass, 0 fail; npm pack --dry-run ships 7 files with no bench artefacts.

Related: #33, #34, #35.

…ot its log

Measured defect, three sessions of the benchmark's first cell, all three alike: after a bgrun
handoff every session polled the job it had just started — 27 of 30 tool calls were made while
the job was still running — and every session asserted the completion wake in writing, the first
claim 3-4 seconds into a ~23s job ("**Wake received.**"). The wake itself is event-driven and
correct; the loop came from three gaps: an agentic loop has no idle primitive, so "I'll wait for
the job to finish" is implemented as a check; the check paid, because bgtail served a running
job's log in deltas; and no tool result carried a clock, so "has the wake arrived?" was
unfalsifiable from inside the loop.

This makes the tools match the contract they already documented (they said "do not poll" and
then served the poll):

- bgrun's result and guidance state that the task is complete at handoff: "Your part is done —
  end your turn now... the wake will arrive as a new turn" with the exit code, stats and last
  line.
- bgtail/bggrep on a still-running job return its state and nothing else — elapsed time, no exit
  code, and where the wake will come from. The gate fires before any tail bookmark moves, so a
  later read is still a first read, not a delta.
- peek: true is the explicit opt-in for a live log, so watching a long job stays possible;
  /bgtail passes it, since a human asking for it is the opt-in. bgstatus keeps its output and
  gains the same state line; its list rows are left alone — a listing is not a poll of one job.
- A finished job's output is byte-identical to before, which is why no finished-job test changed.
- Descriptions, README and the run-bg skill no longer advertise polling as free. The one existing
  test that needed adjusting was itself running-shaped (a log id ending in process.pid with no
  exit marker), so it now writes an exit marker and still exercises the match budget.

Verified by the implementing pass: bun test extension/index.test.ts — 231 pass, 0 fail;
bun run lint (tsc --noEmit) exit 0.
Measured in the benchmark's trace-root cell: after a wake that pointed at nothing, all three bgrun
sessions searched the log for failure words — a pattern that cannot match a frame line — so the file
holding the cause appeared in 0 of 3 of their contexts, against 3 of 3 on the full-read arm. The
wake's stats and "last output" (the log's final line) are not pointers.

The wake now carries one:
  Failure: AssertionError [ERR_ASSERTION]: Expected values to be strictly deep-equal: — at loadStep (.../harness.ts:28:9)

- One bounded scan, folded into the line-count read the exit path already performs: no second read of
  the log, no new dependency. Whole-log rather than tail-only, because long-buried plants its failure
  around 61% of the way through a ~1000-line log.
- FRAME_LOOKAHEAD is 20, not the 5 first sketched, because the real log measures the gap: the
  assertion header sits 10 lines above its frame with a printed diff payload between. A 5-line window
  would emit a frame-less failure line — the exact miss this change fixes. The constant's comment
  carries the measurement.
- It runs only when the exit code is non-zero, so a passing job's wake is byte-identical: a green log
  containing "Error:" or a frame-shaped line cannot produce a Failure: line.
- One failure is named and no count is claimed: a total would mean matching every runner's failure
  spelling, and a wrong total is worse than none. Bun runs files concurrently, so the first failure in
  the log is a real failure, not necessarily the most important one — the code says as much.
- Spaced function names match (`at load part 02 (...)` is what the fixture prints); a name-scoped
  pattern silently dropped such frames, the same class of bug the profiler hit with line-numbered
  views.

7 new tests: the failure line with its frame in one wake; a block failure's frame across a printed
payload (the trace-root shape); a spaced function name; a failure line with no frame; a failure buried
mid-log; a passing log containing "Error:"; and two failure blocks naming the first once.
bun test extension/index.test.ts — 238 pass, 0 fail; bun run lint clean. Verified end-to-end against
the benchmark's real trace-root log, where the wake renders the harness.ts frame.
…the built-in locator

The built-in locator was a default tuned to the product's own runner: measured over a nine-entry
multi-runner corpus it worked on 2, both JS/TS. It is deleted. In its place:

- `on: "failure"` on a digest entry, enforced by the framework (it alone knows the exit code; a
  digest command receives only the log path). A trace preset's block is appended for a failing job
  and cannot appear for a passing one, even when the log contains "Error:".
- Three shipped presets, split by stack ORIENTATION because that, not language, is the correctness
  axis: `js-trace` (innermost first — JS/TS, Go, Rust, JVM), `py-trace` (outermost first: the LAST
  `File "…", line N` frame is the deepest), `rb-trace` (minitest's raise site). Each is one POSIX
  awk pass over $1; runtime paths are excluded so the user frame wins.
- `junit-xml` now appends a location when the emitter carries one (`name (file:line)`, else
  `name (file)`, else the name) — the structured path, real-captured from bun and node.

Corpus re-run through the presets, per entry: bun equal, node equal, Go and Rust GO from no frame
to the user frame, Java detected with a frame where the built-in found none, minitest detected
without one (its frame shape is a third pattern), python corrected from the caller's line to the
throw site. Nothing regressed; that is what licensed deleting the built-in.

Also: the wake's closing line is now "a summary, not the diagnosis — the log holds the detail,
including the context around any failure", on both the passing and failing branches, so a session
is not invited to conclude a cause from a summary alone. The old FRAME_LOOKAHEAD/FRAME_LOOKBEHIND
semantics live on as the js-trace awk cap and backward walk, noted in its comment.

bun test extension/index.test.ts — 242 pass, 0 fail; bun run lint clean.
The nudge fired only at session_start, and only for a project with no digest at all - so a project
whose digest reports counts only, this repo's own .pi/pi-bgrun.json among them, never hears that trace
presets exist. The value of a digest is legible at the moment a job fails, which is where this now
fires as well.

The nudge body is shared: session_start keeps its wording, and the child-exit handler calls it on a
job's first non-zero exit with a moment-aware string (DIGEST_FAILURE_NUDGE_TEXT). Both share the same
per-project marker, so a project is nudged at most once either way, and both stay toast-only via
ctx.ui.notify - never sendUserMessage, so it costs zero LLM context. The tool still never writes
config files.

Deliberately NOT added: detection of a digest that "produced no failure signal". Judging whether output
contains a failure needs per-ecosystem knowledge - the fitted regex this branch deleted from the core,
wearing a smaller hat. The docs state the counts-only limitation instead: a scorecard that reports
counts only says a job went red, not where or why, and the trace presets (js-trace, py-trace, rb-trace)
exist for that.

Tests: 246 pass / 0 fail (four new - fires once on a first failing job in a trusted digest-less
project, not on a passing job, not twice, not when a digest is configured). tsc --noEmit clean.
…naming anything

Found by the benchmark's first cell with a digest configured, from a kept real log
(bun test, 1,281 lines, exit 1): the preset named a PASSING test's line -
"Failure: (pass) preset js-trace corpus (captured): bun test - stack above the `(fail)` summary" -
and the sessions paid for it. Root cause: the signature regex matched `(fail)` (and FAILED /
AssertionError) as an unanchored substring on any line, so a passing test whose *name* mentions
`(fail)` won. The trigger is self-referential: the suite under test prints its own preset corpus, so
the log contains failure-shaped strings from passing tests.

Two rules, both about evidence rather than incident text:
- the runner's markers are anchored: `^[[:space:]]*\(fail\)` and `^[[:space:]]*--- FAIL`. A mid-line
  mention inside a printed name is not a failure record.
- a candidate line that is itself a pass record is skipped, so a passing test's name can never win
  however many failure words it carries.

With no anchored evidence the preset now prints nothing. That is the measured rule, not a stylistic
choice: a wake with no pointer cost 3-4 calls and 18,760 chars, the same cell with this wrong pointer
cost 5-6 calls and 28,633 chars - a claim that may be wrong must be verified, so silence beats a
guess. Frame selection is otherwise unchanged.

Tests: 248 pass / 0 fail, including two new ones from a 4-line slice of the real log - a passing test
named after `(fail)` never wins, and failure words that appear only inside pass records yield no
pointer. tsc --noEmit clean.
@github-actions

Copy link
Copy Markdown
Contributor

CI report

Check Result
tsc --noEmit success
tests success
npm pack --dry-run success (7 files in tarball)

Ref: 75a2c1f5b2ebd2b427d13e85dda054c9d708fcce

@lloydsk
lloydsk merged commit 6b04d24 into main Sep 28, 2026
2 checks passed
@lloydsk
lloydsk deleted the feat/trace-digest-presets branch September 28, 2026 23:37
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