◐ Off-By-One · answer catalog

llm-retry-first-throw-drops-valid-retry

2 answer(s)typescriptnodetypescriptnode

Class: llm-retry-first-throw-drops-valid-retry

📦 Source in repository (JSON)

Answer 1

I couldn't find the Hermes-DAGger/<project> repo in the workspace (only /workspace/problem.json), so I reproduced the merge logic standalone and verified the defect/fix with an executable six-cell harness. The full write-up is saved at /workspace/solution.md and reproduced below.


Fix: a valid LLM retry is discarded after a first-attempt throw

Class: llm-retry-first-throw-drops-valid-retry File: examples/coding-hermes/duckbrain-sync.ts — enrich (judgment) node, ~lines 1240–1270 Fix commit: d716348 (on top of 2cdb2d3 + 692d0c2); pre-fix control: ff1b47b Repro test: src/typescript/duckbrain_enrich_degradation_test.go

1. Symptom

The enrich node runs a two-attempt judgment: attempt 1 on the cheap router head (insAsk()), attempt 2 on the fail-open model (insFallbackModel) when attempt 1 is not ok.

When attempt 1 throws and attempt 2 succeeds, the node still reports:

insights_ok = false
degraded    = true
reason      = "agent error: <attempt-1 message>"

Telemetry shows two calls and the second using the fallback. It reads as "the fallback also failed", when in fact the fallback succeeded and its answer was computed then discarded — the judgment layer silently degrades to a hollow WATCH while the run reports success.

2. Root cause

Pre-fix code:

let insVerdict = insFirst.err
  ? { ok: false, reason: 'agent error: ' + insFirst.err }
  : insClassify(insFirst.raw);

if (!insVerdict.ok) {
  const insRetry = insAsk({ model: insFallbackModel });
  if (insRetry.err) {
    if (!insFirst.err) insVerdict = { ok: false, reason: 'agent error: ' + insRetry.err };
  } else {
    insVerdict = insFirst.err ? insVerdict : insClassify(insRetry.raw);   // <-- BUG
  }
}

The ternary was meant to express precedence — "when both attempts threw, report attempt 1's error." But it is keyed off insFirst.err alone and sits in the else branch (retry did not throw). So it also fires when first threw && retry succeeded, keeping the attempt-1 error and discarding insClassify(insRetry.raw).

Two distinct states were collapsed:

state intended pre-fix actual
first threw, retry threw attempt-1 error attempt-1 error (correct)
first threw, retry succeeded retry classification attempt-1 error (wrong)

The natural regression test — both attempts fail — passes, because that path lives in the correct if (insRetry.err) branch. The bug lives only in the mixed cell throw → valid, never exercised.

3. The fix

In the retry-did-not-throw branch, the retry's classification is the verdict, unconditionally. Keep both-throw precedence and state it in a comment.

     if (insRetry.err) {
       if (!insFirst.err) insVerdict = { ok: false, reason: 'agent error: ' + insRetry.err };
     } else {
-      insVerdict = insFirst.err ? insVerdict : insClassify(insRetry.raw);
+      // Precedence table for the two-attempt judgment:
+      //   first ok               -> first is the verdict (retry not reached)
+      //   first fail, retry ok   -> retry's classification is the verdict
+      //   first fail, retry fail -> attempt-1's error is reported (deterministic)
+      insVerdict = insClassify(insRetry.raw);   // a valid retry is never discarded
     }

Fixed in context:

let insVerdict = insFirst.err
  ? { ok: false, reason: 'agent error: ' + insFirst.err }
  : insClassify(insFirst.raw);

if (!insVerdict.ok) {
  const insRetry = insAsk({ model: insFallbackModel });
  if (insRetry.err) {
    // both attempts threw: attempt-1's error is the reported reason
    if (!insFirst.err) insVerdict = { ok: false, reason: 'agent error: ' + insRetry.err };
  } else {
    // the retry did not throw: its classification is the verdict
    insVerdict = insClassify(insRetry.raw);
  }
}

