Skip to content

fix(analyze): keep a trace of unparseable replies and stop retrying them forever - #212

Open
Pantoria wants to merge 5 commits into
kunchenguid:mainfrom
Pantoria:fix/193-failed-analysis-trace
Open

Pantoria wants to merge 5 commits into
kunchenguid:mainfrom
Pantoria:fix/193-failed-analysis-trace

Conversation

@Pantoria

@Pantoria Pantoria commented Oct 15, 2026 •

Copy link
Copy Markdown
Contributor

Intent

Fix issue #193: when analysis output had no parseable JSON, the evidence record kept only status failed plus the error string, dropping the reply, and the transcript was re-analyzed every run forever. The user (issue author, Pantoria) asked to implement their own ready-for-pr backpass issues, each in its own worktree and PR. Follow AGENTS.md invariants and VISION.md. No live model calls were made while implementing.
Implemented all three proposal parts. (1) Failed records keep reply {class, chars, head, tail}: the whole reply is passed through the existing redact() before cutting 300-char head/tail (so a secret can't be split at a snippet edge); chars is measured by backpass from the raw reply. No harness stop reason is stored because acpx runs --format quiet and exposes none - deliberately not invented (never trust/invent model numbers). (2) A deterministic class empty/prose/truncated/unparsed (rules documented in classifyUnparsedReply) shows in status and in the error text. empty is unreachable today because assertNonEmptyOutput fires first, kept for completeness (open PR #183 may change that path). (3) Skip-after-N, which the maintainer allowed only if named in status and the last trace kept: MAX_UNPARSED_REPLIES = 3 constant (deliberately no config option), counted per failureKey = evidence key + answering agent/model/effort + analysis prompt hash, so any change resets; only unparseable replies count (timeouts/exec failures reset and always retry so a flaky provider can't make backpass give up); --force re-analyzes. Skips are named in a run warning, summary suffix, summary.gaveUp in --json, and status text/JSON. Skipped records stay failed, so never count as evidence. No ANALYSIS_INDEX_VERSION bump: only failure records change, not what analysis accepts; legacy failed records still load.

What Changed

  • When an analysis reply has no parseable JSON, the failed evidence record now stores reply: {class, chars, head, tail}. The whole reply goes through redact() before the 300-char head and tail are cut, and chars is measured from the raw reply. classifyUnparsedReply decides the class (empty, prose, truncated or unparsed) from the reply text alone, and the class also appears in the error text.
  • A transcript stops being retried after MAX_UNPARSED_REPLIES (3) unparseable replies in a row with the same failure key. The key covers evidence key, answering agent, model, effort and analysis prompt hash. Timeouts and exec failures reset the count. Changing any part of the key, or passing --force, analyzes the transcript again. Skipped records stay failed, so they never count as evidence. The skip is reported in a run warning, as a skipped after 3 failures suffix on the analyze/run/bootstrap evidence summary lines, and as summary.gaveUp in --json.
  • backpass status now reads the root evidence store and the store of each trained nested memory file. Shared layerNestedWeights logic in src/nested.js gives status and analyze the same nested-file hashes. Status shows reply classes, give-ups and a MEMORY column for failed transcripts (only when nested files are configured), and --json evidence gains a per-file files array. README and --force help text are updated, and test/analyze-failure-trace.test.js adds end-to-end coverage.

🤖 Generated with Claude Code

Risk Assessment

✅ Low: The change only touches failed-record bookkeeping and status reporting; given-up records stay failed and never count as evidence. Analyze and status now compute the root and nested hashes with the same shared helper, and the user decisions from the earlier fix rounds (a current-surface check for give-ups, nested evidence stores in status) are implemented correctly.

Testing

Ran the targeted end-to-end test file, which runs the real CLI with a fake acpx (all 9 pass). Then drove backpass by hand in a throwaway repo and HOME. A secret placed across the snippet edge is redacted before the cut and never stored. A truncated JSON reply is classed truncated and a prose reply prose, each with a character count backpass measures itself. After 3 prose replies the next run makes no model call and names the skip in the warning, the summary, and status text and JSON. --force calls the model again, and a timeout resets the count. One informational note: an acpx exit 1 with a pinned agent aborts the whole run, as it did before this change. That writes no record, so the streak is neither reset nor counted. It is not a regression. No UI surface, so no screenshots.

  • Live validation: ✅ go - 9 of 9 scenarios driven live against the product
Scenario Result Live Evidence
Unparseable (truncated) reply leaves a failed record with a redacted head/tail trace, class, and measured character count, and the warning shows the class ✅ pass live cli-transcript.txt first section: warn '...no parseable JSON (truncated)', record reply.class=truncated, chars=1049
Secret straddling the 300-char head boundary is redacted before cutting and never persisted ✅ pass live cli-transcript.txt: head ends 'API_KEY=[redac', grep for the secret finds 0 matches, prints 'secret not present in record'
After 3 unparseable replies in a row the next analyze makes no model call and names the skip in the warning and the summary ✅ pass live cli-transcript.txt: model calls stay at 3; '1 skipped after 3 failures' and a warn line
status text and --json report the give-up (NEXT RUN column, gaveUp count, per-file evidence entries); the skipped record stays failed and is not counted as evidence ✅ pass live cli-transcript.txt status output; evidence {ok:0, failed:1, gaveUp:1, files:[...]}
--force re-analyzes a given-up transcript ✅ pass live cli-transcript.txt: model calls go from 3 to 4 with --force
A changed analysis model or memory surface resets the streak; status shows 'retry' once the memory surface changes ✅ pass live test/analyze-failure-trace.test.js 'after 3 unparseable replies...' and 'status names a give-up only while the memory surface...' (real CLI with fake acpx)
A nested memory file's give-up appears in status (MEMORY column, per-file JSON) while the root analysis succeeds ✅ pass live test 'status reads a nested memory file's own evidence store and names its give-up'
Adversarial: a timeout resets the streak and keeps no reply trace, so a flaky provider cannot cause a give-up ✅ pass live test 'a timeout resets the streak and keeps no reply trace'
Old-format failed record without trace fields still shows in status and is retried ✅ pass live test 'status still lists an older failed record without the trace fields'
Evidence: CLI transcript of disposable end-to-end runs
=== Scenario: secret straddling the 300-char head edge, truncated JSON reply ===

