feat(campaign): retry a transient cell failure in the same slot - #670
Merged
Conversation
A campaign cell that dies on a transport hiccup makes coverage incomplete, and runImprovementLoop then refuses the holdout comparison. The only remedies were a full re-run of every arm or a caller-side measurement outside the loop. runCampaign accepts an opt-in cellRetry policy: attempts plus a retryable predicate over the failure receipt. A failed attempt the predicate accepts is dispatched again in the same slot, with the same cellId and seed, until it succeeds or the attempts are used. Every attempt charges the shared cost ledger. A retried attempt keeps its evidence at failure-receipt.attempt-<n>.json; a final failure keeps failure-receipt.json. The final cell records retryAttempts. abortOnCellError fires only when the last attempt fails, and a cancelled campaign is never retried. transientDispatchFailure() is the ready-made predicate: a dispatch-stage failure that isTransientTransportFailure classifies as an infrastructure hiccup. A judge-stage failure is never transport, so it is never retried. selfImprove forwards the policy to the baseline, candidate, and held-out campaigns. The default is unchanged: no retry unless the caller opts in.
drewstone
force-pushed
the
feat/campaign-cell-retry
branch
from
August 21, 2026 00:54
684c576 to
22fd9fe
Compare
tangletools
approved these changes
Aug 21, 2026
tangletools
left a comment
Contributor
There was a problem hiding this comment.
✅ Auto-approved drewstone PR — 22fd9fe4
This PR was opened by the trusted drewstone account.
This approval is provisional and was applied by the local stand-in because the pr-reviewer webhook host is unreachable (2026-08-21). CI on this head is fully green. The full PR reviewer audit re-runs via the resweep when the service returns and will publish findings if it detects issues.
This was referenced Aug 21, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Why
Closes tangle-network/agent-runtime#723 — the issue is filed on Runtime, but
runImprovementLoopis Eval code (Runtime only re-exports it).Observed in the agent-graphs gen2 run: one candidate cell died on a router HTTP 503. That single transport failure made candidate coverage incomplete, so
runImprovementLooprefused the holdout comparison (assertCompleteHoldout: "holdout is incomplete … Refusing to compare unequal holdout results") and the loop held the baseline without ever scoring the candidate. The only remedies were a full re-run of every arm or a caller-side measurement outside the loop.Failing closed on incomplete evidence is correct. But a 503 is distinguishable from a judge error, and the per-cell failure receipt already carries the stage and the error text, so the campaign already holds everything needed to tell them apart.
What
RunCampaignOptions.cellRetry?: { attempts, retryable }— opt-in bounded in-run retry, absent by default.retryablepredicate accepts is dispatched again in the same slot: samecellId, sameseed, same cost tags, same manifest. The schedule and the baseline/candidate pairing are unchanged.costUsd,tokenUsage, andcostCallIdscover all attempts.<cell>/failure-receipt.attempt-<n>.json; a final failed attempt keeps the usual<cell>/failure-receipt.json. SameCampaignCellFailureReceiptshape, only the file name differs.retryAttempts.abortOnCellErrorfires only when a cell's final attempt fails.CostAccountingIncompleteErroris never retried.transientDispatchFailure(opts?)is the shipped predicate:failure.stage === 'dispatch' && isTransientTransportFailure(failure.error.message, opts). A judge-stage failure is never transport, so it is never retried — the dispatch already produced an artifact, and re-dispatching would score a different sample.selfImprove({ cellRetry })forwards the policy to the baseline, candidate, and held-out campaigns;runImprovementLoopinherits it through the existing options spread (no new plumbing in that file).Fail-closed default is unchanged: with no
cellRetry, a failed cell is final on its first attempt and coverage stays incomplete.Absence proof (Rule 1)
git grep -w cellRetry origin/main -- src tests→ 0 hits. No retry option existed.git grep -w isTransientTransportFailure origin/main→ 11 hits, all of them the definition (src/campaign/transient-failure.ts:41), the barrel re-export (src/campaign/index.ts:430), and its own unit test. Zero production consumers.src/campaign/run-campaign.ts:282-300on main calledexecuteCellexactly once per slot;reuseFailedCellsonly re-dispatches on a resumable re-run, not in-run.Simplification (Rule 2)
Proof
Local, node v24.11.1,
pnpm10.34.5, onfeat/campaign-cell-retryrebased ontoorigin/main@7541efe8(after #669):pnpm typecheckpnpm lint(biome, 719 files)pnpm build(tsdown + openapi)pnpm verify:package(analyst digests, skill, model-ids, publint, attw, packed exports, evidence index)run-campaign.test.ts+transient-failure.test.ts+presets.test.ts+contract-self-improve.test.tsNew tests (12), each named for the failure it prevents:
tests/campaign/run-campaign.test.ts— newrunCampaign — cellRetryblock, 6 cases:retryAttempts: 1, cost summed (0.02 = 2 × 0.01) with 2costCallIds,campaignCoverage(...).complete === true,cellsFailed: 0, the failed attempt's receipt retained atfailure-receipt.attempt-1.jsoncarrying that attempt's own error, and a rerun reuses the retried cell from cache without re-dispatching (a retried cell that failed to cache would re-bill on resume).retryAttempts: 2, every attempt's receipt on disk, final receipt cost 0.03, coverage incomplete,cellsFailed: 1. Fail-closed is preserved.abortOnCellErrordoes not fire while a retryable failure has attempts left (the cell recovers) — a transient blip must not tear down the campaign.abortOnCellErrorstill rejects with the final attempt's error once retry is exhausted — retry must not swallow a real abort.src/campaign/transient-failure.test.ts— newtransientDispatchFailureblock, 3 cases: a dispatch-stage transport failure is retried; a judge-stage failure never is, even with a 503-shaped message; a non-transport dispatch failure is scored, not retried (retrying real failures silently drops the hard cells and inflates every arm).tests/campaign/presets.test.ts— 2 cases through the realrunImprovementLoop, reproducing the incident: a transient holdout 503 is recovered and the gate ships (exactly 1 cell carriesretryAttempts: 1), and with attempts exhausted the loop still throwsbaseline holdout is incomplete (2/3 designed cells scorable).tests/contract-self-improve.test.ts— 1 case: the same flaky agent rejects with/holdout is incomplete/withoutcellRetryand completes with it, provingselfImproveforwards the policy.Full-suite disclosure
pnpm teston this branch: 5298 passed / 67 failed / 3 skipped across 378 files; 13 files carry the failures. None of them is caused by this change, measured rather than asserted:origin/mainworktree (no part of this change present): 11 of 13 failed there too, 53 failed / 220.tests/campaign/official-optimizer-abort.test.tsfails 2/2 on cleanorigin/mainwithError: optimizer did not start(the test's detached optimizer subprocess never comes up in this sandbox), andsrc/sandbox-harness.test.tspasses on this branch when it is not competing for CPU — it was a load flake from running 378 files at once.The failures are environment-dependent: git worktree creation (
worktree.test.ts, 29/29 on both), sandbox harness, detached optimizer subprocesses, and network-backed benchmark fixtures. None of the 13 files importscellRetry,transientDispatchFailure, orexecuteCell. The 4 files this change touches are 133/133 green. CI is the arbiter.Docs
docs/eval-surface-map.mdgains a "Failed cells: receipts and bounded retry" section under the run* primitive table — the doc that already owns campaign-cell semantics.CHANGELOG.mdrecords 0.152.0.Version
Minor bump 0.151.0 → 0.152.0 (#669 took 0.151.0; re-bumped from the new main after rebasing) across the five lockstep files (
package.json,clients/python/pyproject.toml,clients/python/src/agent_eval_rpc/__init__.py,clients/python/uv.lock) plus the recomputed dependency-lock digest insrc/analyst/benchmark-implementation.ts. The wire contract is untouched, so the Python RPC client needs no change beyond the version lockstep.Closes tangle-network/agent-runtime#723