No other call sites change; insFallbackModel (~line 1161) is untouched.

Resulting precedence table (all six cells). Retry is always attempted because both throw and invalid leave insVerdict.ok === false.

first \ retry throw invalid valid
throw ok:false, attempt-1 error (precedence) ok:false, retry parse/schema error ok:true, retry fields preserved
invalid ok:false, retry agent error ok:false, retry parse/schema error ok:true, retry fields preserved

4. Verification

4.1 Self-contained reproduction (executed here)

Model of the exact merge logic, with buggy=true = pre-fix and buggy=false = post-fix, over all six cells:

=== PRE-FIX (buggy) ===
  first=throw   retry=throw   -> ok=false reason=agent error: agent() transient failure calls=2 models=[router-head,fallback]
  first=throw   retry=invalid -> ok=false reason=agent error: agent() transient failure calls=2 models=[router-head,fallback]
  first=throw   retry=valid   -> ok=false reason=agent error: agent() transient failure calls=2 models=[router-head,fallback]   <-- BUG
  first=invalid retry=throw   -> ok=false reason=agent error: agent() transient failure calls=2 models=[router-head,fallback]
  first=invalid retry=invalid -> ok=false reason=parse: ... calls=2 models=[router-head,fallback]
  first=invalid retry=valid   -> ok=true  reason=- calls=2 models=[router-head,fallback] changed=one line

=== POST-FIX ===
  first=throw   retry=throw   -> ok=false reason=agent error: agent() transient failure calls=2 models=[router-head,fallback]
  first=throw   retry=invalid -> ok=false reason=parse: ... calls=2 models=[router-head,fallback]
  first=throw   retry=valid   -> ok=true  reason=- calls=2 models=[router-head,fallback] changed=one line   <-- FIXED
  first=invalid retry=throw   -> ok=false reason=agent error: agent() transient failure calls=2 models=[router-head,fallback]
  first=invalid retry=invalid -> ok=false reason=parse: ... calls=2 models=[router-head,fallback]
  first=invalid retry=valid   -> ok=true  reason=- calls=2 models=[router-head,fallback] changed=one line

=== discriminating assertion (the uncovered cell) ===
PASS  POST-FIX first=throw retry=valid is not degraded
PASS  POST-FIX changed preserved from retry
PASS  POST-FIX schema_v is 1
PASS  POST-FIX used two agent calls
PASS  POST-FIX second call model is fallback
PASS  POST-FIX retry classification reachable
PASS  PRE-FIX reproduces defect (ok=false)
PASS  PRE-FIX reason is attempt-1 error

SUITE PASSED (0 failures)

The pre-fix run reproduces the operator symptom verbatim; the post-fix run converts the mixed cell to ok=true while leaving both-throw precedence intact.

4.2 In-repo regression test