$ backpass analyze --harness pi --since all --analysis-agent pi --jobs 1
· analyzing 1 transcript(s) with pi effort=medium at jobs=1
warn pi session 2026-10-30 00:35: analysis returned no parseable JSON (truncated)
  1/1 analyzed

analyzed against AGENTS.md (1 instructions, 15 tok)
  0 newly analyzed · 0 cached · 0 skipped (too short) · 1 failed
  failed transcripts are listed by `backpass status` and retried next run
[exit 0] [model calls so far: 1]
--- persisted evidence record ---
{
  "status": "failed",
  "error": "analysis returned no parseable JSON (truncated)",
  "unparsedReplies": 1,
  "reply": {
    "class": "truncated",
    "chars": 1049,
    "head": "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx API_KEY=[redac",
    "tail": "yyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyy\n{\"positive\": ["
  }
}
0
secret not present in record

=== Scenario: prose reply x3 then skip ===

$ backpass analyze --harness pi --since all --analysis-agent pi --jobs 1
· analyzing 1 transcript(s) with pi effort=medium at jobs=1
warn pi session 2026-10-30 00:35: analysis returned no parseable JSON (prose)
  1/1 analyzed

analyzed against AGENTS.md (1 instructions, 15 tok)
  0 newly analyzed · 0 cached · 0 skipped (too short) · 1 failed
  failed transcripts are listed by `backpass status` and retried next run
[exit 0] [model calls so far: 2]

$ backpass analyze --harness pi --since all --analysis-agent pi --jobs 1
· analyzing 1 transcript(s) with pi effort=medium at jobs=1
warn pi session 2026-10-30 00:35: analysis returned no parseable JSON (prose)
  1/1 analyzed

analyzed against AGENTS.md (1 instructions, 15 tok)
  0 newly analyzed · 0 cached · 0 skipped (too short) · 1 failed
  failed transcripts are listed by `backpass status` and retried next run
[exit 0] [model calls so far: 3]

$ backpass analyze --harness pi --since all --analysis-agent pi --jobs 1
warn 1 transcript(s) skipped after 3 unparseable replies in a row; `backpass status` lists them with their last reply, and a changed transcript, memory surface, analysis model, or --force analyzes them again

analyzed against AGENTS.md (1 instructions, 15 tok)
  0 newly analyzed · 0 cached · 0 skipped (too short) · 0 failed · 1 skipped after 3 failures
[exit 0] [model calls so far: 3]

$ backpass status
repo /st/tmp/tmp.fZpdGrBjUu/repo

BUDGET (always-loaded)
  AGENTS.md      [................................] 15 / 5,000 tok · 1 instructions

CACHE
  scan cache      1 file(s) fingerprinted
  evidence        0 ok · 0 skipped · 1 failed (1 skipped after 3 failures)
  rejections      0 remembered

FAILED TRANSCRIPTS (retried on the next run unless skipped)
HARNESS  SESSION  REPLY             NEXT RUN                  ERROR
pi       pi-s1    prose · 65 chars  skipped after 3 failures  analysis returned no parseable JSON (prose)
  each unparseable reply's redacted head and tail are kept in .backpass/evidence/<identity>.json

PROPOSAL
  none yet - run `backpass`

MODELS
  analysis        auto - 7 candidates, none probed yet
  synthesis       auto - 7 candidates, none probed yet
[exit 0] [model calls so far: 3]

$ backpass status --json
{
  "repo": "repo",
  "budgets": [
    {
      "path": "AGENTS.md",
      "label": "AGENTS.md",
      "capTokens": 5000,
      "current": 15,
      "projected": 15,
      "delta": 0,
      "withinBudget": true,
      "over": 0,
      "utilization": 0.003,
      "instructions": 1,
      "pointerTo": null,
      "separate": false
    }
  ],
  "crossSurfaceDuplicates": [],
  "evidence": {
    "ok": 0,
    "failed": 1,
    "skipped": 0,
    "gaveUp": 1,
    "files": [
      {
        "path": "AGENTS.md",
        "ok": 0,
        "failed": 1,
        "skipped": 0,
        "gaveUp": 1
      }
    ]
  },
  "scanCacheEntries": 1,
  "hosts": {},
  "summary": null,
  "proposal": null,
  "rejections": 0,
  "skills": 0
}
[exit 0] [model calls so far: 3]

=== Scenario: --force re-analyzes a given-up transcript ===

