diff --git a/README.md b/README.md index 4492345..2c59d64 100644 --- a/README.md +++ b/README.md @@ -80,8 +80,8 @@ The host also decides several details, all handled internally: | ------ | --------- | | `bgrun` | Launch a command detached in the background. Optional `name` gives the job a short human-readable label. Returns `started: ` immediately. Wakes the session automatically on completion. | | `bgstatus` | Show job status. With an id: any job's state + exit code — a `bg_N` id (the host's own background job on omp) is answered with its state, where its output went and how to cancel it, rather than "not found". Without: this session's running jobs (finished jobs hidden by default — pass `includeDone: true` or set `showCompletedJobs`), then the host's background jobs under their own heading. Other sessions' *running* jobs are listed only when `adoptForeignJobs` is enabled; finished foreign logs from the shared dir can also appear when finished jobs are included. | -| `bgtail` | Read the newest lines of a job's log (default 40; it reads the log's **last 2 MB** — widen with `bytes`, max 64 MiB + 4 KiB of wrapper overhead), **condensed for context**: ANSI escapes stripped, repeated lines collapsed (a run of 3+ folds into one line carrying its `[xN]` count; a pair is kept as two lines — a fold needs its count to stay legible), long lines and total size capped. First read = full last-N tail; repeat reads return **only lines appended since your last read** (delta tailing) — polling a running job never re-pays for lines already seen. A repeat read asking for **more** lines than you have already been shown is a widening, not a poll: it returns the lines above your current coverage rather than the whole window again — coverage is a trailing run, so a line seen before an intervening gap can repeat. Pass `raw: true` for the unprocessed last-N window (still advances the bookmark). On omp, a native `bg_N` job's spilled output is read the same way, stamped as the host's file. | -| `bggrep` | Regex search over the **last 2 MB** of a job's log (`bytes` widens the window, max 64 MiB + 4 KiB of wrapper overhead; on omp, a native `bg_N` job's spilled output is searchable the same way): line-numbered matches, optional `context` lines, each line pre-truncated to 10 000 chars before matching, results capped (~50 matches, ~8KB) and condensed. JavaScript regex syntax: a **leading** `(?i)`/`(?m)`/`(?s)` group is accepted and translated to the equivalent flags (`(?i)error` searches case-insensitively); a *scoped* group (`(?i:error)`) is native anywhere; a bare flag group anywhere else is rejected with an explanation — JS `RegExp` has no unscoped inline flags. Resolves the job id to the configured jobs dir itself — no log path to reconstruct. `ctx_execute_file` can read the same file (it takes an absolute path; only your Read-deny rules apply), but it needs that path. Matching runs under a wall-clock budget ([Bounded matching](#bounded-matching)). With no `pattern`, a generic failure-signature default is used (override it — convenience, not guarantee). | +| `bgtail` | Read the newest lines of a job's log (default 40; it reads the log's **last 2 MB** — widen with `bytes`, max 64 MiB + 4 KiB of wrapper overhead), **condensed for context**: ANSI escapes stripped, repeated lines collapsed (a run of 3+ folds into one line carrying its `[xN]` count; a pair is kept as two lines — a fold needs its count to stay legible), long lines and total size capped. First read = full last-N tail; repeat reads return **only lines appended since your last read** (delta tailing), so a line already shown is never sent twice. While the job is still running it returns its state and nothing else — launching was the task, the wake is the next input; `peek: true` reads the live log anyway. A repeat read asking for **more** lines than you have already been shown is a widening, not a poll: it returns the lines above your current coverage rather than the whole window again — coverage is a trailing run, so a line seen before an intervening gap can repeat. Pass `raw: true` for the unprocessed last-N window (still advances the bookmark). On omp, a native `bg_N` job's spilled output is read the same way, stamped as the host's file. | +| `bggrep` | Regex search over the **last 2 MB** of a job's log (`bytes` widens the window, max 64 MiB + 4 KiB of wrapper overhead; on omp, a native `bg_N` job's spilled output is searchable the same way): line-numbered matches, optional `context` lines, each line pre-truncated to 10 000 chars before matching, results capped (~50 matches, ~8KB) and condensed. JavaScript regex syntax: a **leading** `(?i)`/`(?m)`/`(?s)` group is accepted and translated to the equivalent flags (`(?i)error` searches case-insensitively); a *scoped* group (`(?i:error)`) is native anywhere; a bare flag group anywhere else is rejected with an explanation — JS `RegExp` has no unscoped inline flags. Resolves the job id to the configured jobs dir itself — no log path to reconstruct. While the job is still running it returns its state and nothing else; `peek: true` searches the live log anyway. `ctx_execute_file` can read the same file (it takes an absolute path; only your Read-deny rules apply), but it needs that path. Matching runs under a wall-clock budget ([Bounded matching](#bounded-matching)). With no `pattern`, a generic failure-signature default is used (override it — convenience, not guarantee). | | `bgclean` | Remove old job logs. **Default scope: this session's jobs only** — other sessions' logs are untouched — and it also drops stale per-project digest markers (`.bgrun-used-*`, `.digest-nudge-*`) in the session's jobs dir (markers are not session data). Pass `all: true` to sweep every shared jobs dir — under the project-local default that is the project's dir plus the machine-global one, while an explicit absolute `jobsDir` is swept alone — and do the same marker sweep across them. Retention: `cleanupDays` config (7 days); `days` must be a positive number (`days: 0` is rejected rather than purging everything). Never removes a running job's log. | | `bgkill` | Stop a running job. Optional `force: true` sends `SIGKILL` instead of `SIGTERM`. Signals the job's **whole process group** (the child is spawned detached, so the command and anything it started go together; where a platform cannot signal groups, it falls back to the single pid and says so). Refuses an id that already finished, one that is not a bgrun job (`bg_N` belongs to the host — `hub cancel`), another session's job unless `includeForeign: true`, and a pid that cannot be the job's — gone, unusable, or recycled onto an unrelated process. It reports the signal it sent, not a guess at the outcome: the authoritative result is the wake, or the stale-check for a foreign job. | @@ -203,19 +203,25 @@ ever enter the conversation: - **Quick peek:** `bgtail ` — condensed newest lines (ANSI stripped, repeats collapsed only where the count can be shown, ~2KB/line and ~8KB caps). The first read is the last-40-lines tail; each later - read returns only what was appended since, so repeated polling is nearly - free. Asking for **more lines than you have been shown** widens the view - instead of polling: you get the lines above your coverage, so widening costs + read returns only what was appended since. A still-running job is answered with + its **state**, not the log — launching was the task and the wake is the next + input, so a poll has nothing to pay it off; `peek: true` reads a live log when + you really need it. Asking for **more lines than you have been shown** widens the view + instead of re-sending the tail: you get the lines above your coverage, so widening costs the difference rather than the whole window again (a line seen before an intervening gap can repeat). The wake message itself - already carries the exit code and the log's last line, so many turns need no - follow-up read at all. + already carries the exit code, the stats and the log's last line; a configured + trace digest (`js-trace` / `py-trace` / `rb-trace`, see the digest section) can + additionally name the failure and its source frame. Either way it is a summary, + never the full record: on a failed run the context around the failure lives + only in the log, so read a window around the failure before concluding a cause. - **Pattern search:** `bggrep [pattern] [context]` — line-numbered matches, capped and condensed (~50 matches, ~2KB/line, ~8KB); takes the job id, so there is no log path to reconstruct. Searches the **last 2 MB** by default — pass `bytes` to widen (max 64 MiB), or use `ctx_execute_file` on the path for whole-file code-based analysis. Pass your own pattern when you know the log's - format. A **wider window costs latency and memory, not context**: the returned + format. A still-running job returns its state, not matches (`peek: true` opts + in). A **wider window costs latency and memory, not context**: the returned matches stay capped either way. - **Whole-log analysis:** `ctx_execute_file` on the job's log path (reachable when logs are project-local) to extract only failure lines. Never `cat` or @@ -444,6 +450,14 @@ command's own log line count (the internal exit marker is excluded). A project can additionally opt into a **digest scorecard**: a one-line pass/fail summary extracted from the log and appended to the wake. +A scorecard that reports **counts only** — pass/fail totals, failing test names — +will not point at a failure: it says a job went red, not where or why. The three +**trace** presets (`js-trace`, `py-trace`, `rb-trace`) exist for that; they name +the failure and its source frame. The tool deliberately does **not** try to +detect that a scorecard "produced no failure signal" — that judgement would need +per-ecosystem knowledge of what a failure looks like, so it is left to you: a +count-only scorecard is a red/green indicator, and the cause is in the log. + #### Job identity: name, type, command Every `bgrun` job carries three identifiers, and the digest selector reads all @@ -473,8 +487,9 @@ Three ways, easiest first — pick the first one you're comfortable with: result on both a green and a red log, and writes the config. It sees your actual output format, which is exactly what a good digest depends on — and you never have to read a log yourself. The one-shot toast some - projects see on session start ("no digest configured") — once per project - that has run a bgrun job — is pointing at this same skill. + projects see ("no digest configured") — once per project that has run a + bgrun job, toasted either at session start or at its first failing job — + is pointing at this same skill. 2. **One-line preset if you know your stack.** Create `/$CONFIG_DIR/pi-bgrun.json` (or merge into an existing one): @@ -487,13 +502,29 @@ Three ways, easiest first — pick the first one you're comfortable with: | `go-test` | Go test output: package ok/FAIL counts + failing test names | `test` | | `jest` | Jest output: Tests/Test Suites summary + failed test names | `test` | | `pytest` | pytest output: final passed/failed/error summary line + FAILED test ids | `test` | - | `junit-xml` | JUnit XML: ``/`` counts + failing testcase names | `test` | + | `junit-xml` | JUnit XML: ``/`` counts + failing testcase names, with `file:line` when the emitter carries one | `test` | + | `js-trace` | JS/TS, Go, Rust, JVM stack traces (innermost frame first): first failure signature + the deepest source frame, forwards or backwards | `test` | + | `py-trace` | Python tracebacks (outermost frame first): first failure header + the deepest `File "…", line N` frame (the LAST one, not the first) | `test` | + | `rb-trace` | Ruby / Minitest: the `N) Error:`/`N) Failure:` header + its first `path:line:in 'fn'` frame (the raise site) | `test` | All shipped presets are test runners, so they all suggest the conventional type `test`. The suggestion is documentation, not behavior: you still write the `type` on the entry yourself, and a preset entry with no `type` applies to every job as before. + The three `*-trace` presets name a failure and its source frame; the built-in + wake no longer guesses one, so this is how a wake gets a pointer at all. Use + them with `on` (below) so a green log that happens to contain an `Error:` line + cannot inject a failure: + + ```json + { "digest": { "type": "test", "preset": "js-trace", "on": "failure" } } + ``` + + They recognise exactly the shapes in their descriptions and print nothing for + any other runner — that runner's failure is still in the log, just not pointed + at by the wake. + 3. **Custom command.** For formats the presets don't cover: ```json @@ -524,6 +555,22 @@ Three ways, easiest first — pick the first one you're comfortable with: yourself, you can also ask your agent to validate a specific command against specific job logs. +#### Failure-only scorecards (`on`) + +An entry may carry `"on": "failure"`, which appends its output **only when the +job exited non-zero**: + +```json +{ "digest": { "type": "test", "preset": "js-trace", "on": "failure" } } +``` + +The **framework** enforces this, not the command: a digest command receives only +the log path, never the exit code, so a green job's wake must never show a +failure a command guessed from the text. That matters for the `*-trace` presets +— a green log can legitimately contain an `Error:` line. `on` defaults to absent +("always append"), so existing configs are unchanged; any value other than +`"failure"` is a config error and drops the entry, like a bad `match` or `type`. + #### Multiple scorecards (one per job type) `digest` can also be an ordered **list** of scorecards. Give each a `type` and @@ -623,7 +670,7 @@ Command: go test ./... Stats: 42.3s, 1204 lines Last output: FAIL example.com/api/handlers digest (go-test): 7 ok / 1 FAIL: TestResolveNotFound -Analyze the failure: `bgtail` for a peek at the end of the log, or `bggrep` to search it — then continue the task that depended on this. +Analyze the failure: `bgtail` for a peek at the end of the log, or `bggrep` to search it. This wake is a summary, not the diagnosis — the log holds the context around the failure, and any failure named here (by a trace digest) is one failure, not the whole story. Then continue the task that depended on this. ``` Shell safety: the command comes from trust-gated config and runs with your diff --git a/extension/digestPresets.ts b/extension/digestPresets.ts index 9b9b274..e9edc94 100644 --- a/extension/digestPresets.ts +++ b/extension/digestPresets.ts @@ -24,6 +24,15 @@ export interface DigestPreset { suggestedType: string; } +// Bun's transpiler re-encodes non-ASCII inside String.raw templates as +// `\uXXXX`; a raw string keeps that VERBATIM (backslash included), and awk drops +// the backslash — so a literal "—" reaches awk as the five characters "u2014". +// Interpolating these normal-string constants (which DO evaluate the escape) +// keeps the raw template safe for regex backslashes while still handing awk real +// UTF-8. Only the trace presets need non-ASCII. +const TRACE_SEP = " — "; +const TRACE_GLYPHS = "✗|✘|✖|×"; + export const DIGEST_PRESETS: DigestPreset[] = [ { id: "go-test", @@ -56,16 +65,74 @@ export const DIGEST_PRESETS: DigestPreset[] = [ }, { id: "junit-xml", - description: "JUnit XML: / counts + failing testcase names", + description: + "JUnit XML: / counts + failing testcase names, with file:line when the emitter carries one", suggestedType: "test", // Count failure/error elements (attributes like failures="0" don't match — - // they lack the `<`), then pull the enclosing testcase's name attribute. - // Real pytest --junitxml emits the whole document on ONE line, so the scan - // is record-based (`RS='` and matches `[[:space:]]name="` so it never picks up - // `classname="..."` nor a `1 { r=$0; e=index(r,""); if (e) r=substr(r,1,e-1); if (match(r,/[[:space:]]name="[^"]*"/)) { n=substr(r,RSTART+7,RLENGTH-8); if (r ~ /` and matches + // `[[:space:]]name="` so it never picks up `classname="..."` nor a `, some put them on the failing element) and some carry neither + // (node:test emits `file` only; Go's gotestsum emits none), so the location + // is appended only when the emitted attribute exists and the name still + // prints alone otherwise. + command: + 'printf \'failures: %s errors: %s\\n\' "$(grep -o \'1 { r=$0; e=index(r,""); if (e) r=substr(r,1,e-1); if (r ~ /=1&&fr(L[s]))s--; if(s+1<=f-1&&fr(L[s+1])){print "Failure: "trim(L[f])"${TRACE_SEP}"trim(L[s+1]);exit} print "Failure: "trim(L[f]) }' "$1" | head -1`, + }, + { + id: "py-trace", + description: + "Python tracebacks (outermost frame printed first): first failure header + the LAST `File \"…\", line N, in fn` frame, which is the deepest — not the first", + suggestedType: "test", + // One awk pass. (1) First signature — unittest `FAIL:`/`ERROR:`, a bare + // `Traceback (most recent call last)`, pytest's `E AssertionError`, or a bare + // `AssertionError`. (2) Keep the LAST `File "…", line N, in fn` frame within + // the 60-line forward cap (Python prints outermost-first, so the deepest is + // the last of the run). (3) If the traceback sits ABOVE the header (pytest's + // E-line form), take the nearest `File` frame above it instead. Prints + // nothing when no signature is found. + command: String.raw`awk 'function trim(s){gsub(/^[ \t]+|[ \t]+$/,"",s);return s} function fr(l){return l ~ /^[ \t]*File "[^"]+", line [0-9]+, in /} {L[NR]=$0} END{ for(i=1;i<=NR;i++) if(L[i] ~ /(^|[^[:alnum:]_])(FAIL|ERROR)(ED|URE)?([^[:alnum:]_]|$)|Traceback \(most recent call last\)|AssertionError/){f=i;break} if(!f)exit; last=""; for(j=f+1;j<=NR&&j<=f+60;j++) if(fr(L[j]))last=trim(L[j]); if(last!=""){print "Failure: "trim(L[f])"${TRACE_SEP}"last;exit} b=""; for(k=f-1;k>=1&&k>=f-60;k--) if(fr(L[k])){b=trim(L[k]);break} if(b!=""){print "Failure: "trim(L[f])"${TRACE_SEP}"b;exit} print "Failure: "trim(L[f]) }' "$1" | head -1`, + }, + { + id: "rb-trace", + description: + "Ruby / Minitest: the `N) Error:` / `N) Failure:` header + its first `path:line:in 'fn'` frame (Ruby prints the raise site first, so the first is the deepest)", + suggestedType: "test", + // One awk pass. (1) The numbered Minitest error/failure header. (2) The first + // `path.rb:line:in ` frame after it. Ruby's backtrace is ordered from the + // raise point outward, so the first frame IS the deepest. Prints nothing when + // no header is found. + command: String.raw`awk 'function trim(s){gsub(/^[ \t]+|[ \t]+$/,"",s);return s} function fr(l){return l ~ /^[ \t]*[^ \t]+\.rb:[0-9]+:in /} {L[NR]=$0} END{ for(i=1;i<=NR;i++) if(L[i] ~ /^[ \t]*[0-9]+\)[ \t]*(Error|Failure):/){f=i;break} if(!f)exit; for(j=f+1;j<=NR&&j<=f+60;j++) if(fr(L[j])){print "Failure: "trim(L[f])"${TRACE_SEP}"trim(L[j]);exit} print "Failure: "trim(L[f]) }' "$1" | head -1`, }, ]; @@ -103,6 +170,14 @@ export interface DigestEntry { label?: string; preset?: string; command?: string; + /** + * When `"failure"`, the entry's output is appended ONLY for a job that exited + * non-zero. The framework owns this check — a digest command receives only the + * log path, never the exit code — so a green log that happens to contain an + * `Error:` line cannot inject a failure into the wake. Absent means "always" + * (the pre-existing behaviour). + */ + on?: "failure"; } /** @@ -120,6 +195,8 @@ export interface DigestJobTarget { export interface SelectedDigest { command: string; label: string; + /** Carried from the entry so the wake path can gate on the exit code. */ + on?: "failure"; } /** @@ -340,7 +417,12 @@ export function selectDigestEntry( // A type-matching entry always carries a non-empty type, so `entry.type` is // the label fallback — no need for the preset-id default here. if (hit?.entry.type) { - return { command: hit.command, label: hit.entry.label || hit.entry.type }; + const out: SelectedDigest = { + command: hit.command, + label: hit.entry.label || hit.entry.type, + }; + if (hit.entry.on) out.on = hit.entry.on; + return out; } } @@ -350,10 +432,12 @@ export function selectDigestEntry( const entry = hit.entry; const matchLabel = entry.match?.name === undefined ? "" : labelFromMatchName(entry.match.name); - return { + const out: SelectedDigest = { command: hit.command, label: entry.label || matchLabel || defaultDigestLabel(entry), }; + if (entry.on) out.on = entry.on; + return out; } return undefined; } diff --git a/extension/index.test.ts b/extension/index.test.ts index ffd0d47..33baf73 100644 --- a/extension/index.test.ts +++ b/extension/index.test.ts @@ -38,6 +38,7 @@ import { selectDigestEntry, } from "./digestPresets.ts"; import { + DIGEST_FAILURE_NUDGE_TEXT, DIGEST_NUDGE_TEXT, digestNudgeMarkerPath, jobUsageMarkerPath, @@ -3495,6 +3496,159 @@ test("bgtail: delta tailing — first read full tail, then only new lines, then }); }); +// ── running jobs: content is gated behind an explicit `peek` ──────────────── +// +// A read of a still-running job used to come back with content (or a delta), +// which is exactly what paid for the 9-12 call loops measured in the first +// benchmark cell. It now returns the job's state instead; `peek: true` is the +// opt-in. A finished job's read is byte-identical (the delta tests above, and +// the finished-case test below, hold that line). + +const LIVE_MARKER = "LIVE-LOG-MARKER"; +const RUNNING_NOTE_RE = /⏳ still running — [\d.]+s elapsed, no exit code yet/; + +// Wait until the wrapper's asynchronous write has landed: "no content while +// running" proves nothing if the content was not in the log yet. A real child +// process is writing to a real fd, so there is no fake-timer seam here — this +// is integration plumbing, like waitForPath/waitForWakes above. +async function waitForLogText(logPath: string, text: string): Promise { + const start = Date.now(); + for (;;) { + try { + if (readFileSync(logPath, "utf8").includes(text)) return; + } catch { + // not created yet + } + if (Date.now() - start > 3_000) { + throw new Error(`timed out waiting for ${JSON.stringify(text)} in ${logPath}`); + } + await new Promise((r) => setTimeout(r, 25)); + } +} + +test("bgtail/bggrep: a running job returns the state line, not the log; peek: true returns both", async () => { + await withJobsDir(async (dir, h) => { + const { wakes, tools, ctx } = h; + const bgrun = tools.get("bgrun")!; + const bgtail = tools.get("bgtail")!; + const bggrep = tools.get("bggrep")!; + const bgstatus = tools.get("bgstatus")!; + const bgkill = tools.get("bgkill")!; + + const res = await bgrun.execute( + "r1", + { command: `echo ${LIVE_MARKER}; sleep 30` }, + undefined, + undefined, + ctx, + ); + const started = res.content[0].text as string; + const id = started.match(/^started: ([^\n]+)/)![1]; + assert.match(started, /Your part is done — end your turn now/); + assert.match(started, /Nothing to wait for/); + const logPath = join(dir, `${id}.log`); + await waitForLogText(logPath, LIVE_MARKER); + + try { + // No peek: the state line replaces the content entirely. + const tail = await bgtail.execute("r2", { id }, undefined, undefined, ctx); + const tailText = tail.content[0].text as string; + assert.match(tailText, RUNNING_NOTE_RE); + assert.match(tailText, /Launching was the task; the wake is your next input/); + assert.match(tailText, /pass peek: true only if you need the live log now/); + assert.ok(!tailText.includes(LIVE_MARKER), "no log content without peek"); + assert.equal(tail.details.running, true); + + const grep = await bggrep.execute( + "r3", + { id, pattern: LIVE_MARKER }, + undefined, + undefined, + ctx, + ); + const grepText = grep.content[0].text as string; + assert.match(grepText, RUNNING_NOTE_RE); + assert.ok(!grepText.includes(LIVE_MARKER), "no match output without peek"); + assert.equal(grep.details.running, true); + + // bgstatus on the same job: state plus the consequence, no peek sentence + // (there is no log content there to opt into). + const status = await bgstatus.execute("r4", { id }, undefined, undefined, ctx); + const statusText = status.content[0].text as string; + assert.match(statusText, new RegExp(`^${id}: running\\n`)); + assert.match(statusText, RUNNING_NOTE_RE); + assert.match(statusText, /Nothing to read until it exits\.$/m); + assert.ok(!statusText.includes("peek"), "status offers no peek"); + + // peek: true — the log AND the state line, so the read still says what + // the job is doing. + const peeked = await bgtail.execute( + "r5", + { id, peek: true }, + undefined, + undefined, + ctx, + ); + const peekedText = peeked.content[0].text as string; + assert.match(peekedText, RUNNING_NOTE_RE); + assert.ok(peekedText.includes(LIVE_MARKER), "peek returns the log"); + + const peekedGrep = await bggrep.execute( + "r6", + { id, pattern: LIVE_MARKER, peek: true }, + undefined, + undefined, + ctx, + ); + const peekedGrepText = peekedGrep.content[0].text as string; + assert.match(peekedGrepText, RUNNING_NOTE_RE); + assert.match(peekedGrepText, new RegExp(`L1: ${LIVE_MARKER}`)); + } finally { + // The state has to be real, so the process has to be real: stop it, and + // let the exit wake land before the temp dir goes away. + await bgkill.execute("r7", { id, force: true }, undefined, undefined, ctx); + await waitForWakes(wakes, 1); + } + }); +}); + +test("bgtail/bggrep/bgstatus: a finished job's output carries no state line", async () => { + await withJobsDir(async (_dir, h) => { + const { wakes, tools, ctx } = h; + const bgrun = tools.get("bgrun")!; + const res = await bgrun.execute( + "f1", + { command: `echo ${LIVE_MARKER}` }, + undefined, + undefined, + ctx, + ); + const id = (res.content[0].text as string).match(/^started: ([^\n]+)/)![1]; + await waitForWakes(wakes, 1); + + const tail = await tools + .get("bgtail")! + .execute("f2", { id }, undefined, undefined, ctx); + const tailText = tail.content[0].text as string; + assert.match(tailText, new RegExp(LIVE_MARKER), "content as before"); + assert.doesNotMatch(tailText, /still running/); + + const grep = await tools + .get("bggrep")! + .execute("f3", { id, pattern: LIVE_MARKER }, undefined, undefined, ctx); + const grepText = grep.content[0].text as string; + assert.match(grepText, new RegExp(`L1: ${LIVE_MARKER}`)); + assert.doesNotMatch(grepText, /still running/); + + const status = await tools + .get("bgstatus")! + .execute("f4", { id }, undefined, undefined, ctx); + const statusText = status.content[0].text as string; + assert.match(statusText, new RegExp(`^${id}: done exit=0`)); + assert.doesNotMatch(statusText, /still running/); + }); +}); + test("bgtail: a wider `lines` hands back the earlier lines not yet shown, never the seen ones", async () => { await withJobsDir(async (dir, h) => { const { wakes, tools, ctx } = h; @@ -4092,6 +4246,363 @@ test("wake message: Stats line also present on a red (non-zero exit) run", async }); }); +// ── trace presets: real multi-runner corpus ─────────────────────────────── +// +// The built-in wake locator was deleted in favour of these opt-in presets (see +// digestPresets.ts) — the framework no longer guesses a failure pointer, so the +// wake's default shape is unchanged from the pre-locator era. This corpus is the +// SAME evidence the built-in was measured against, moved over rather than +// duplicated: each entry is either CAPTURED verbatim from a real runner on a +// development machine (temp-root rewritten to /proj; nothing else touched) or +// DOCUMENTARY (written from the runner's documented shape; toolchain absent). +// Each was compared case-by-case against the deleted locator: every entry +// reproduces or beats it. A preset prints NOTHING when no signature is found +// (silent, exactly as the old locator was on a green log). +const TRACE_CORPUS: Array<{ + name: string; + provenance: "captured" | "documentary"; + preset: string; + log: string; + /** Exact stdout without its trailing newline; null = no output at all. */ + expect: string | null; +}> = [ + { + name: "bun test — stack above the `(fail)` summary (backward + earliest-of-run)", + provenance: "captured", + preset: "js-trace", + expect: + "Failure: (fail) load part 02 > step 04 [0.46ms] — at loadStep (/proj/buntest/harness.ts:2:41)", + log: `bun test v1.3.6 (d530ed99) + +part-02.test.ts: +1 | export function loadStep(): void { +2 | throw new Error("boom: step 04 failed"); + ^ +error: boom: step 04 failed + at loadStep (/proj/buntest/harness.ts:2:41) + at (/proj/buntest/part-02.test.ts:4:3) +(fail) load part 02 > step 04 [0.46ms] + + 0 pass + 1 fail +Ran 1 test across 1 file. [14.00ms] +`, + }, + { + name: "node --test — AssertionError header, payload, then stack", + provenance: "captured", + preset: "js-trace", + expect: + "Failure: ✖ load part 02 > step 04 (1.857ms) — at loadStep (file:///proj/nodetest/harness.mjs:3:10)", + log: `✖ load part 02 > step 04 (1.857ms) +ℹ tests 1 +ℹ suites 0 +ℹ pass 0 +ℹ fail 1 +ℹ cancelled 0 +ℹ skipped 0 +ℹ todo 0 +ℹ duration_ms 67.162917 + +✖ failing tests: + +test at part-02.test.mjs:4:1 +✖ load part 02 > step 04 (1.857ms) + AssertionError [ERR_ASSERTION]: Expected values to be strictly deep-equal: + + actual - expected + + { + + actual: 1, + + got: 1 + - expected: 2, + - got: 2 + } + + at loadStep (file:///proj/nodetest/harness.mjs:3:10) + at TestContext. (file:///proj/nodetest/part-02.test.mjs:5:3) + at Test.runInAsyncScope (node:async_hooks:227:14) + at Test.run (node:internal/test_runner/test:1201:25) + at Test.start (node:internal/test_runner/test:1096:17) + at startSubtestAfterBootstrap (node:internal/test_runner/harness:385:17) { + generatedMessage: true, + code: 'ERR_ASSERTION', + actual: { actual: 1, got: 1 }, + expected: { expected: 2, got: 2 }, + operator: 'deepStrictEqual', + diff: 'simple' + } +`, + }, + { + name: "go test — panic; runtime frames skipped, user frame + offset stripped", + provenance: "captured", + preset: "js-trace", + expect: + "Failure: --- FAIL: TestLoadPart02 (0.00s) — /proj/gotest/main_test.go:6", + log: `--- FAIL: TestLoadPart02 (0.00s) +panic: boom: step 04 failed [recovered, repanicked] + +goroutine 35 [running]: +testing.tRunner.func1.2({0x100de5a60, 0x100e18a30}) + /usr/local/go/src/testing/testing.go:1974 +0x1a0 +testing.tRunner.func1() + /usr/local/go/src/testing/testing.go:1977 +0x318 +panic({0x100de5a60?, 0x100e18a30?}) + /usr/local/go/src/runtime/panic.go:860 +0x12c +gotest.loadStep(...) + /proj/gotest/main_test.go:6 +gotest.TestLoadPart02(0x4926f1b68248?) + /proj/gotest/main_test.go:10 +0x30 +created by testing.(*T).Run in goroutine 1 + /usr/local/go/src/testing/testing.go:2101 +0x3a8 +FAIL gotest 0.227s +FAIL +`, + }, + { + name: "cargo test — FAILED header; rust std frames skipped, user frame wins", + provenance: "captured", + preset: "js-trace", + expect: + "Failure: test tests::load_part_02 ... FAILED — at ./src/lib.rs:2:5", + log: ` Finished test [unoptimized + debuginfo] target(s) in 0.00s + Running unittests src/lib.rs (target/debug/deps/cargotest-0ec6bd605ee75128) + +running 1 test +test tests::load_part_02 ... FAILED + +failures: + +---- tests::load_part_02 stdout ---- +thread 'tests::load_part_02' panicked at src/lib.rs:2:5: +boom: step 04 failed +stack backtrace: + 0: rust_begin_unwind + at /rustc/79e9716c980570bfd1f666e3b16ac583f0168962/library/std/src/panicking.rs:597:5 + 1: core::panicking::panic_fmt + at /rustc/79e9716c980570bfd1f666e3b16ac583f0168962/library/core/src/panicking.rs:72:14 + 2: cargotest::load_step + at ./src/lib.rs:2:5 + 3: cargotest::tests::load_part_02 + at ./src/lib.rs:10:9 +`, + }, + { + name: "minitest (ruby) — `1) Error:` header + the raise site", + provenance: "captured", + preset: "rb-trace", + expect: "Failure: 1) Error: — test_part_02.rb:4:in `load_step'", + log: `Run options: --seed 25974 + +# Running: + +E + +Finished in 0.000198s, 5050.5052 runs/s, 0.0000 assertions/s. + + 1) Error: +TestPart02#test_load_part_02: +RuntimeError: boom: step 04 failed + test_part_02.rb:4:in \`load_step' + test_part_02.rb:9:in \`test_load_part_02' + +1 runs, 0 assertions, 0 failures, 1 errors, 0 skips +`, + }, + { + name: "java (JVM) — `Exception in thread` header + the throw frame", + provenance: "captured", + preset: "js-trace", + expect: + 'Failure: Exception in thread "main" java.lang.RuntimeException: boom: step 04 failed — at Foo.loadStep(Foo.java:3)', + log: `Exception in thread "main" java.lang.RuntimeException: boom: step 04 failed + at Foo.loadStep(Foo.java:3) + at Foo.main(Foo.java:2) +`, + }, + { + name: "unittest (python) — outermost-first traceback, deepest `File` frame", + provenance: "captured", + preset: "py-trace", + expect: + 'Failure: FAIL: test_load_part_02 (test_part_02.TestPart02) — File "/proj/unittest/test_part_02.py", line 4, in load_step', + log: `F +====================================================================== +FAIL: test_load_part_02 (test_part_02.TestPart02) +---------------------------------------------------------------------- +Traceback (most recent call last): + File "/proj/unittest/test_part_02.py", line 8, in test_load_part_02 + load_step() + File "/proj/unittest/test_part_02.py", line 4, in load_step + assert {"actual": 1, "got": 1} == {"expected": 2, "got": 2} +AssertionError + +---------------------------------------------------------------------- +Ran 1 test in 0.000s + +FAILED (failures=1) +`, + }, + { + name: "pytest — documented default traceback: failure named, no `File` frame (MISS)", + provenance: "documentary", + preset: "py-trace", + expect: + "Failure: E AssertionError: assert {'actual': 1, 'got': 1} == {'expected': 2, 'got': 2}", + log: `============================= test session starts ============================== +platform darwin -- Python 3.10.14, pytest-7.4.0, pluggy-1.0.0 +rootdir: /proj/pytest +collected 1 item + +test_part_02.py F [100%] + +=================================== FAILURES =================================== +________________________________ test_load_part_02 ______________________________ + + def test_load_part_02(): +> load_step() + +test_part_02.py:9: +_ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ + + def load_step(): +> assert {"actual": 1, "got": 1} == {"expected": 2, "got": 2} +E AssertionError: assert {'actual': 1, 'got': 1} == {'expected': 2, 'got': 2} + +=========================== short test summary info ============================ +FAILED test_part_02.py::test_load_part_02 - AssertionError: assert {'actual': 1, 'got': 1} == {'expected': 2, 'got': 2} +============================== 1 failed in 0.02s =============================== +`, + }, + { + name: "bun test green — no signature, no output", + provenance: "captured", + preset: "js-trace", + expect: null, + log: `bun test v1.3.6 (d530ed99) + + 1 pass + 0 fail + 1 expect() calls +Ran 1 test across 1 file. [4.00ms] +`, + }, +]; + +for (const c of TRACE_CORPUS) { + test(`preset ${c.preset} corpus (${c.provenance}): ${c.name}`, () => { + const out = runPreset(c.preset, c.log); + assert.equal(out, c.expect === null ? "" : c.expect + "\n"); + }); +} + +// A preset must anchor on evidence of a failing test, not on any line that +// merely contains a failure word. This suite's own PASSING tests print captured +// failure logs, so a real `bun test` run over it contains `(pass)` lines whose +// names carry `(fail)` / `FAILED` / `AssertionError` as text. When the only such +// line is a pass record the preset must stay silent: a wrong pointer costs more +// than none. +test("preset js-trace: a passing test named after `(fail)` never wins over the real failure", () => { + // Minimized verbatim slice of the real job log (lines 101, 893, 900, 944): a + // PASSING corpus test whose name mentions the `(fail)` summary precedes the + // actual failing test's `AssertionError` and its frame. + const log = [ + "(pass) preset js-trace corpus (captured): bun test — stack above the `(fail)` summary (backward + earliest-of-run) [19.06ms]", + "AssertionError [ERR_ASSERTION]: Expected values to be strictly deep-equal:", + " at TestContext. (/private/tmp/long-buried-fixture/part-05.test.ts:59:9)", + "(fail) load part 05 > step 15 [1.29ms]", + ].join("\n"); + assert.equal( + runPreset("js-trace", log), + "Failure: AssertionError [ERR_ASSERTION]: Expected values to be strictly deep-equal: — at TestContext. (/private/tmp/long-buried-fixture/part-05.test.ts:59:9)\n", + ); +}); + +test("preset js-trace: failure words only inside pass records yield no pointer", () => { + // Real `(pass)` lines 102 and 104: their names carry `AssertionError` and + // `FAILED` as text. No genuine failure signature exists, so the preset prints + // nothing rather than name a passing test. + const log = [ + "(pass) preset js-trace corpus (captured): node --test — AssertionError header, payload, then stack [16.37ms]", + "(pass) preset js-trace corpus (captured): cargo test — FAILED header; rust std frames skipped, user frame wins [16.17ms]", + ].join("\n"); + assert.equal(runPreset("js-trace", log), ""); +}); + +// ── failure-only digest (`on: "failure"`), enforced by the framework ─────── +// +// A digest command receives only the log path — never the exit code — so the +// gate cannot live in the command. These pin both branches with a REAL job: the +// same log content is emitted once with exit 1 and once with exit 0. +const TRACE_PRINTF = + "printf 'error: boom\\n at loadStep (/proj/buntest/harness.ts:2:41)\\n(fail) load part 02 > step 04 [0.46ms]\\n'"; +const TRACE_DIGEST_LINE = + "Failure: (fail) load part 02 > step 04 [0.46ms] — at loadStep (/proj/buntest/harness.ts:2:41)"; + +test("wake digest: on:failure appends the trace block for a failing job", async () => { + const { dir, proj, home } = setupDigestEnv(); + try { + writeJson(join(proj, ".pi", "pi-bgrun.json"), { + digest: { preset: "js-trace", on: "failure" }, + }); + const { pi, wakes, tools, ctx } = makeFakePi(); + trustCtx(ctx, proj, true); + await loadExtension(pi); + const bgrun = tools.get("bgrun")!; + + await bgrun.execute( + "call-gate1", + { command: `${TRACE_PRINTF}; exit 1` }, + undefined, + undefined, + ctx, + ); + await waitForWakes(wakes, 1); + const wake = wakes[0].text; + assert.match(wake, /❌/); + assert.equal( + digestBlockOf(wake), + TRACE_DIGEST_LINE, + "failing job carries the trace digest block", + ); + } finally { + teardownDigestEnv(dir, proj, home); + } +}); + +test("wake digest: on:failure is silent for a passing job with the SAME log", async () => { + const { dir, proj, home } = setupDigestEnv(); + try { + writeJson(join(proj, ".pi", "pi-bgrun.json"), { + digest: { preset: "js-trace", on: "failure" }, + }); + const { pi, wakes, tools, ctx } = makeFakePi(); + trustCtx(ctx, proj, true); + await loadExtension(pi); + const bgrun = tools.get("bgrun")!; + + // Identical log (an `Error:` and a `(fail)` line are present), exit 0. + await bgrun.execute( + "call-gate2", + { command: TRACE_PRINTF }, + undefined, + undefined, + ctx, + ); + await waitForWakes(wakes, 1); + const wake = wakes[0].text; + assert.match(wake, /✅/); + assert.equal( + digestBlockOf(wake), + null, + "framework gate: a green run contributes nothing from a failure-only entry", + ); + assert.ok(!wake.includes("digest ("), "no digest block at all"); + } finally { + teardownDigestEnv(dir, proj, home); + } +}); + test("wake message: missing log file — Stats shows duration only, wake still sent", async () => { await withJobsDir(async (dir, h) => { const { wakes, tools, ctx } = h; @@ -4247,6 +4758,18 @@ test("resolveConfig: invalid digest values dropped, valid ones kept (best-effort }); cfg = mod.resolveConfig({ cwd: proj, isProjectTrusted: () => true }); assert.equal(cfg.digest, undefined); + // `on: "failure"` is kept; any other value is a config error and drops the + // entry, exactly like a bad `match`/`type`. + writeJson(join(proj, ".pi", "pi-bgrun.json"), { + digest: { preset: "js-trace", on: "failure" }, + }); + cfg = mod.resolveConfig({ cwd: proj, isProjectTrusted: () => true }); + assert.deepEqual(cfg.digest, [{ preset: "js-trace", on: "failure" }]); + writeJson(join(proj, ".pi", "pi-bgrun.json"), { + digest: { preset: "js-trace", on: "always" }, + }); + cfg = mod.resolveConfig({ cwd: proj, isProjectTrusted: () => true }); + assert.equal(cfg.digest, undefined, "invalid `on` drops the entry"); // An array of non-entry scalars has no usable entries → undefined. writeJson(join(proj, ".pi", "pi-bgrun.json"), { digest: ["go-test"] }); cfg = mod.resolveConfig({ cwd: proj, isProjectTrusted: () => true }); @@ -4891,6 +5414,74 @@ test("preset junit-xml: green and red scorecards (real single-line pytest output "pretty-printed XML also names the failure", ); assert.ok(!pretty.includes("pytest"), "no testsuite name leak"); + + // ── location: emitted only when the emitter carries file/line ──────────── + // Captured `bun test --reporter=junit`: file+line live on the . + const bunXml = + '\n' + + '\n' + + ' \n' + + ' \n' + + ' \n' + + " \n" + + " \n" + + "\n"; + const bunRed = runPreset("junit-xml", bunXml); + assert.match(bunRed, /failures: 1 {2}errors: 0/); + assert.match( + bunRed, + /^load part 02 > step 04 \(part-02\.test\.ts:3\)$/m, + "bun's file+line is folded into the location", + ); + + // Captured `node --test --test-reporter=junit`: `` carries `file` + // but no `line` (and the failure element carries neither), so the location is + // the file alone. Attributes verbatim; the failure body is elided. + const nodeXml = [ + '', + "", + '\t', + '\t\t', + "[body elided]", + "\t\t", + "\t", + "", + ].join("\n"); + const nodeRed = runPreset("junit-xml", nodeXml); + assert.match( + nodeRed, + /^load part 02 > step 04 \(\/private\/tmp\/nodetest\/part-02\.test\.mjs\)$/m, + "node:test emits file without line — no phantom column", + ); + + // Documentary (no pytest/PHPUnit here): the documented shape puts + // `file`/`line` on the ; a failure element carrying them must work + // too, since emitters disagree on where the location sits. + const testcaseLoc = runPreset( + "junit-xml", + 'E assert False', + ); + assert.match( + testcaseLoc, + /^test_boom \(tests\/test_a\.py:42\)$/m, + "testcase-level file/line (pytest/PHPUnit shape)", + ); + const failureLoc = runPreset( + "junit-xml", + 'trace', + ); + assert.match( + failureLoc, + /^test_x \(\/a\/b\/Foo\.java:17\)$/m, + "failure-level file/line also honoured", + ); + // A testcase that is failing but carries only `file` (no `line`) must not + // gain a fake line number. + const fileOnly = runPreset( + "junit-xml", + 'x', + ); + assert.match(fileOnly, /^test_y \(a\/b\.py\)$/m, "file without line"); }); test("shipped presets: ids are stable and every command ends in head (bounded output)", () => { @@ -4899,6 +5490,9 @@ test("shipped presets: ids are stable and every command ends in head (bounded ou "jest", "pytest", "junit-xml", + "js-trace", + "py-trace", + "rb-trace", ]); for (const preset of DIGEST_PRESETS) { assert.match( @@ -5244,7 +5838,7 @@ test("wake digest: no digest configured → wake shape unchanged (regression gua assert.equal(lines[3], "Last output: hello world"); assert.equal( lines[4], - "The exit code, stats and last output above are the result — report it, and continue the task that depended on this. Read the log only for detail they do not carry.", + "The exit code, stats and last output above are a summary, not the diagnosis — the log holds the detail, including the context around any failure. For a failing job, read a window around the failure before concluding a cause.", ); assert.ok(!wake.includes("digest (")); } finally { @@ -5975,6 +6569,125 @@ test("digest nudge: a bgrun in a nested cwd writes the usage marker at the proje } }); +test("digest nudge: a failing job in a trusted, digest-less project fires the failure nudge once and writes the marker", async () => { + const { dir, proj, home } = setupDigestEnv(); + try { + const { pi, wakes, tools, ctx } = makeFakePi(); + trustCtx(ctx, proj, true); + const messages = captureNotify(ctx); + await loadExtension(pi); + const bgrun = tools.get("bgrun")!; + + await bgrun.execute( + "call-nudge-fail", + { command: "exit 3" }, + undefined, + undefined, + ctx, + ); + await waitForWakes(wakes, 1); + + // Filter to the nudge: the human toast (`… → exit 3`) is also a notify. + const nudges = messages.filter((m) => m === DIGEST_FAILURE_NUDGE_TEXT); + assert.equal(nudges.length, 1, "failure nudge toasted once"); + assert.ok(existsSync(nudgeMarker(dir, proj)), "marker file created"); + } finally { + teardownDigestEnv(dir, proj, home); + } +}); + +test("digest nudge: a passing job does not fire the failure nudge (marker untouched)", async () => { + const { dir, proj, home } = setupDigestEnv(); + try { + const { pi, wakes, tools, ctx } = makeFakePi(); + trustCtx(ctx, proj, true); + const messages = captureNotify(ctx); + await loadExtension(pi); + const bgrun = tools.get("bgrun")!; + + await bgrun.execute( + "call-nudge-ok", + { command: "echo green" }, + undefined, + undefined, + ctx, + ); + await waitForWakes(wakes, 1); + + assert.ok( + !messages.includes(DIGEST_FAILURE_NUDGE_TEXT), + "no failure nudge on a green job", + ); + assert.ok(!existsSync(nudgeMarker(dir, proj)), "no marker written"); + } finally { + teardownDigestEnv(dir, proj, home); + } +}); + +test("digest nudge: a second failing job does not repeat the failure nudge", async () => { + const { dir, proj, home } = setupDigestEnv(); + try { + const { pi, wakes, tools, ctx } = makeFakePi(); + trustCtx(ctx, proj, true); + const messages = captureNotify(ctx); + await loadExtension(pi); + const bgrun = tools.get("bgrun")!; + + await bgrun.execute( + "call-nudge-fail-1", + { command: "exit 1" }, + undefined, + undefined, + ctx, + ); + await waitForWakes(wakes, 1); + await bgrun.execute( + "call-nudge-fail-2", + { command: "exit 2" }, + undefined, + undefined, + ctx, + ); + await waitForWakes(wakes, 2); + + const nudges = messages.filter((m) => m === DIGEST_FAILURE_NUDGE_TEXT); + assert.equal(nudges.length, 1, "one-shot marker suppresses the repeat"); + } finally { + teardownDigestEnv(dir, proj, home); + } +}); + +test("digest nudge: a configured project does not fire the failure nudge (marker untouched)", async () => { + const { dir, proj, home } = setupDigestEnv(); + try { + writeJson(join(proj, ".pi", "pi-bgrun.json"), { + digest: { preset: "go-test" }, + }); + const { pi, wakes, tools, ctx } = makeFakePi(); + trustCtx(ctx, proj, true); + const messages = captureNotify(ctx); + await loadExtension(pi); + const bgrun = tools.get("bgrun")!; + + await bgrun.execute( + "call-nudge-configured", + { command: "exit 4" }, + undefined, + undefined, + ctx, + ); + await waitForWakes(wakes, 1); + + assert.ok( + !messages.includes(DIGEST_FAILURE_NUDGE_TEXT), + "no failure nudge when a digest is configured", + ); + assert.ok(!existsSync(nudgeMarker(dir, proj)), "no marker written"); + } finally { + teardownDigestEnv(dir, proj, home); + } +}); + test("resolveConfig: project config is honored only when the project is trusted", async () => { const mod: any = await loadModule(); const proj = mkdtempSync(join(tmpdir(), "pi-bgrun-proj-")); @@ -6361,7 +7074,10 @@ test("bggrep: the match budget trips and reports an error (timeout plumbing)", a const { pi, tools, ctx } = makeFakePi(); await loadExtension(pi); const id = `grep-budget-${Math.floor(Date.now() / 1000)}-${process.pid}`; - writeFileSync(join(dir, `${id}.log`), "alpha\nbeta\ngamma\n"); + // The marker makes this a finished log: a log with no marker whose pid is + // live reads as a still-running job, and the readers withhold content from + // those (this fixture is about the match budget, not liveness). + writeFileSync(join(dir, `${id}.log`), "alpha\nbeta\ngamma\n__BGRUN_EXIT__=0\n"); const bggrep = tools.get("bggrep")!; const t0 = Date.now(); const res = await bggrep.execute( diff --git a/extension/index.ts b/extension/index.ts index 7b819e9..d796d30 100644 --- a/extension/index.ts +++ b/extension/index.ts @@ -1526,6 +1526,17 @@ export function digestNudgeMarkerPath( export const DIGEST_NUDGE_TEXT = "pi-bgrun: no digest configured for this project — use the digest-config skill to set one up."; +/** + * The failure-time variant of DIGEST_NUDGE_TEXT, toasted at the first non-zero + * exit in a trusted project with no digest configured (see + * maybeNudgeDigest). Moment-aware: a failure just happened, so it says what a + * digest would have changed. Same one-shot marker as the session-start nudge, + * so a project is nudged at most once either way. Exported so tests assert the + * real string. + */ +export const DIGEST_FAILURE_NUDGE_TEXT = + "pi-bgrun: a job just failed and this project has no digest — a digest would surface the failure in the wake instead of leaving it in the log. Use the digest-config skill to set one up."; + // Job and config `type` values are short routing tokens. Both sides cap at the // same length; if only the job side truncated, a >MAX_TYPE_LEN config type // would silently never match the job's truncated type. @@ -1556,6 +1567,7 @@ function normalizeDigestEntry(raw: unknown): DigestEntry | undefined { label?: unknown; preset?: unknown; command?: unknown; + on?: unknown; }; // `type` and `match` compose (AND): both are kept and both must match at @@ -1629,6 +1641,19 @@ function normalizeDigestEntry(raw: unknown): DigestEntry | undefined { // Neither preset nor command → nothing this entry can score. Drop it. if (!preset && !command) return undefined; + // `on: "failure"` is the only accepted gate: the FRAMEWORK (not the command) + // appends this entry's output only for a non-zero exit. Anything else is a + // config error and drops the entry, like a bad `match`/`type`. + let on: "failure" | undefined; + if (entry.on !== undefined) { + if (entry.on === "failure") { + on = "failure"; + } else { + warnDigestInvalid("on", entry.on); + return undefined; + } + } + const out: DigestEntry = {}; if (type) out.type = type; if (match) out.match = match; @@ -1637,6 +1662,7 @@ function normalizeDigestEntry(raw: unknown): DigestEntry | undefined { } if (preset) out.preset = preset; if (command) out.command = command; + if (on) out.on = on; return out; } @@ -2031,7 +2057,7 @@ export default function (pi: ExtensionAPI) { // fixed-size buffer, no full-file read). Missing/unreadable file → null: // the Stats line then just omits the line count — best-effort, never // breaks a wake. - function countLogLines(logPath: string): number | null { + function scanLog(logPath: string): number | null { let fd: number; try { fd = openSync(logPath, "r"); @@ -2854,8 +2880,7 @@ export default function (pi: ExtensionAPI) { "Use bgrun (not bash) for any command expected to run >30s or emit >100 lines — tests, builds, linters.", "Give every bgrun job a short name (e.g. name: 'unit-tests') so it's recognizable in status output, the status widget, and wake messages.", "When the project's digest config defines `type` entries, pass the matching `type` (e.g. type: 'test') so the wake selects the right scorecard — bgrun's `started:` line names them when you omit it.", - "After bgrun returns a job id, continue other work; you will be woken automatically when it finishes.", - "Do not poll a job you just started — no bgstatus/bgtail round after bgrun: the wake is the signal and carries the outcome (exit code, stats, the log's last line, and the digest when the project configures one). Read the log only when the wake is not enough — typically the detail behind a failure.", + "Launching the job is the whole task: end your turn after a bgrun handoff — there is nothing to wait for. The wake arrives as a new turn carrying the outcome (exit code, stats, the log's last line, and the digest when the project configures one), but it is a summary, never the full record — on a failing job the context around the failure exists only in the log, so read a window around it before concluding a cause. Do not call bgstatus/bgtail/bggrep before it.", "Never cat or Read a full bgrun log — bgtail returns a condensed peek (ANSI stripped, repeats collapsed, ~8KB cap); use bggrep for pattern search, or read the log's path directly for whole-log analysis.", ]; @@ -2864,8 +2889,9 @@ export default function (pi: ExtensionAPI) { name: "bgrun", label: "Run in Background", description: toolDescription( - "Run a long shell command detached in the background. Returns 'started: ' immediately. " + - "You will be woken automatically when the job finishes. Use this instead of bash for any command " + + "Run a long shell command detached in the background. Returns 'started: ' immediately — " + + "your part is done there: end your turn, and you'll be woken when the job finishes. " + + "Use this instead of bash for any command " + "expected to run >30s or emit >100 lines (tests, builds, linters) — and whenever the output must " + "outlive the session, since the job's log survives a restart and stays greppable. Optionally pass " + "`name` for a short human-readable label used in the job id, status output, and wake messages, and " + @@ -3088,7 +3114,7 @@ export default function (pi: ExtensionAPI) { // Universal stats — duration + log line count. Non-heuristic, always // present, never pattern-based. A missing log contributes no line // count (duration is always known). - const logLines = countLogLines(logPath); + const logLines = scanLog(logPath); const statsParts = [formatDuration(rec.exitedAt - rec.started)]; if (logLines !== null) statsParts.push(`${logLines.toLocaleString("en-US")} lines`); @@ -3147,7 +3173,14 @@ export default function (pi: ExtensionAPI) { command: rec.cmd, }; const selected = selectDigestEntry(digestEntries, digestTarget); - if (selected) { + // A failure-only entry is the FRAMEWORK's call, not the command's: + // a digest command receives only the log path, never the exit code, + // so `on: "failure"` is enforced here. A green job contributes + // nothing from such an entry — even when its log happens to contain + // an `Error:` line a command would match. + const failureGatedOff = + selected?.on === "failure" && exitCode === 0; + if (selected && !failureGatedOff) { if (truncatedAt !== null) { // A scorecard reads the log's END (summary lines, failure // lists) — exactly what a head cap drops. Its numbers would be @@ -3163,7 +3196,7 @@ export default function (pi: ExtensionAPI) { const text = raw === undefined ? undefined : capDigestOutput(raw); if (text) digestBlock = { label: selected.label, text }; } - } else if (digestEntries?.length) { + } else if (!selected && digestEntries?.length) { // Configured but nothing selected — otherwise silent. Surface the // job's type/name plus the configured types, once per distinct // diagnostic (capped), so a type mismatch or dead glob is visible. @@ -3205,13 +3238,16 @@ export default function (pi: ExtensionAPI) { } else if (digestNoMatchNote) { wake += `${digestNoMatchNote}\n`; } - // A failure is what needs the log; on success the wake already IS the - // result. An unconditional "call bgtail" here would undo the point of the - // wake, because it is the last instruction the agent reads. + // The wake is a summary, never the diagnosis: it must not license a + // conclusion about a cause. Both closing lines say so — an + // unconditional "call bgtail" on success would undo the point of the + // wake, since it is the last instruction the agent reads. The failure + // wording is true whatever the ecosystem: the log holds the context, + // and a failure named by a trace digest is one failure, not the story. wake += exitCode === 0 - ? "The exit code, stats and last output above are the result — report it, and continue the task that depended on this. Read the log only for detail they do not carry." - : "Analyze the failure: `bgtail` for a peek at the end of the log, or `bggrep` to search it — then continue the task that depended on this."; + ? "The exit code, stats and last output above are a summary, not the diagnosis — the log holds the detail, including the context around any failure. For a failing job, read a window around the failure before concluding a cause." + : "Analyze the failure: `bgtail` for a peek at the end of the log, or `bggrep` to search it. This wake is a summary, not the diagnosis — the log holds the context around the failure, and any failure named here (by a trace digest) is one failure, not the whole story. Then continue the task that depended on this."; try { if (rec.ctx.isIdle()) { pi.sendUserMessage(wake); @@ -3237,6 +3273,15 @@ export default function (pi: ExtensionAPI) { ); } + // Failure-time digest nudge: a failure is the moment a digest's value + // becomes legible, so offer the failure-worded nudge here for a + // trusted, digest-less project. Shares the per-project one-shot marker + // with the session_start nudge, so a project nudged either way stays + // silent. Toast only — never enters LLM context. + if (exitCode !== 0) { + maybeNudgeDigest(rec.ctx, DIGEST_FAILURE_NUDGE_TEXT); + } + // Update/clear the widget. updateWidget(rec.ctx); }); @@ -3264,7 +3309,7 @@ export default function (pi: ExtensionAPI) { } startedLines.push( ` log: ${logPath}`, - ` You'll be woken automatically when it finishes — no need to poll; the wake carries the exit code, the stats and the log's last line.`, + ` ✅ Your part is done — end your turn now. Nothing to wait for: the wake will arrive as a new turn with the exit code, the stats and the log's last line.`, ); return { content: [{ type: "text", text: startedLines.join("\n") }], @@ -3412,13 +3457,20 @@ export default function (pi: ExtensionAPI) { let digestNoMatchSuppressed = false; const DIGEST_NO_MATCH_WARN_CAP = 3; - // ── Digest nudge: one-shot session_start toast for digest-less projects ──── + // ── Digest nudge: one-shot toast for digest-less projects ───────────────── // When a trusted project has actually used bgrun (≥1 finished job log in the // jobs dir) but never configured a digest, point the human at the - // digest-config skill once. Toast only — never sendUserMessage, so it costs - // zero LLM context. Dismissal is a per-project marker file in the jobs dir; - // the user's config files are never written. - function maybeNudgeDigest(ctx: ExtensionContext): void { + // digest-config skill once. Called from session_start (default text) and from + // the first non-zero exit of a job (DIGEST_FAILURE_NUDGE_TEXT) — the failure + // moment is when the value of a digest is legible, and the failure wording + // says so. Both share the same per-project marker, so a project is nudged at + // most once either way. Toast only — never sendUserMessage, so it costs zero + // LLM context. Dismissal is a per-project marker file in the jobs dir; the + // user's config files are never written. + function maybeNudgeDigest( + ctx: ExtensionContext, + text: string = DIGEST_NUDGE_TEXT, + ): void { try { if (!ctx.isProjectTrusted?.()) return; const cfg = resolveConfig(ctx); @@ -3430,11 +3482,11 @@ export default function (pi: ExtensionAPI) { if (!existsSync(jobUsageMarkerPath(cfg.jobsDir, projectDir))) return; const markerPath = digestNudgeMarkerPath(cfg.jobsDir, projectDir); if (existsSync(markerPath)) return; // already nudged once — stay silent - ctx.ui.notify(DIGEST_NUDGE_TEXT, "info"); + ctx.ui.notify(text, "info"); try { writeFileSync(markerPath, String(Date.now())); } catch { - // best-effort — a marker write failure must never break session_start + // best-effort — a marker write failure must never break the caller } } catch (err) { logWarn(`[pi-bgrun] digest nudge failed: ${(err as Error).message}`); @@ -3522,8 +3574,9 @@ export default function (pi: ExtensionAPI) { // Delta tailing: each read bookmarks the total raw line count at read time // (the high-water mark of what the caller has had the opportunity to see). // The FIRST read for a job returns the full last-N tail; repeat reads return - // only lines appended since, so polling a running job never re-pays context - // for lines already seen. Deliberately-skipped prefix lines are never + // only lines appended since, so lines already seen are never sent twice. A + // still-running job is answered with its state instead of any of this unless + // `peek: true` (see runningNote). Deliberately-skipped prefix lines are never // replayed as "new". raw: true keeps the verbatim last-N window (no delta // header) but still advances the bookmark. A shrunken log (rotated/replaced) // resets to a full tail. Bookmarks are in-memory only (see tailBookmarks @@ -3731,12 +3784,95 @@ export default function (pi: ExtensionAPI) { }; } + /** + * The state line a read of a still-running job stands in for. The readers used + * to report only the log's growth ("+62 new lines since last read"), never the + * job's own state, so a session that assumed it had been woken ("Wake + * received." 3.2s into a 23s job) found every delta read consistent with that + * assumption and kept polling — 9-12 calls per session, measured in the first + * benchmark cell. The tool description already forbade polling and was not + * obeyed, so the read itself stops paying off: content comes back only under + * the explicit `peek: true`, and the line names the completion semantics the + * session is supposed to act on. `withPeek: false` is bgstatus, which returns + * no log content and so has no opt-in to point at. + */ + function runningNote( + started: number, + withPeek: boolean, + now = Date.now(), + ): string { + const elapsed = ((now - started) / 1000).toFixed(1); + return ( + `⏳ still running — ${elapsed}s elapsed, no exit code yet. Launching was the task; the wake is your next input. ` + + (withPeek + ? `Nothing to read until it exits — pass peek: true only if you need the live log now.` + : `Nothing to read until it exits.`) + ); + } + + /** + * When the job started — the record's stamp, else the epoch its id encodes, + * else the log's birth time. + */ + function jobStartedFor( + rec: JobRecord | undefined, + id: string, + logPath: string, + ): number { + if (rec) return rec.started; + const epoch = jobEpochFromId(id); + if (epoch !== undefined) return epoch * 1000; + try { + return statSync(logPath).birthtimeMs; + } catch { + return Date.now(); + } + } + + /** + * The note a read of `id` must carry, or "" once the job has exited. State + * comes from the same source bgstatusCore's by-id path uses: the in-memory + * record (a live child is authoritative), and for a record with no live handle + * — reconstructed, adopted, or discovered only from the jobs dir — the log's + * exit marker, then pid liveness. A job bgrun cannot positively tie to a live + * process is NOT claimed as running: the note must never appear over a + * finished job's read, whose output stays byte-identical. + */ + function runningNoteFor( + id: string, + ctx: ExtensionContext | undefined, + now = Date.now(), + ): string { + // A native job's state is the host's to report; bgrun has no record of it. + if (nativeOutputFor(id, ctx)) return ""; + const rec = jobs.get(id); + const logPath = + rec?.logPath ?? join(resolveConfig(ctx).jobsDir, `${id}.log`); + let exit = rec?.exitCode; + if (exit === undefined && !rec?.child) { + const fromLog = parseExitFromLogPath(logPath); + if (fromLog !== null) exit = fromLog; + else { + const pid = rec ? rec.pid : pidFromId(id); + if (pid === null || pid <= 0 || !isRunningPid(pid)) exit = -1; + } + } + if (exit !== undefined) return ""; + return runningNote(jobStartedFor(rec, id, logPath), true, now); + } + // Shared by the bgtail tool (agent-facing) and the /bgtail slash command // (human-facing). Returns the read *unstamped*: both callers pass the result // through annotateNative() so a read of a native job's artifact says so — the // stamp needs the job id, which is the caller's parameter, not this one's. async function bgtailCore( - params: { id: string; lines?: number; raw?: boolean; bytes?: number }, + params: { + id: string; + lines?: number; + raw?: boolean; + bytes?: number; + peek?: boolean; + }, ctx?: ExtensionContext, ): Promise<{ content: { type: "text"; text: string }[]; @@ -3761,6 +3897,26 @@ export default function (pi: ExtensionAPI) { isError: true, }; } + // A read of a still-running job returns the state and NOTHING else — not + // even the delta. Serving that content is what paid the poller for each of + // the 9-12 calls measured in the first benchmark cell, so the payoff goes + // away; `peek: true` is the deliberate opt-in (watching a live long job is + // legitimate, it just must not be the default path). Gated before the + // bookkeeping below, so a later peek is still a first read, not a delta + // claiming lines the caller never saw. + const running = runningNoteFor(id, ctx); + if (running !== "" && params.peek !== true) { + return { + content: [{ type: "text", text: running }], + details: { + id, + logPath: resolved.logPath, + notFound: false, + running: true, + }, + }; + } + const statePrefix = running === "" ? "" : `${running}\n`; const { logPath, content, size } = resolved; // The cap dropped bytes off the END, so every "last N lines" view below is // the end of what was KEPT. Flag it in the output, or a mid-run line reads @@ -3828,7 +3984,8 @@ export default function (pi: ExtensionAPI) { let newLines: number | undefined; // Trailing content lines the caller will have been shown by this read. // Bookmarked so a later read can tell "asked for a wider window" (wants - // older lines it has not seen) apart from "polling" (wants appended lines). + // older lines it has not seen) apart from a plain repeat read (wants + // appended lines). let served: number; if (raw || prev === undefined || shrank || replaced || windowChanged || pathChanged) { // Full tail: first read, raw mode, a shrunken/replaced log, or a changed @@ -3856,7 +4013,7 @@ export default function (pi: ExtensionAPI) { // replies to the wrong question and reads as "nothing more to see", so // hand back the slice between the two windows instead. When everything // is already covered (served === total) there is nothing older to give, - // and the cheap poll response stands. + // and the small no-new-lines response stands. const widenedTo = Math.min(lines, total); if (lines > prev.served && total > prev.served) { const older = rawLines.slice( @@ -3881,7 +4038,7 @@ export default function (pi: ExtensionAPI) { { type: "text", text: - `no new lines since last read — showing the ${older.length} earlier line${older.length === 1 ? "" : "s"} of the widened window ` + + `${statePrefix}no new lines since last read — showing the ${older.length} earlier line${older.length === 1 ? "" : "s"} of the widened window ` + `(now the last ${widenedTo} of ${total})\n${condensed.text}${olderNotes}${capNote}${windowNote}${lineNote}`, }, ], @@ -3911,7 +4068,7 @@ export default function (pi: ExtensionAPI) { content: [ { type: "text", - text: `(no new lines since last read — log at ${total} line${total === 1 ? "" : "s"})${capNote}${windowNote}${lineNote}`, + text: `${statePrefix}(no new lines since last read — log at ${total} line${total === 1 ? "" : "s"})${capNote}${windowNote}${lineNote}`, }, ], details: { @@ -3958,7 +4115,7 @@ export default function (pi: ExtensionAPI) { content: [ { type: "text", - text: head + body + notes + capNote + windowNote + lineNote, + text: statePrefix + head + body + notes + capNote + windowNote + lineNote, }, ], details: { @@ -3980,7 +4137,7 @@ export default function (pi: ExtensionAPI) { name: "bgtail", label: "Tail Background Log", description: - "Read the newest lines of a background job's log, condensed for context: ANSI escapes stripped, repeated lines collapsed, long lines truncated, output capped (~8KB). Strips the exit-marker line. The first read returns the last N lines (default 40); REPEAT reads return only lines appended since your last read (delta tailing) — polling a running job never re-pays for the same lines. A larger `lines` than you have already been shown is a widening request: it returns the lines above your current coverage rather than the whole window again (coverage is a trailing run, so a line seen before an intervening gap can repeat). raw: true returns the unprocessed last-N window. A shrunken, replaced, or differently-windowed log resets to a full tail. Reads only the log's last 2 MiB by default (`bytes` widens it, max 64 MiB) and says so when the log is bigger. For pattern search use bggrep; for whole-log analysis, read the log path directly (it is printed by bgstatus and in these notes).", + "Read the newest lines of a background job's log, condensed for context: ANSI escapes stripped, repeated lines collapsed, long lines truncated, output capped (~8KB). Strips the exit-marker line. The first read returns the last N lines (default 40); repeat reads return only lines appended since your last read (delta tailing), so a line already shown is never sent twice. While the job is still running this returns its state and nothing else — pass `peek: true` to read the live log anyway. A larger `lines` than you have already been shown is a widening request: it returns the lines above your current coverage rather than the whole window again (coverage is a trailing run, so a line seen before an intervening gap can repeat). raw: true returns the unprocessed last-N window. A shrunken, replaced, or differently-windowed log resets to a full tail. Reads only the log's last 2 MiB by default (`bytes` widens it, max 64 MiB) and says so when the log is bigger. For pattern search use bggrep; for whole-log analysis, read the log path directly (it is printed by bgstatus and in these notes).", promptSnippet: "Read the last N lines of a bgrun job's log", parameters: Type.Object({ id: Type.String({ @@ -4005,6 +4162,12 @@ export default function (pi: ExtensionAPI) { minimum: 1, }), ), + peek: Type.Optional( + Type.Boolean({ + description: + "Read the log even while the job is running (default false: a running job returns its state only, since the wake carries the outcome).", + }), + ), }), async execute(_toolCallId, params, _signal, _onUpdate, ctx) { return annotateNative(await bgtailCore(params, ctx), params.id, ctx); @@ -4023,9 +4186,14 @@ export default function (pi: ExtensionAPI) { const MAX_GREP_MATCHES = 50; async function bggrepCore( - params: { id: string; pattern?: string; context?: number; bytes?: number }, + params: { + id: string; + pattern?: string; + context?: number; + bytes?: number; + peek?: boolean; + }, ctx?: ExtensionContext, - ): Promise<{ content: { type: "text"; text: string }[]; details: Record; @@ -4064,6 +4232,25 @@ export default function (pi: ExtensionAPI) { isError: true, }; } + // Same gate as bgtail: while the job is still running, a search returns the + // state, not the matches — the search result was the payoff that kept the + // poll loops going. `peek: true` is the explicit opt-in. Gated before the + // search, so the worker is not even spent on a read whose content would be + // withheld. + const running = runningNoteFor(id, ctx); + if (running !== "" && params.peek !== true) { + return { + content: [{ type: "text", text: running }], + details: { + id, + matches: 0, + logPath: resolved.logPath, + notFound: false, + running: true, + }, + }; + } + const statePrefix = running === "" ? "" : `${running}\n`; const { logPath, content, size } = resolved; // A capped log is missing its END, and "no matches" is exactly what a // failure pattern looks like when the failures were past the cap — so the @@ -4145,7 +4332,7 @@ export default function (pi: ExtensionAPI) { if (matchIdx.length === 0) { return { content: [ - { type: "text", text: `${header} — none${truncNote}${windowNote}${lineNote}` }, + { type: "text", text: `${statePrefix}${header} — none${truncNote}${windowNote}${lineNote}` }, ], details: { id, @@ -4187,7 +4374,7 @@ export default function (pi: ExtensionAPI) { content: [ { type: "text", - text: `${header}${capNote}\n${text}${notes}${truncNote}${windowNote}${lineNote}`, + text: `${statePrefix}${header}${capNote}\n${text}${notes}${truncNote}${windowNote}${lineNote}`, }, ], details: { @@ -4215,7 +4402,7 @@ export default function (pi: ExtensionAPI) { name: "bggrep", label: "Grep Background Log", description: toolDescription( - "Search the tail of a background job's log with a regex — the last 2 MiB by default, widen with `bytes` (each line is pre-truncated to 10k chars before matching); returns only matching lines with line numbers (optional context lines), capped (~50 matches, ~8KB) and condensed. JavaScript regex syntax: a LEADING `(?i)`, `(?m)` or `(?s)` group is accepted and translated to the equivalent flags — `(?i)error` searches case-insensitively. A *scoped* group (`(?i:error)`) is understood natively, anywhere. A bare flag group anywhere else is rejected with an explanation, since JS has no unscoped inline flags. Matching runs under a wall-clock budget (default 2s, PI_BGRUN_GREP_TIMEOUT_MS), so a runaway regex fails instead of hanging. Resolves the job id to the configured jobs dir itself, so there is no log path to reconstruct; reading that path directly is the whole-log escape hatch. Pass your own pattern whenever you know the log's format; with no pattern a generic failure-signature default is used (a convenience only — not a guarantee).", + "Search the tail of a background job's log with a regex — the last 2 MiB by default, widen with `bytes` (each line is pre-truncated to 10k chars before matching); returns only matching lines with line numbers (optional context lines), capped (~50 matches, ~8KB) and condensed. JavaScript regex syntax: a LEADING `(?i)`, `(?m)` or `(?s)` group is accepted and translated to the equivalent flags — `(?i)error` searches case-insensitively. A *scoped* group (`(?i:error)`) is understood natively, anywhere. A bare flag group anywhere else is rejected with an explanation, since JS has no unscoped inline flags. Matching runs under a wall-clock budget (default 2s, PI_BGRUN_GREP_TIMEOUT_MS), so a runaway regex fails instead of hanging. Resolves the job id to the configured jobs dir itself, so there is no log path to reconstruct; reading that path directly is the whole-log escape hatch. While the job is still running this returns its state and nothing else — pass `peek: true` to search the live log anyway. Pass your own pattern whenever you know the log's format; with no pattern a generic failure-signature default is used (a convenience only — not a guarantee).", BGGREP_GUIDELINES, ), promptSnippet: "Search a bgrun job's log for a pattern", @@ -4244,6 +4431,12 @@ export default function (pi: ExtensionAPI) { minimum: 1, }), ), + peek: Type.Optional( + Type.Boolean({ + description: + "Search the log even while the job is running (default false: a running job returns its state only, since the wake carries the outcome).", + }), + ), }), async execute(_toolCallId, params, _signal, _onUpdate, ctx) { return annotateNative(await bggrepCore(params, ctx), params.id, ctx); @@ -4289,6 +4482,10 @@ export default function (pi: ExtensionAPI) { if (rec.name) lines.push(` name: ${rec.name}`); if (rec.type) lines.push(` type: ${rec.type}`); lines.push(` cmd: ${rec.cmd}`, ` log: ${rec.logPath}`); + // Asking for status of a running job is itself a poll: the state is + // already the answer, but the consequence has to be spelled out or the + // next call is a tail. No `peek` sentence — this result carries no log. + if (state === "running") lines.push(runningNote(rec.started, false)); return { content: [{ type: "text", text: lines.join("\n") }], details: { @@ -4374,11 +4571,15 @@ export default function (pi: ExtensionAPI) { if (pid !== null && (pid <= 0 || !isRunningPid(pid))) exit = -1; } const state = exit === null ? "running" : "done"; + // Same consequence as the in-memory branch above: a log-only job that is + // still alive is a poll's target, not a result. + const note = + state === "running" ? `\n${runningNote(jobStartedFor(undefined, id, logPath), false)}` : ""; return { content: [ { type: "text", - text: `${id}: ${state}${exit === null ? "" : ` exit=${exit}`} (recovered from log)\n log: ${logPath}`, + text: `${id}: ${state}${exit === null ? "" : ` exit=${exit}`} (recovered from log)\n log: ${logPath}${note}`, }, ], details: { @@ -4876,10 +5077,12 @@ export default function (pi: ExtensionAPI) { const n = Number(tokens[1]); // Stamped like the tool path: a native job's artifact is the host's file // and the reader must say so. Both callers apply annotateNative() — see - // bgtailCore's note. + // bgtailCore's note. peek: true because a human typing /bgtail on a + // running job is asking for the log, which is exactly the explicit opt-in + // the agent-facing gate wants to be. const res = annotateNative( await bgtailCore( - { id, lines: Number.isFinite(n) && n > 0 ? n : undefined }, + { id, lines: Number.isFinite(n) && n > 0 ? n : undefined, peek: true }, ctx, ), id, diff --git a/skills/digest-config/SKILL.md b/skills/digest-config/SKILL.md index 831c8c7..bb43605 100644 --- a/skills/digest-config/SKILL.md +++ b/skills/digest-config/SKILL.md @@ -1,6 +1,6 @@ --- name: digest-config -description: Set up the pi-bgrun digest scorecard for this project. Use when the user asks to configure a digest, enable digest heuristics, or when a pi-bgrun nudge points at this skill. Samples the project's real job logs, picks a shipped preset (go-test, jest, pytest, junit-xml) or drafts a custom digest command, validates it against green AND red logs, then writes the digest section into the project's pi-bgrun.json. +description: Set up the pi-bgrun digest scorecard for this project. Use when the user asks to configure a digest, enable digest heuristics, or when a pi-bgrun nudge points at this skill. Samples the project's real job logs, picks a shipped preset (go-test, jest, pytest, junit-xml, js-trace, py-trace, rb-trace) or drafts a custom digest command, validates it against green AND red logs, then writes the digest section into the project's pi-bgrun.json. --- # Configure a project digest scorecard @@ -66,6 +66,11 @@ Selection (exactly one entry, or none): get a scorecard. - `label` sets the wake tag; without it a type entry uses its `type` string, a glob entry uses the matched `match.name`, else the preset id (or `command`). +- `"on": "failure"` appends the entry's output **only when the job exited + non-zero**. The framework enforces this (a digest command never sees the exit + code), so a green log containing an `Error:` line cannot inject a failure. + Use it for the `*-trace` presets. Absent means "always"; any other value is a + config error and drops the entry. - An invalid `match` (non-string), an invalid `type`, or an entry with no valid `preset`/`command` is dropped silently; an empty/all-invalid list counts as unconfigured. @@ -73,11 +78,28 @@ Selection (exactly one entry, or none): Shipped presets: `go-test` (package ok/FAIL counts + failing test names), `jest` (Tests/Test Suites summary + failed test names), `pytest` (final passed/failed/error summary line + FAILED test ids), `junit-xml` -(``/`` counts + failing testcase names). Each preset also +(``/`` counts + failing testcase names, with `file:line` when +the emitter carries one), and three **trace** presets that name a failure and +its source frame (there is no built-in wake pointer — these are it, and they +print nothing when their shape is not recognised): +`js-trace` (JS/TS, Go, Rust, JVM — innermost frame printed first: the deepest +frame, forwards or backwards), `py-trace` (Python — outermost printed first: the +**last** `File "…", line N` frame, which is the deepest), `rb-trace` (Ruby / +Minitest — `N) Error:` header + the first `path:line:in 'fn'` frame, the raise +site). Pair each with `"on": "failure"`. Each preset also carries a `suggestedType` (all `test`) — use it as the `type` when scaffolding an entry, e.g. `{ "type": "test", "preset": "go-test" }`. The suggestion is advisory; a preset entry with no `type` still applies to every job. +A scorecard that reports **counts only** (pass/fail totals, failing test names) +will not point at a failure — it says a job went red, not where or why. That is +what the three **trace** presets (`js-trace`, `py-trace`, `rb-trace`) are for: +they name the failure and its source frame. The tool does not try to detect a +"count-only" scorecard as a missing failure signal; that judgement needs +per-ecosystem knowledge and is left to you. When a project's logs carry stack +traces/tracebacks, prefer a matching trace preset (paired with `on: "failure"`) +over a count-only one. + ## Procedure 1. **Find done-job logs.** Locate the project's jobsDir from the config diff --git a/skills/run-bg/SKILL.md b/skills/run-bg/SKILL.md index 6d7fb87..517ebe8 100644 --- a/skills/run-bg/SKILL.md +++ b/skills/run-bg/SKILL.md @@ -84,8 +84,8 @@ Which to use: |---|---| | Start | `bgrun(command: "make test-short", name: "unit-tests", type: "test")` → `started: ` (name is an optional short label; use it so jobs are recognizable in `bgstatus`, the live panel, and wake messages) | | Status | `bgstatus()` for one job, or `bgstatus()` for this session's running jobs — finished jobs are hidden by default; pass `includeDone: true` to list them | -| Tail | `bgtail(, 40)` — first read: last-40 tail; later reads: only lines appended since (delta tailing); a *bigger* `lines` than you have been shown is a widening and returns the lines above your coverage (a line seen before a gap can repeat) | -| Grep | `bggrep(, "pattern", context?)` — line-numbered matches, capped and condensed; default pattern = generic failure signatures (override when you know the format). JavaScript regex: a leading `(?i)`/`(?m)`/`(?s)` group works (`(?i)error` = case-insensitive), a scoped `(?i:…)` group is native anywhere, and a bare flag group elsewhere is rejected | +| Tail | `bgtail(, 40)` — first read: last-40 tail; later reads: only lines appended since (delta tailing); a *bigger* `lines` than you have been shown is a widening and returns the lines above your coverage (a line seen before a gap can repeat). While the job is still running this returns its state and nothing else — `peek: true` reads the live log anyway | +| Grep | `bggrep(, "pattern", context?)` — line-numbered matches, capped and condensed; default pattern = generic failure signatures (override when you know the format). JavaScript regex: a leading `(?i)`/`(?m)`/`(?s)` group works (`(?i)error` = case-insensitive), a scoped `(?i:…)` group is native anywhere, and a bare flag group elsewhere is rejected. While the job is still running this returns its state and nothing else — `peek: true` searches the live log anyway | | Clean | `bgclean()` for this session's old logs; `bgclean all` to sweep every session's (default 7-day retention) | | Kill | `bgkill()` — SIGTERM to the job's process group; `force: true` for SIGKILL. Refuses finished ids, non-bgrun ids, and another session's job unless `includeForeign: true` | @@ -96,16 +96,22 @@ Which to use: matching `type` (e.g. `type: "test"`) — like `name`, it helps the wake select the right digest scorecard. You do not have to go looking for the vocabulary: when a digest is configured and you omit `type`, the `started:` - result names the configured types. Note the returned job-id. Continue other - work; you will be woken automatically when the job finishes. -2. **On wake:** the wake *is* the result — exit code, duration, log line count, the - log's last line, and the digest scorecard when the project configures one. + result names the configured types. Note the returned job-id. Launching was + the task: end your turn there — there is nothing to wait for, and the wake + arrives as a new turn carrying the outcome. +2. **On wake:** the wake carries the result summary — exit code, duration, log line count, the + log's last line, and the digest scorecard when the project configures one (a + trace digest there can name the failure and its source frame). It is a + summary, never the full record: on a failed run the context around the failure + exists only in the log, so read a window around it before concluding a cause. - `exit: 0` → success. Report it. Do not open the log to confirm: the wake's own counters are the confirmation, and a read spends context you do not need to spend. - `exit: ` → failure. Analyze the log (see below). 3. **Checking before the wake is rarely worth it.** A job you just started will be `running`, and the wake arrives on its own — polling early buys nothing and costs - a call. Reach for `bgstatus` when the job is *not* one you just started: another + a call. The readers enforce the same thing: while a job is running, `bgtail` / + `bggrep` return its state and nothing else (`peek: true` is the opt-in for the + live log). Reach for `bgstatus` when the job is *not* one you just started: another session's, one from before a restart, or one whose wake never came. - `running` → keep doing other work. Do NOT spin a wait loop. - `done exit=0` → success. @@ -126,7 +132,7 @@ first — it is a short pass/fail scorecard configured for this project and usually answers "what failed" without any follow-up read. `bgtail` stays the positional-peek tool for everything else. -- **Quick peek (≤40 lines):** call `bgtail` with the job id and `lines: 40` — strips the `__BGRUN_EXIT__` marker. The first read returns the last-40 tail; repeat reads return only lines appended since your last read (delta tailing) — polling a running job is nearly free. Asking for a **larger `lines`** than you have been shown widens the window instead: it returns the lines above your coverage, so widening costs the difference rather than re-sending the window (a line seen before a gap can repeat). +- **Quick peek (≤40 lines):** call `bgtail` with the job id and `lines: 40` — strips the `__BGRUN_EXIT__` marker. The first read returns the last-40 tail; repeat reads return only lines appended since your last read (delta tailing). **A still-running job is answered with its state, not the log** — pass `peek: true` when you really do need the live output (the wake is the normal path). Asking for a **larger `lines`** than you have been shown widens the window instead: it returns the lines above your coverage, so widening costs the difference rather than re-sending the window (a line seen before a gap can repeat). - **Failure extraction:** `bggrep(, "pattern")` — line-numbered matches with optional context lines, capped and condensed. Resolves the job id to the configured jobs dir itself — no path to reconstruct. (A sandboxed whole-log reader, context-mode's `ctx_execute_file`, can read the same file given its absolute path — see below.) Searches the last 2 MiB by default; `bytes: 67112960` (the 64 MiB ceiling plus the wrapper's 4 KiB of notices and exit marker) widens it to the whole kept log — the exact ceiling alone stops a few bytes short of the marker, dropping the head you asked for — more scanning costs latency and memory, **not context**, since the returned matches stay capped. Pass your own pattern whenever you know the tool's output format; the default only catches common failure signatures. - **Whole-log failure analysis:** read the log's **absolute path** directly (with a sandboxed whole-log reader if your environment has one — context-mode's