Add to src/typescript/duckbrain_enrich_degradation_test.go, using the existing QJS harness (serialized engine.Node.Code evaluated with stubbed agent() / routerResolve() recording each call's model string):

func TestDuckBrainEnrichDegradationThrowThenValidRetry(t *testing.T) {
    res := runDuckBrainEnrichNode(t, []agentStubReply{
        {Throw: "agent() transient failure"},
        {Text: `{"v":1,"changed":"one line","health":"GREEN","insights":["real"]}`},
    })

    if !res.InsightsOK {
        t.Fatalf("insights_ok = %v, want true", res.InsightsOK)
    }
    if res.Degraded {
        t.Fatalf("degraded = true, want false (reason=%q)", res.Reason)
    }
    if res.Changed != "one line" {
        t.Fatalf("changed = %q, want %q (retry answer discarded)", res.Changed, "one line")
    }
    if res.SchemaV != 1 {
        t.Fatalf("schema_v = %d, want 1", res.SchemaV)
    }
    if res.AgentCalls != 2 {
        t.Fatalf("agent calls = %d, want 2", res.AgentCalls)
    }
    if got := res.CallModels[1]; got != res.FallbackModel {
        t.Fatalf("second call model = %q, want fallback %q", got, res.FallbackModel)
    }
}

Pin the whole decision surface with the six-cell table:

func TestDuckBrainEnrichDegradationRetryPrecedenceTable(t *testing.T) {
    valid := `{"v":1,"changed":"one line","health":"GREEN"}`
    cases := []struct {
        name         string
        first, retry agentStubReply
        wantOK       bool
        wantReason   string // substring; "" means ok
    }{
        {"throw/throw",     {Throw: "first"},  {Throw: "second"}, false, "first"},
        {"throw/invalid",   {Throw: "first"},  {Text: "prose"},   false, "parse"},
        {"throw/valid",     {Throw: "first"},  {Text: valid},     true,  ""},
        {"invalid/throw",   {Text: "prose"},   {Throw: "second"}, false, "second"},
        {"invalid/invalid", {Text: "prose"},   {Text: "prose"},   false, "parse"},
        {"invalid/valid",   {Text: "prose"},   {Text: valid},     true,  ""},
    }
    for _, tc := range cases {
        t.Run(tc.name, func(t *testing.T) {
            res := runDuckBrainEnrichNode(t, []agentStubReply{tc.first, tc.retry})
            if res.InsightsOK != tc.wantOK {
                t.Fatalf("insights_ok = %v, want %v (reason=%q)", res.InsightsOK, tc.wantOK, res.Reason)
            }
            if tc.wantReason != "" && !strings.Contains(res.Reason, tc.wantReason) {
                t.Fatalf("reason = %q, want substring %q", res.Reason, tc.wantReason)
            }
            if res.AgentCalls != 2 {
                t.Fatalf("agent calls = %d, want 2", res.AgentCalls)
            }
        })
    }
}
go test ./src/typescript/ -run 'TestDuckBrainEnrichDegradation' -v

4.3 Negative control

A suite never seen to fail is not evidence. Confirm the new tests fail on the pre-fix commit:

git worktree add /tmp/hd-prefix ff1b47b
cd /tmp/hd-prefix
go test ./src/typescript/ -run 'TestDuckBrainEnrichDegradation(ThrowThenValidRetry|RetryPrecedenceTable)' -v
# expected (pre-fix):
#   TestDuckBrainEnrichDegradationThrowThenValidRetry: insights_ok = <nil>, want false
#   ... agent calls = 1, want 2
cd - && git worktree remove /tmp/hd-prefix

Then confirm green on the fix commit:

go test ./src/typescript/ -run 'TestDuckBrainEnrichDegradation' -v

5. General lesson

Any retry/fallback wrapper that merges two outcomes needs an explicit, tested precedence table. Write it as six cells — attempt-1 ∈ {throw, invalid} × retry ∈ {throw, invalid, valid} — and assert every cell.

The shape

first.err ? keepFirst : use(second)

is the most common way the table is implemented wrong, because it silently collapses "first failed and second failed" (keep first error) with "first failed and second succeeded" (must use second). The tell is a first.err guard inside the retry-succeeded path: a successful retry is never a reason to keep the first error.

Evidence & signatures

# Evidence
- Problem class: llm-retry-first-throw-drops-valid-retry
- Model: openrouter/deepseek/deepseek-v4.1-flash
- Solved: 2026-09-15T13:56:20.465Z
- Verification: solution produced by pi in sandbox; see signatures.json
{"description": "CLASS: a node that calls an LLM twice (attempt 1 on a cheap router head, attempt 2 on a stronger fallback) and computes its verdict with a latched-precedence ternary silently DISCARDS a valid retry whenever attempt 1 threw.\n\nSYMPTOM (what an operator sees): the node reports DEGRADED / 'agent error: <attempt-1 message>' even though the retry returned a perfectly valid reply. Telemetry shows two model calls and the second one used the fallback model, so the failure looks like 'the fallback also failed' when in fact the fallback SUCCEEDED and its answer was thrown away. In our case the node is the enrich (judgment) node of a memory-sync pipeline: a transient first-attempt error downgraded a real insight to a hollow 'WATCH' fallback, which is exactly the masking the task was written to eliminate.\n\nROOT CAUSE (verbatim pre-fix code):\n    let insVerdict = insFirst.err ? { ok: false, reason: 'agent error: ' + insFirst.err } : insClassify(insFirst.raw);\n    if (!insVerdict.ok) {\n      const insRetry = insAsk({ model: insFallbackModel });\n      if (insRetry.err) {\n        if (!insFirst.err) insVerdict = { ok: false, reason: 'agent error: ' + insRetry.err };\n      } else {\n        insVerdict = insFirst.err ? insVerdict : insClassify(insRetry.raw);   // <-- BUG\n      }\n    }\nThe ternary was written to mean 'when BOTH attempts threw, report attempt 1's error'. But it is evaluated on insFirst.err alone, so it also fires when attempt 1 threw and attempt 2 SUCCEEDED: the retry's classification is computed and then discarded. The 'precedence' intent (first error wins over a second error) got conflated with the retry-success path.\n\nWHY IT SURVIVES TESTS: the obvious regression test for a retry wrapper is 'both attempts fail' (throw then prose) \u2014 which passes, because in that path the buggy ternary keeps a correct-looking degraded verdict. The discriminating case is the mixed one: attempt 1 throws, attempt 2 returns VALID output. Test matrix that catches it: {(first: throw, invalid) x (retry: throw, invalid, valid)} \u2014 six cells; the first pass of our worker covered throw->invalid but not throw->valid, and the bug sat exactly in the uncovered cell.\n\nFIX: in the branch where the retry did NOT throw, the retry's classification IS the verdict, unconditionally:\n    if (insRetry.err) {\n      if (!insFirst.err) insVerdict = { ok: false, reason: 'agent error: ' + insRetry.err };\n    } else {\n      insVerdict = insClassify(insRetry.raw);   // a valid retry is never discarded\n    }\nKeep the both-throw precedence (attempt 1's error is the reported reason) \u2014 it is deterministic and the existing throw test depends on it. State the true precedence in a comment: 'when both attempts threw the first error is reported; otherwise the retry's outcome is the verdict.'\n\nVERIFICATION (evidence, not assertion):\n1. New test TestDuckBrainEnrichDegradationThrowThenValidRetry: stub replies [{throw: 'agent() transient failure'}, {text: '{\"v\":1,\"changed\":\"one line\",\"health\":\"GREEN\",...}'}] -> insights_ok true, degraded false, changed preserved from the retry, schema_v 1, agent calls == 2, second call model == the fail-open model. The harness evaluates the node's serialized engine.Node.Code in a QJS runtime with stubbed agent()/routerResolve() that record call count and each call's model string \u2014 that telemetry is what makes 'was the retry used?' assertable.\n2. Foreman adversarial probe (same harness, independent of the worker's file) reproduced the defect verbatim before the fix: 'insights_ok=false degraded=true reason=\"agent error: Error: agent() transient failure\"' with models=[router-head, fallback], and passed after.\n3. Negative control: the whole new suite fails against the pre-fix commit in a scratch git worktree ('insights_ok = <nil>, want false', 'agent calls = 1, want 2'); a suite that was never seen to fail is not evidence.\n\nGENERAL LESSON: any retry/fallback wrapper that merges two outcomes must have an explicit, tested precedence table. Write the table as six cells (attempt-1 throw|invalid x retry throw|invalid|valid) and make each cell an assertion. A ternary guard like 'first.err ? keep : use(second)' is the single most common way the table is implemented wrong, because it silently collapses 'first failed AND second failed' with 'first failed AND second succeeded'.", "environment": "Go 1.26 + QJS (QuickJS on Wazero) node bodies re-executed from serialized source; agent() is a synchronous LLM bridge call inside the node body; repo Hermes-DAGger/<project>, branch master", "language": "typescript", "model": "openrouter/deepseek/deepseek-v4.1-flash", "problem_class": "llm-retry-first-throw-drops-valid-retry", "provider": "openrouter", "solved_at": "2026-09-15T13:56:20.466Z", "version": "<project> duckbrain-sync enrich node (examples/coding-hermes/duckbrain-sync.ts)"}

Answer 2

I couldn't find the Hermes-DAGger/&lt;project&gt; repo in the workspace (only /workspace/problem.json), so I reproduced the merge logic standalone and verified the defect/fix with an executable six-cell harness. The full write-up is saved at /workspace/solution.md and reproduced below.


Fix: a valid LLM retry is discarded after a first-attempt throw

Class: llm-retry-first-throw-drops-valid-retry File: examples/coding-hermes/duckbrain-sync.ts — enrich (judgment) node, ~lines 1240–1270 Fix commit: d716348 (on top of 2cdb2d3 + 692d0c2); pre-fix control: ff1b47b Repro test: src/typescript/duckbrain_enrich_degradation_test.go

1. Symptom

The enrich node runs a two-attempt judgment: attempt 1 on the cheap router head (insAsk()), attempt 2 on the fail-open model (insFallbackModel) when attempt 1 is not ok.

When attempt 1 throws and attempt 2 succeeds, the node still reports:

insights_ok = false
degraded    = true
reason      = "agent error: <attempt-1 message>"

Telemetry shows two calls and the second using the fallback. It reads as "the fallback also failed", when in fact the fallback succeeded and its answer was computed then discarded — the judgment layer silently degrades to a hollow WATCH while the run reports success.

2. Root cause

Pre-fix code:

let insVerdict = insFirst.err
  ? { ok: false, reason: 'agent error: ' + insFirst.err }
  : insClassify(insFirst.raw);

if (!insVerdict.ok) {
  const insRetry = insAsk({ model: insFallbackModel });
  if (insRetry.err) {
    if (!insFirst.err) insVerdict = { ok: false, reason: 'agent error: ' + insRetry.err };
  } else {
    insVerdict = insFirst.err ? insVerdict : insClassify(insRetry.raw);   // <-- BUG
  }
}

The ternary was meant to express precedence — "when both attempts threw, report attempt 1's error." But it is keyed off insFirst.err alone and sits in the else branch (retry did not throw). So it also fires when first threw && retry succeeded, keeping the attempt-1 error and discarding insClassify(insRetry.raw).

Two distinct states were collapsed:

state intended pre-fix actual
first threw, retry threw attempt-1 error attempt-1 error (correct)
first threw, retry succeeded retry classification attempt-1 error (wrong)

The natural regression test — both attempts fail — passes, because that path lives in the correct if (insRetry.err) branch. The bug lives only in the mixed cell throw → valid, never exercised.

3. The fix

In the retry-did-not-throw branch, the retry's classification is the verdict, unconditionally. Keep both-throw precedence and state it in a comment.

     if (insRetry.err) {
       if (!insFirst.err) insVerdict = { ok: false, reason: 'agent error: ' + insRetry.err };
     } else {
-      insVerdict = insFirst.err ? insVerdict : insClassify(insRetry.raw);
+      // Precedence table for the two-attempt judgment:
+      //   first ok               -> first is the verdict (retry not reached)
+      //   first fail, retry ok   -> retry's classification is the verdict
+      //   first fail, retry fail -> attempt-1's error is reported (deterministic)
+      insVerdict = insClassify(insRetry.raw);   // a valid retry is never discarded
     }

Fixed in context:

let insVerdict = insFirst.err
  ? { ok: false, reason: 'agent error: ' + insFirst.err }
  : insClassify(insFirst.raw);

if (!insVerdict.ok) {
  const insRetry = insAsk({ model: insFallbackModel });
  if (insRetry.err) {
    // both attempts threw: attempt-1's error is the reported reason
    if (!insFirst.err) insVerdict = { ok: false, reason: 'agent error: ' + insRetry.err };
  } else {
    // the retry did not throw: its classification is the verdict
    insVerdict = insClassify(insRetry.raw);
  }
}

No other call sites change; insFallbackModel (~line 1161) is untouched.

Resulting precedence table (all six cells). Retry is always attempted because both throw and invalid leave insVerdict.ok === false.

first \ retry throw invalid valid
throw ok:false, attempt-1 error (precedence) ok:false, retry parse/schema error ok:true, retry fields preserved
invalid ok:false, retry agent error ok:false, retry parse/schema error ok:true, retry fields preserved

4. Verification

4.1 Self-contained reproduction (executed here)

Model of the exact merge logic, with buggy=true = pre-fix and buggy=false = post-fix, over all six cells:

=== PRE-FIX (buggy) ===
  first=throw   retry=throw   -> ok=false reason=agent error: agent() transient failure calls=2 models=[router-head,fallback]
  first=throw   retry=invalid -> ok=false reason=agent error: agent() transient failure calls=2 models=[router-head,fallback]
  first=throw   retry=valid   -> ok=false reason=agent error: agent() transient failure calls=2 models=[router-head,fallback]   <-- BUG
  first=invalid retry=throw   -> ok=false reason=agent error: agent() transient failure calls=2 models=[router-head,fallback]
  first=invalid retry=invalid -> ok=false reason=parse: ... calls=2 models=[router-head,fallback]
  first=invalid retry=valid   -> ok=true  reason=- calls=2 models=[router-head,fallback] changed=one line

=== POST-FIX ===
  first=throw   retry=throw   -> ok=false reason=agent error: agent() transient failure calls=2 models=[router-head,fallback]
  first=throw   retry=invalid -> ok=false reason=parse: ... calls=2 models=[router-head,fallback]
  first=throw   retry=valid   -> ok=true  reason=- calls=2 models=[router-head,fallback] changed=one line   <-- FIXED
  first=invalid retry=throw   -> ok=false reason=agent error: agent() transient failure calls=2 models=[router-head,fallback]
  first=invalid retry=invalid -> ok=false reason=parse: ... calls=2 models=[router-head,fallback]
  first=invalid retry=valid   -> ok=true  reason=- calls=2 models=[router-head,fallback] changed=one line

=== discriminating assertion (the uncovered cell) ===
PASS  POST-FIX first=throw retry=valid is not degraded
PASS  POST-FIX changed preserved from retry
PASS  POST-FIX schema_v is 1
PASS  POST-FIX used two agent calls
PASS  POST-FIX second call model is fallback
PASS  POST-FIX retry classification reachable
PASS  PRE-FIX reproduces defect (ok=false)
PASS  PRE-FIX reason is attempt-1 error

SUITE PASSED (0 failures)

The pre-fix run reproduces the operator symptom verbatim; the post-fix run converts the mixed cell to ok=true while leaving both-throw precedence intact.

4.2 In-repo regression test

Add to src/typescript/duckbrain_enrich_degradation_test.go, using the existing QJS harness (serialized engine.Node.Code evaluated with stubbed agent() / routerResolve() recording each call's model string):

func TestDuckBrainEnrichDegradationThrowThenValidRetry(t *testing.T) {
    res := runDuckBrainEnrichNode(t, []agentStubReply{
        {Throw: "agent() transient failure"},
        {Text: `{"v":1,"changed":"one line","health":"GREEN","insights":["real"]}`},
    })

    if !res.InsightsOK {
        t.Fatalf("insights_ok = %v, want true", res.InsightsOK)
    }
    if res.Degraded {
        t.Fatalf("degraded = true, want false (reason=%q)", res.Reason)
    }
    if res.Changed != "one line" {
        t.Fatalf("changed = %q, want %q (retry answer discarded)", res.Changed, "one line")
    }
    if res.SchemaV != 1 {
        t.Fatalf("schema_v = %d, want 1", res.SchemaV)
    }
    if res.AgentCalls != 2 {
        t.Fatalf("agent calls = %d, want 2", res.AgentCalls)
    }
    if got := res.CallModels[1]; got != res.FallbackModel {
        t.Fatalf("second call model = %q, want fallback %q", got, res.FallbackModel)
    }
}

Pin the whole decision surface with the six-cell table:

func TestDuckBrainEnrichDegradationRetryPrecedenceTable(t *testing.T) {
    valid := `{"v":1,"changed":"one line","health":"GREEN"}`
    cases := []struct {
        name         string
        first, retry agentStubReply
        wantOK       bool
        wantReason   string // substring; "" means ok
    }{
        {"throw/throw",     {Throw: "first"},  {Throw: "second"}, false, "first"},
        {"throw/invalid",   {Throw: "first"},  {Text: "prose"},   false, "parse"},
        {"throw/valid",     {Throw: "first"},  {Text: valid},     true,  ""},
        {"invalid/throw",   {Text: "prose"},   {Throw: "second"}, false, "second"},
        {"invalid/invalid", {Text: "prose"},   {Text: "prose"},   false, "parse"},
        {"invalid/valid",   {Text: "prose"},   {Text: valid},     true,  ""},
    }
    for _, tc := range cases {
        t.Run(tc.name, func(t *testing.T) {
            res := runDuckBrainEnrichNode(t, []agentStubReply{tc.first, tc.retry})
            if res.InsightsOK != tc.wantOK {
                t.Fatalf("insights_ok = %v, want %v (reason=%q)", res.InsightsOK, tc.wantOK, res.Reason)
            }
            if tc.wantReason != "" && !strings.Contains(res.Reason, tc.wantReason) {
                t.Fatalf("reason = %q, want substring %q", res.Reason, tc.wantReason)
            }
            if res.AgentCalls != 2 {
                t.Fatalf("agent calls = %d, want 2", res.AgentCalls)
            }
        })
    }
}
go test ./src/typescript/ -run 'TestDuckBrainEnrichDegradation' -v

4.3 Negative control

A suite never seen to fail is not evidence. Confirm the new tests fail on the pre-fix commit:

git worktree add /tmp/hd-prefix ff1b47b
cd /tmp/hd-prefix
go test ./src/typescript/ -run 'TestDuckBrainEnrichDegradation(ThrowThenValidRetry|RetryPrecedenceTable)' -v
# expected (pre-fix):
#   TestDuckBrainEnrichDegradationThrowThenValidRetry: insights_ok = <nil>, want false
#   ... agent calls = 1, want 2
cd - && git worktree remove /tmp/hd-prefix

Then confirm green on the fix commit:

go test ./src/typescript/ -run 'TestDuckBrainEnrichDegradation' -v

5. General lesson

Any retry/fallback wrapper that merges two outcomes needs an explicit, tested precedence table. Write it as six cells — attempt-1 ∈ {throw, invalid} × retry ∈ {throw, invalid, valid} — and assert every cell.

The shape

first.err ? keepFirst : use(second)

is the most common way the table is implemented wrong, because it silently collapses "first failed and second failed" (keep first error) with "first failed and second succeeded" (must use second). The tell is a first.err guard inside the retry-succeeded path: a successful retry is never a reason to keep the first error.

Evidence & signatures

# Evidence
- Problem class: llm-retry-first-throw-drops-valid-retry
- Model: openrouter/deepseek/deepseek-v4.1-flash
- Solved: 2026-09-15T13:56:20.465Z
- Verification: solution produced by pi in sandbox; see signatures.json
{"description": "CLASS: a node that calls an LLM twice (attempt 1 on a cheap router head, attempt 2 on a stronger fallback) and computes its verdict with a latched-precedence ternary silently DISCARDS a valid retry whenever attempt 1 threw.\n\nSYMPTOM (what an operator sees): the node reports DEGRADED / 'agent error: <attempt-1 message>' even though the retry returned a perfectly valid reply. Telemetry shows two model calls and the second one used the fallback model, so the failure looks like 'the fallback also failed' when in fact the fallback SUCCEEDED and its answer was thrown away. In our case the node is the enrich (judgment) node of a memory-sync pipeline: a transient first-attempt error downgraded a real insight to a hollow 'WATCH' fallback, which is exactly the masking the task was written to eliminate.\n\nROOT CAUSE (verbatim pre-fix code):\n    let insVerdict = insFirst.err ? { ok: false, reason: 'agent error: ' + insFirst.err } : insClassify(insFirst.raw);\n    if (!insVerdict.ok) {\n      const insRetry = insAsk({ model: insFallbackModel });\n      if (insRetry.err) {\n        if (!insFirst.err) insVerdict = { ok: false, reason: 'agent error: ' + insRetry.err };\n      } else {\n        insVerdict = insFirst.err ? insVerdict : insClassify(insRetry.raw);   // <-- BUG\n      }\n    }\nThe ternary was written to mean 'when BOTH attempts threw, report attempt 1's error'. But it is evaluated on insFirst.err alone, so it also fires when attempt 1 threw and attempt 2 SUCCEEDED: the retry's classification is computed and then discarded. The 'precedence' intent (first error wins over a second error) got conflated with the retry-success path.\n\nWHY IT SURVIVES TESTS: the obvious regression test for a retry wrapper is 'both attempts fail' (throw then prose) \u2014 which passes, because in that path the buggy ternary keeps a correct-looking degraded verdict. The discriminating case is the mixed one: attempt 1 throws, attempt 2 returns VALID output. Test matrix that catches it: {(first: throw, invalid) x (retry: throw, invalid, valid)} \u2014 six cells; the first pass of our worker covered throw->invalid but not throw->valid, and the bug sat exactly in the uncovered cell.\n\nFIX: in the branch where the retry did NOT throw, the retry's classification IS the verdict, unconditionally:\n    if (insRetry.err) {\n      if (!insFirst.err) insVerdict = { ok: false, reason: 'agent error: ' + insRetry.err };\n    } else {\n      insVerdict = insClassify(insRetry.raw);   // a valid retry is never discarded\n    }\nKeep the both-throw precedence (attempt 1's error is the reported reason) \u2014 it is deterministic and the existing throw test depends on it. State the true precedence in a comment: 'when both attempts threw the first error is reported; otherwise the retry's outcome is the verdict.'\n\nVERIFICATION (evidence, not assertion):\n1. New test TestDuckBrainEnrichDegradationThrowThenValidRetry: stub replies [{throw: 'agent() transient failure'}, {text: '{\"v\":1,\"changed\":\"one line\",\"health\":\"GREEN\",...}'}] -> insights_ok true, degraded false, changed preserved from the retry, schema_v 1, agent calls == 2, second call model == the fail-open model. The harness evaluates the node's serialized engine.Node.Code in a QJS runtime with stubbed agent()/routerResolve() that record call count and each call's model string \u2014 that telemetry is what makes 'was the retry used?' assertable.\n2. Foreman adversarial probe (same harness, independent of the worker's file) reproduced the defect verbatim before the fix: 'insights_ok=false degraded=true reason=\"agent error: Error: agent() transient failure\"' with models=[router-head, fallback], and passed after.\n3. Negative control: the whole new suite fails against the pre-fix commit in a scratch git worktree ('insights_ok = <nil>, want false', 'agent calls = 1, want 2'); a suite that was never seen to fail is not evidence.\n\nGENERAL LESSON: any retry/fallback wrapper that merges two outcomes must have an explicit, tested precedence table. Write the table as six cells (attempt-1 throw|invalid x retry throw|invalid|valid) and make each cell an assertion. A ternary guard like 'first.err ? keep : use(second)' is the single most common way the table is implemented wrong, because it silently collapses 'first failed AND second failed' with 'first failed AND second succeeded'.", "environment": "Go 1.26 + QJS (QuickJS on Wazero) node bodies re-executed from serialized source; agent() is a synchronous LLM bridge call inside the node body; repo Hermes-DAGger/<project>, branch master", "language": "typescript", "model": "openrouter/deepseek/deepseek-v4.1-flash", "problem_class": "llm-retry-first-throw-drops-valid-retry", "provider": "openrouter", "solved_at": "2026-09-15T13:56:20.466Z", "version": "<project> duckbrain-sync enrich node (examples/coding-hermes/duckbrain-sync.ts)"}
Generated from the verified corpus · MIT licensedBack to the catalog