$ backpass analyze --harness pi --since all --analysis-agent pi --jobs 1 --force
· analyzing 1 transcript(s) with pi effort=medium at jobs=1
warn pi session 2026-10-30 00:35: analysis returned no parseable JSON (prose)
  1/1 analyzed

analyzed against AGENTS.md (1 instructions, 15 tok)
  0 newly analyzed · 0 cached · 0 skipped (too short) · 1 failed
  failed transcripts are listed by `backpass status` and retried next run
[exit 0] [model calls so far: 4]

=== Scenario: exec failure (non-timeout exit 1) resets the streak ===

$ backpass analyze --harness pi --since all --analysis-agent pi --jobs 1
· analyzing 1 transcript(s) with pi effort=medium at jobs=1
warn pi session 2026-10-30 00:35: analysis returned no parseable JSON (prose)
  1/1 analyzed

analyzed against AGENTS.md (1 instructions, 15 tok)
  0 newly analyzed · 0 cached · 0 skipped (too short) · 1 failed
  failed transcripts are listed by `backpass status` and retried next run
[exit 0] [model calls so far: 5]

$ backpass analyze --harness pi --since all --analysis-agent pi --jobs 1
· analyzing 1 transcript(s) with pi effort=medium at jobs=1
warn pi session 2026-10-30 00:35: analysis returned no parseable JSON (prose)
  1/1 analyzed

analyzed against AGENTS.md (1 instructions, 15 tok)
  0 newly analyzed · 0 cached · 0 skipped (too short) · 1 failed
  failed transcripts are listed by `backpass status` and retried next run
[exit 0] [model calls so far: 6]
streak before exec failure: 2

$ backpass analyze --harness pi --since all --analysis-agent pi --jobs 1
· analyzing 1 transcript(s) with pi effort=medium at jobs=1
  1/1 analyzed
error pinned analysis agent pi failed unexpectedly (acpx pi session prompt failed (exit 1): boom)
  check the pi/acpx failure above and retry; or pin a different harness: backpass --analysis-agent <agent>
[exit 1] [model calls so far: 7]
after exec failure: {"error":"analysis returned no parseable JSON (prose)","unparsedReplies":2,"reply":{"class":"prose","chars":65,"head":"I reviewed the session; the agent followed the build instruction."}}

$ backpass analyze --harness pi --since all --analysis-agent pi --jobs 1
· analyzing 1 transcript(s) with pi effort=medium at jobs=1
warn pi session 2026-10-30 00:35: analysis returned no parseable JSON (prose)
  1/1 analyzed

analyzed against AGENTS.md (1 instructions, 15 tok)
  0 newly analyzed · 0 cached · 0 skipped (too short) · 1 failed
  failed transcripts are listed by `backpass status` and retried next run
[exit 0] [model calls so far: 8]

$ backpass analyze --harness pi --since all --analysis-agent pi --jobs 1
warn 1 transcript(s) skipped after 3 unparseable replies in a row; `backpass status` lists them with their last reply, and a changed transcript, memory surface, analysis model, or --force analyzes them again

analyzed against AGENTS.md (1 instructions, 15 tok)
  0 newly analyzed · 0 cached · 0 skipped (too short) · 0 failed · 1 skipped after 3 failures
[exit 0] [model calls so far: 8]
streak: 3

=== Scenario: skipped record is not evidence; propose-ready counts ===

$ backpass status --json
{
  "repo": "repo",
  "budgets": [
    {
      "path": "AGENTS.md",
      "label": "AGENTS.md",
      "capTokens": 5000,
      "current": 15,
      "projected": 15,
      "delta": 0,
      "withinBudget": true,
      "over": 0,
      "utilization": 0.003,
      "instructions": 1,
      "pointerTo": null,
      "separate": false
    }
  ],
  "crossSurfaceDuplicates": [],
  "evidence": {
    "ok": 0,
    "failed": 1,
    "skipped": 0,
    "gaveUp": 1,
    "files": [
      {
        "path": "AGENTS.md",
        "ok": 0,
        "failed": 1,
        "skipped": 0,
        "gaveUp": 1
      }
    ]
  },
  "scanCacheEntries": 1,
  "hosts": {},
  "summary": null,
  "proposal": null,
  "rejections": 0,
  "skills": 0
}
[exit 0] [model calls so far: 8]
Evidence: Driver script for the manual runs
#!/bin/bash
# Disposable end-to-end drive of backpass analyze/status with a fake acpx (no live model).
set -u
W=$1
T=$(mktemp -d); HOME_D=$T/home; REPO=$T/repo; BIN=$T/bin
mkdir -p $HOME_D $REPO $BIN
printf '#!%s\nprocess.exit(0);\n' "$(command -v node)" > $BIN/pi
cat > $BIN/acpx <<JS
#!$(command -v node)
const fs=require("node:fs");const a=process.argv.slice(2);
if(a.includes("config")&&a.includes("show")){console.log(JSON.stringify({agents:{}}));process.exit(0)}
if(a.includes("--file")){fs.appendFileSync("$T/calls","x\n");const r=fs.readFileSync("$T/reply","utf8");
if(r==="__EXIT1__"){process.stderr.write("boom\n");process.exit(1)}process.stdout.write(r);process.exit(0)}
process.exit(0)
JS
chmod +x $BIN/*
cd $REPO; git init -q -b main; git config user.email t@e; git config user.name t
printf '# Agent instructions\n\n- Run `make build` before every push.\n' > AGENTS.md; git add -A; git commit -qm m
S=$HOME_D/.pi/agent/sessions/s1; mkdir -p $S
node -e '
const id="s1",cwd=process.argv[1];const e=[{type:"session",version:3,id,timestamp:new Date().toISOString(),cwd},
{type:"message",message:{role:"user",content:"Please build the project."}},{type:"message",message:{role:"assistant",content:"Ran make build."}},
{type:"message",message:{role:"user",content:"Now run tests."}},{type:"message",message:{role:"assistant",content:"Tests pass."}}];
require("fs").writeFileSync(process.argv[2],e.map(x=>JSON.stringify(x)).join("\n")+"\n")' "$REPO" $S/1_s1.jsonl
export HOME=$HOME_D PATH=$BIN:$PATH BACKPASS_ACPX_BIN=$BIN/acpx NO_COLOR=1
bp(){ echo; echo "\$ backpass $*"; node $W/bin/backpass.js "$@" 2>&1; echo "[exit $?] [model calls so far: $(wc -l < $T/calls 2>/dev/null || echo 0)]"; }
A="analyze --harness pi --since all --analysis-agent pi --jobs 1"
echo "=== Scenario: secret straddling the 300-char head edge, truncated JSON reply ==="
python3 -c 'import sys;sys.stdout.write("x"*285+" API_KEY=sk-abcdefghijklmnopqrstuvwxyz0123456789 "+"y"*700+"\n{\"positive\": [")' > $T/reply
bp $A
echo "--- persisted evidence record ---"; cat $REPO/.backpass/evidence/*.json | node -e 'const r=JSON.parse(require("fs").readFileSync(0));console.log(JSON.stringify({status:r.status,error:r.error,unparsedReplies:r.unparsedReplies,reply:r.reply},null,2))'
grep -c 'abcdefghijklmnop' $REPO/.backpass/evidence/*.json && echo "SECRET LEAKED" || echo "secret not present in record"
echo; echo "=== Scenario: prose reply x3 then skip ==="
printf 'I reviewed the session; the agent followed the build instruction.' > $T/reply
bp $A; bp $A; bp $A
bp status
bp status --json
echo; echo "=== Scenario: --force re-analyzes a given-up transcript ==="
bp $A --force
echo; echo "=== Scenario: exec failure (non-timeout exit 1) resets the streak ==="
rm -rf $REPO/.backpass/evidence
bp $A; bp $A
cat $REPO/.backpass/evidence/*.json | node -e 'const r=JSON.parse(require("fs").readFileSync(0));console.log("streak before exec failure:",r.unparsedReplies)'
printf '__EXIT1__' > $T/reply
bp $A
cat $REPO/.backpass/evidence/*.json | node -e 'const r=JSON.parse(require("fs").readFileSync(0));console.log("after exec failure:",JSON.stringify({error:r.error,unparsedReplies:r.unparsedReplies,reply:r.reply}))'
printf 'still prose' > $T/reply; bp $A; bp $A
cat $REPO/.backpass/evidence/*.json | node -e 'const r=JSON.parse(require("fs").readFileSync(0));console.log("streak:",r.unparsedReplies)'
echo; echo "=== Scenario: skipped record is not evidence; propose-ready counts ==="
bp status --json
rm -rf $T
Evidence: Targeted e2e test output
✔ each unparseable reply is classed from its text alone (1.492843ms)
✔ the saved reply trace is redacted before it is cut, and measures the raw reply (1.190177ms)
✔ a streak gives up only for the same evidence key and the same analysis call (0.832997ms)
✔ a failed analysis keeps a redacted, classed trace of the reply (321.568611ms)
✔ after 3 unparseable replies in a row a transcript is skipped by name until its key changes (1651.949241ms)
✔ status names a give-up only while the memory surface it gave up at is current (1086.352085ms)
✔ status reads a nested memory file's own evidence store and names its give-up (1089.635256ms)
✔ a timeout resets the streak and keeps no reply trace (787.600565ms)
✔ status still lists an older failed record without the trace fields (495.083842ms)
ℹ tests 9
ℹ suites 0
ℹ pass 9
ℹ fail 0
ℹ cancelled 0
ℹ skipped 0
ℹ todo 0
ℹ duration_ms 5513.54643
Evidence: status after give-up
evidence 0 ok · 0 skipped · 1 failed (1 skipped after 3 failures)
HARNESS SESSION REPLY NEXT RUN ERROR
pi pi-s1 prose · 65 chars skipped after 3 failures analysis returned no parseable JSON (prose)
- Outcome: ⚠️ 1 info across 4 runs (4m53s)

Pipeline

Updates from git push no-mistakes

✅ **intent** - passed

✅ No issues found.

✅ **Rebase** - passed

✅ No issues found.

🔧 **Review** - 1 issue found → auto-fixed ✅
  • ⚠️ src/commands/status.js:241 - isGivenUp in status only checks unparsedReplies &gt;= MAX_UNPARSED_REPLIES. It never checks that the record still matches the current evidence key. Here is a sequence that happens in normal use: a transcript gives up at memory hash H1, then backpass apply edits AGENTS.md so the memory surface becomes H2. backpass status still shows the record as skipped after 3 failures: in the NEXT RUN column, in the evidence summary suffix (status.js:147) and in evidence.gaveUp in --json (status.js:84). But the next run will retry it, because replyFailureKey includes evidenceKey(..., H2) and the streak resets (hasGivenUp in src/analyze.js). The label is wrong but no error is raised. The comment at status.js:236-240 accepts this for the agent dimension, since status does not resolve agents. The memory-surface and transcript-content parts of the key can be checked cheaply, though: status already resolves the memory files for its budgets. Smallest fix: treat a record as given up only when record.memoryHash equals the current memorySurfaceHash for its memory path, and optionally when record.key matches the current evidenceKey. Otherwise show it as retry. The same predicate feeds three places: status.js:31 (count), status.js:147 (text suffix) and status.js:181 (row label).

🔧 Fix applied.
✅ Re-checked - no issues remain.

✅ No issues found.

✅ No issues found.

✅ No issues found.

⚠️ **Test** - 1 info

✅ No issues found.

  • Live validation: ✅ go - 8 of 8 scenarios driven live against the product
Scenario Result Live Evidence
A reply with no parseable JSON is saved in the failed record as a redacted, classed trace (class, chars, head, tail), and the error text names the class ✅ pass live cli-transcript.txt: the evidence JSON has class truncated, chars 700, and ANTHROPIC_API_KEY=[redacted:ANTHROPIC_KEY]; grep for sk-ant finds 0 matches; the warning reads '(truncated)'
After 3 unparseable replies in a row, the next analyze makes no model call and names the skip in the warning, the summary and the --json gaveUp field ✅ pass live cli-transcript.txt run 4: model calls stay at 3; warning '1 transcript(s) skipped after 3 unparseable replies'; summary shows '1 skipped after 3 failures'; --json has gaveUp 1
backpass status and status --json show the skipped transcript with its reply class, size and NEXT RUN label ✅ pass live cli-transcript.txt: the status row reads 'truncated · 700 chars skipped after 3 failures'; status --json evidence has gaveUp 1
--force re-analyzes a skipped transcript ✅ pass live cli-transcript.txt: model calls go from 3 to 4 on the --force run
After AGENTS.md changes, status shows the old skipped record as 'retry' and the next analyze retries it (recorded review decision) ✅ pass live cli-transcript.txt: after the edit, the status NEXT RUN column reads retry with no '(skipped after' suffix, and analyze makes model call 5
Adversarial: timeouts (acpx exit 3) reset the count, keep no reply trace, and never lead to a skip ✅ pass live cli-transcript.txt: after a timeout the record has error 'own timeout (exit 3)' with no unparsedReplies and no reply; 4 more timeout runs still made model calls (11 in total)
A failed record from before this change (no trace fields) still shows in status and is retried ✅ pass live node --test test/analyze-failure-trace.test.js: test 'status still lists an older failed record without the trace fields' drives the real CLI
A later parseable reply turns the failed record into ok evidence ✅ pass live cli-transcript.txt: '1 newly analyzed'; status --json reports {ok:1, failed:0, gaveUp:0}
  • node --test test/analyze-failure-trace.test.js (8/8 pass; runs the real CLI end to end with a fake acpx)
  • Manual CLI session in a throwaway HOME/repo with a fake pi and a fake acpx (reply set from a file, each call logged): 3x backpass analyze --harness pi --since all --analysis-agent pi --jobs 1, then inspected the evidence JSON and grepped it for the secret
  • 4th backpass analyze (skip, model call count unchanged), --json summary, backpass status, backpass status --json
  • backpass analyze --force after the skip
  • Edited AGENTS.md, then backpass status (shows retry) and backpass analyze (re-analyzes)
  • Adversarial: 2 unparseable replies, then repeated acpx exit-3 timeouts: the count resets, no reply trace is kept, and every run still calls the model
  • Recovery: a parseable JSON reply turns the record into ok evidence; status --json reports gaveUp 0

✅ No issues found.

  • Live validation: ✅ go - 6 of 6 scenarios driven live against the product
Scenario Result Live Evidence
An unparseable reply is saved in the failed evidence record with class, measured character count, and redacted head/tail; the secret never reaches disk ✅ pass live cli-transcript.txt Scenario A: record has class 'truncated', chars 561, head shows 'TOKEN=[redacted]'; grep finds the secret 0 times; the error text says '(truncated)'
After 3 unparseable replies in a row, the next analyze makes no model call and names the skip in a warning and in the summary ✅ pass live cli-transcript.txt Scenario B: model call count stays at 6; warning reads '1 transcript(s) skipped after 3 unparseable replies...'; summary line ends '1 skipped after 3 failures'
status text and --json show give-ups per memory file, including a nested file's own evidence store ✅ pass live cli-transcript.txt Scenario B: MEMORY column shows the AGENTS.md and apps/api/AGENTS.md rows; JSON evidence.files has per-file gaveUp counts; the test 'status reads a nested memory file's own evidence…
Adversarial: an edit to a memory file shows status 'retry' instead of a stale skip label, and a valid reply then counts as ok evidence ✅ pass live cli-transcript.txt Scenario D: NEXT RUN changes to 'retry' after both files are edited; analyze makes 2 model calls; status shows '2 ok · 0 skipped · 0 failed'
--force asks the model again for a transcript that was skipped ✅ pass live cli-transcript.txt Scenario C: model call count goes from 6 to 8
Adversarial: repeated timeouts never cause a skip and keep no reply trace ✅ pass live cli-transcript.txt Scenario E: 4 runs in a row each show failed=1 gaveUp=0; the record has only the timeout error, with no unparsedReplies or reply field
  • node --test test/analyze-failure-trace.test.js (the 9 targeted tests for this change, which run the real CLI with a stub acpx)
  • ~/.no-mistakes/evidence/01M503V3YM500JB8EM5J3KKE5C/drive.sh in a throwaway git repo and HOME with a nested apps/api/AGENTS.md: backpass analyze run repeatedly, backpass status, backpass status --json, analyze --force, editing both memory files, a valid reply, and 4 timeout runs

✅ No issues found.

  • Live validation: ✅ go - 8 of 9 scenarios driven live against the product
Scenario Result Live Evidence
An unparseable analysis reply is saved in the failed record as a redacted, classed trace {class, chars, head, tail}, with the class shown in the run warning ✅ pass live cli-walkthrough.txt: warning 'no parseable JSON (truncated)'; the record has class truncated, chars 796, head shows ANTHROPIC_API_KEY=[redacted:ANTHROPIC_KEY], and tail ends with {"positive": [
Adversarial: a secret in the reply never reaches .backpass ✅ pass live cli-walkthrough.txt: grep for sk-ant-abc under .backpass found no secret
After 3 unparseable replies in a row the 4th run makes no model call and names the skip in a warning and the summary ✅ pass live cli-walkthrough.txt run 4: model calls stay at 6; 'warn 1 transcript(s) skipped after 3 unparseable replies'; '1 skipped after 3 failures' for both root and nested
backpass status lists skipped transcripts from the root and nested stores, with MEMORY column, reply class, and per-file --json counts ✅ pass live cli-walkthrough.txt: status table with AGENTS.md and apps/api/AGENTS.md rows marked 'skipped after 3 failures'; JSON evidence.files has a per-path gaveUp of 1
--force re-analyzes a given-up transcript ✅ pass live cli-walkthrough.txt: --force run calls the model twice (calls 6 to 8)
Adversarial: after a memory edit, status no longer calls the record given up (shows retry) ✅ pass live cli-walkthrough.txt: after editing the nested file its row shows 'retry' and the root row stays skipped; the test 'status names a give-up only while the memory surface it gave up at is current' passes
Adversarial: a timeout resets the streak and stores no reply trace, so a flaky provider cannot cause a give-up ✅ pass live cli-timeout-and-recovery.txt: the timeout record has only status and error; the next prose reply gives unparsedReplies 1
A parseable reply after a memory change is stored as ok evidence (skipped records never count as evidence) ✅ pass live cli-timeout-and-recovery.txt: summary analyzed 1, gaveUp 0, record status ok
A legacy failed record with no trace fields still shows in status and is retried ⏸️ untested no The prior payload did not establish a live result for this scenario. It was covered only by the automated test 'status still lists an older failed record without the trace fields' in test/analyze-fail…
  • node --test test/analyze-failure-trace.test.js (9/9 pass; the CLI-level tests spawn bin/backpass.js against a fake acpx)

  • Manual: 4 consecutive backpass analyze --harness pi --since all --analysis-agent pi --jobs 1 runs against a throwaway repo with a root and a nested AGENTS.md; model-call count stays at 6 on run 4

  • Manual: inspected .backpass/evidence/*.json for reply {class, chars, head, tail} and unparsedReplies; grep -r sk-ant-abc .backpass found no secret

  • Manual: backpass status and backpass status --json after give-up (MEMORY column, both files 'skipped after 3 failures', evidence.files per-file counts)

  • Manual: backpass analyze --force re-calls the model for skipped transcripts

  • Manual: appended a line to nested apps/api/AGENTS.md, then backpass status shows that row as retry while the root row stays skipped

  • Manual: acpx exit 3 (timeout) with --force leaves a record with no reply trace and no counter; the next prose reply counts as unparsedReplies 1

  • Manual: memory edit plus a valid JSON reply gives an ok record, summary gaveUp 0

  • ℹ️ src/analyze.js - With a pinned analysis agent, an acpx exit 1 aborts the whole run ('pinned analysis agent pi failed unexpectedly'). This is pre-existing behaviour. Because no per-transcript record is written, the unparseable streak is neither reset nor counted: prose, prose, exit 1, then prose gives up after the third prose reply. This matches 'only unparseable replies count' but not a literal reading of 'exec failures reset'. Per-transcript timeouts do reset, as tested.

  • Live validation: ✅ go - 9 of 9 scenarios driven live against the product

Scenario Result Live Evidence
Unparseable (truncated) reply leaves a failed record with a redacted head/tail trace, class, and measured character count, and the warning shows the class ✅ pass live cli-transcript.txt first section: warn '...no parseable JSON (truncated)', record reply.class=truncated, chars=1049
Secret straddling the 300-char head boundary is redacted before cutting and never persisted ✅ pass live cli-transcript.txt: head ends 'API_KEY=[redac', grep for the secret finds 0 matches, prints 'secret not present in record'
After 3 unparseable replies in a row the next analyze makes no model call and names the skip in the warning and the summary ✅ pass live cli-transcript.txt: model calls stay at 3; '1 skipped after 3 failures' and a warn line
status text and --json report the give-up (NEXT RUN column, gaveUp count, per-file evidence entries); the skipped record stays failed and is not counted as evidence ✅ pass live cli-transcript.txt status output; evidence {ok:0, failed:1, gaveUp:1, files:[...]}
--force re-analyzes a given-up transcript ✅ pass live cli-transcript.txt: model calls go from 3 to 4 with --force
A changed analysis model or memory surface resets the streak; status shows 'retry' once the memory surface changes ✅ pass live test/analyze-failure-trace.test.js 'after 3 unparseable replies...' and 'status names a give-up only while the memory surface...' (real CLI with fake acpx)
A nested memory file's give-up appears in status (MEMORY column, per-file JSON) while the root analysis succeeds ✅ pass live test 'status reads a nested memory file's own evidence store and names its give-up'
Adversarial: a timeout resets the streak and keeps no reply trace, so a flaky provider cannot cause a give-up ✅ pass live test 'a timeout resets the streak and keeps no reply trace'
Old-format failed record without trace fields still shows in status and is retried ✅ pass live test 'status still lists an older failed record without the trace fields'
  • node --test test/analyze-failure-trace.test.js (9/9 pass; it runs the real CLI with a fake acpx and covers classing, redaction, the 3-strike skip and its reset by --force / model / memory change, status give-up only at the current memory surface, the nested store with a MEMORY column and per-file JSON, the timeout reset, and old-format records)
  • bash evidence/drive.sh &lt;worktree&gt;: manual CLI run in a disposable HOME and repo with a fake acpx and pi; runs backpass analyze --harness pi --since all --analysis-agent pi --jobs 1 repeatedly, plus backpass status, backpass status --json, --force, and an acpx exit 1 partway through a streak
  • Adversarial: secret straddling the 300-char head edge (API_KEY=sk-... at offset 285); checked that the persisted evidence JSON never contains the secret
  • Read the persisted .backpass/evidence/*.json failure records (reply.class/chars/head/tail, unparsedReplies, status)
✅ **Document** - passed

✅ No issues found.

✅ No issues found.

✅ No issues found.

✅ No issues found.

✅ **Lint** - passed

✅ No issues found.

✅ No issues found.

✅ No issues found.

✅ No issues found.

✅ **Push** - passed

✅ No issues found.

✅ No issues found.

✅ No issues found.

✅ No issues found.

@greptile-apps

greptile-apps Bot commented Oct 15, 2026 •

Copy link
Copy Markdown

RetriggerView in GreptileConfidence Score: 5/5

[Medium impact] The PR appears safe to merge; no new actionable issue was found.

Reviews (5) · Last reviewed commit: "no-mistakes(document): Qualify README re..." · Reviewed by Greptile

Comment thread src/commands/status.js Resolved
@kunchenguid

Copy link
Copy Markdown
Owner

Speaking as Kun's firstmate:

Thanks @Pantoria, this is solid work. I reviewed the diff against main: CI is green on both OSes, the no-mistakes attestation is bound to the current head (8d68e6f), there's live CLI evidence (8/8), and it still merges cleanly onto today's main (f24dbab, with #213 and #210 merged). I also ran the full suite locally on that merge and it passed.

contract-class: new-default (only for one part). Keeping a redacted head/tail, chars and an empty/prose/truncated/unparsed class on the failed record, and showing it in status, is a restore: it's the "failure is loud and named" path #193 asked for. The skip after 3 unparseable replies is on for every unconfigured run, though, and it replaces what the README promises today ("…listed by backpass status, and retried on the next run"). That changes a default-on behavior, so it's Kun's call. I'm holding this for his decision, not because anything is wrong with the PR. No action needed from you right now.

VISION (per rule):

  1. Evidence is the only currency: aligns (a skipped record stays failed and never counts as evidence; the trace makes the failure inspectable).
  2. The human owns the weights: aligns (no memory write path).
  3. Nothing the model says is taken on faith: aligns (chars and the class are measured from the reply itself, and no stop reason is invented since acpx doesn't give one).
  4. The budget is the constraint, not a setting: aligns (stops paying for the same failure every run).
  5. It reads what you already have and owns nothing: aligns (redacted before cutting, stored locally only).
  6. Failure is loud and named: aligns strongly (the skip shows up in the run warning, the summary, status and --json, and --force or any input change retries it).
  7. Scope / align-resist: aligns. The skip is named rather than quiet, so it isn't a degraded path, but it's still a new default.

@Pantoria
Pantoria force-pushed the fix/193-failed-analysis-trace branch from 8d68e6f to 214227c Compare October 16, 2026 04:48
@kunchenguid

Copy link
Copy Markdown
Owner

Speaking as Kun's firstmate:

Thanks @Pantoria. I re-reviewed the new head 214227c. Since my last pass it adds status reading each nested memory file's own evidence store, through the shared layerNestedWeights helper. That part is read-only: status builds a State for each nested store but never creates a directory. CI is green on both OSes, no-mistakes is bound to this head, and the PR is still CLEAN against main.

contract-class: still new-default (only for one part), so the hold is unchanged. The redacted, classed reply trace and the per-file status reporting are restores. The skip after 3 unparseable replies is still on for every unconfigured run, and it replaces the README's "retried on the next run" promise. That's Kun's call. This is a captain-decision hold, not waiting on you, and there's nothing for you to do right now.

VISION (per rule): the same verdicts as my last stamp: aligns on all seven. The new nested-store reporting strengthens "Failure is loud and named", because nested give-ups no longer go unseen.

@kunchenguid

Copy link
Copy Markdown
Owner

Speaking as Kun's firstmate:

Thanks @Pantoria. I re-reviewed the new head 38e4621. It's rebased onto current main (3882b86), it's CLEAN, CI is green on both OSes, and no-mistakes is bound to this head. The new commits tighten the docs: the README now says the "retried on the next run" promise has an exception for repeated unparseable replies, and status only names a give-up while the memory surface it gave up on is still current.

contract-class: still new-default (for one part), so the hold hasn't changed. The redacted, classed reply trace and the per-file status reporting are restores. The skip after 3 unparseable replies is still on for every unconfigured run. Rewording the README to describe the skip documents the new default, but it doesn't make it a restore. That's Kun's call. This is a captain-decision hold, not waiting on you. There's nothing for you to do right now.

VISION (per rule): aligns on all seven, same as my last stamp. Evidence is the only currency (skipped records stay failed and never count as evidence), the human owns the weights (no memory write path), nothing the model says is taken on faith (chars and the class are measured from the reply, no invented stop reason), the budget is the constraint (the skip bounds repeat calls), it reads what you already have and owns nothing, failure is loud and named (the give-up is named in the run summary and status), and it's in scope.

Zexuan Liu and others added 5 commits October 30, 2026 00:32
…trying them forever

An analysis reply with no parseable JSON now records, in its failed evidence
record, the reply's length, its redacted first and last 300 characters, and a
class judged from the text alone: empty, truncated, unparsed, or prose. acpx
reports no stop reason in quiet mode, so none is recorded.

After 3 unparseable replies in a row for the same evidence key and analysis
call (agent, model, effort, prompt template), the transcript is skipped and
named in the run summary and `backpass status`, keeping its last reply trace.
A changed key or --force analyzes it again; other failures reset the streak.
A skipped transcript stays `failed` and never counts as evidence.

Fixes kunchenguid#193

Co-Authored-By: Claude Opus 5.5 <[email protected]>
…ed. The broken rule: `backpass status` has to report every evidence store that analysis writes. Before this fix it read only the root store, so failures, reply classes and give-ups recorded for a nested memory file never appeared. Changes: - src/nested.js: added `layerNestedWeights(rootFile, weights, skills)`. It holds the ancestor and `nestedSurfaceHash` logic that was previously inline in `primaryMemoryFile`. - src/commands/analyze.js: now calls that helper, so analyze and status build each nested file's hash from the same code. - src/commands/status.js: builds one list of stores: the root store plus one per trained nested file (it exists, isn't a pointer, and its state is read from `nestedStateDir` without creating anything). It checks each record against that file's own memory hash. The changes show up in four places: - Evidence counts and the "skipped after N" note now include nested stores. - The failed-transcripts table gets a MEMORY column, but only when nested files are configured, so root-only output is unchanged. - The note about where reply traces are kept points to each file's own store. - `--json` `evidence` has a new `files` array of `{path, ok, failed, skipped, gaveUp}`, one entry per memory file. - README.md (line 416): one clause saying status also reads nested evidence and names the file. - test/analyze-failure-trace.test.js: the fake acpx can now give a nested file its own reply, and the harness can set up a nested `apps/api/AGENTS.md`. The new test runs the real CLI: the root analysis succeeds, the nested one gives up after 3 replies, and status text and JSON show which file it was (`1 ok · 0 skipped · 1 failed (1 skipped after 3 failures)`, an `apps/api/AGENTS.md` row marked skipped, and per-file JSON counts). Editing the nested file turns the row back to `retry`. I checked that this test fails against the old status.js and passes with the fix. Verification: `pnpm run check` passes (lint, format, typecheck, and all 937 tests)
@Pantoria
Pantoria force-pushed the fix/193-failed-analysis-trace branch from 38e4621 to 3de5215 Compare October 30, 2026 04:36
@kunchenguid

Copy link
Copy Markdown
Owner

Speaking as Kun's firstmate: thanks @Pantoria for rebasing. I re-reviewed the new head 3de5215. It's raised via no-mistakes and bound to this head, CI is green on ubuntu and macOS along with the generated-files guard, and it's CLEAN against current main 342d356. Nothing in it touches .github/workflows.

contract-class: still new-default (for one part). The redacted, classed reply trace and the per-file status reporting restore the "failure is loud and named" path. MAX_UNPARSED_REPLIES = 3 still skips a transcript after three unparseable replies on every unconfigured run, though, and that replaces the README's "retried on the next run" promise. Rewording the README describes the new default, but it doesn't make it a restore.

VISION (per rule, main 342d356, head 3de5215):

  1. Evidence is the only currency: aligns (skipped records stay failed and never count as evidence).
  2. The human owns the weights: aligns (no write path changes; --force re-analyzes).
  3. Nothing the model says is taken on faith: aligns (chars and the class are measured by backpass from the reply, not taken from the model).
  4. The budget is the constraint, not a setting: aligns (not touched).
  5. It reads what you already have and owns nothing: aligns (the trace is redacted before cutting; status reads nested stores without creating anything).
  6. Failure is loud and named: aligns (the give-up is named in the warning, summary, --json and status).
  7. Scope: aligns.

This is a captain-decision hold with Kun, not waiting on you. There's nothing for you to do right now.

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.

2 participants