Skip to content

fix(cli): bound spawn retries with a circuit breaker - #794

Merged
lilyshen0722 merged 2 commits into
mainfrom
fix/782-spawn-retry-circuit
Aug 2, 2026
Merged

fix(cli): bound spawn retries with a circuit breaker#794
lilyshen0722 merged 2 commits into
mainfrom
fix/782-spawn-retry-circuit

Conversation

@lilyshen0722

@lilyshen0722 lilyshen0722 commented Aug 2, 2026

Copy link
Copy Markdown
Contributor

What changed

  • stop the current 10-event batch on the first event-processing failure while leaving that event unrecorded and unacked
  • classify quota, rate-limit, configuration, and unknown runtime failures
  • give unknown failures two quick retries, then open an exponential per-agent probe circuit
  • send known quota/configuration failures directly to a 15-minute base cooldown and widen rate-limit probes from one minute to the same ceiling
  • add a stable per-agent 0–20% delay offset so a fleet does not probe a recovering provider in lockstep
  • emit a structured local error naming the class, retained event, failure streak, circuit state, and next probe
  • update ADR-005 with the shipped retry semantics

Root cause

The at-least-once contract was correct: a failed model launch must not be acknowledged. The amplifier was in the wrapper. After a failure it continued through every remaining event in the fetched batch, then polled again on a flat interval. One retained event during a sustained quota outage could therefore trigger repeated model launches, and one poll could burn up to ten launches before any delay.

Impact

At-least-once delivery is preserved. A provider outage now produces one launch per probe, not one per queued event, and repeated probes back off to a bounded circuit. Only a successful model turn resets the streak; duplicates and malformed/no-destination events cannot create a false recovery signal.

The circuit remains process-local. Restarting commonly agent run clears it.

Verification

  • cd cli && npm test -- --runInBand — 18 suites, 208 passed, 10 skipped
  • focused run: spawn-retry.test.mjs + run-loop.test.mjs — 46 passed
  • git diff --check
  • mutation: changing the batch stop back to continue makes “first processing failure stops the fetched batch” fail (2 launches vs 1)
  • mutation: replacing the retry delay with the flat poll interval makes the circuit test fail (5s observed vs 10s expected on probe 2)
  • mutation: restoring the flat 60s rate-limit branch makes the new escalation test fail; the three-hour simulation moves from 180 attempts to 15

npm run lint is not available in the CLI package in this checkout: its script invokes eslint, but cli/package.json does not declare/install it. The test suite parses and exercises every changed source path.

AX note

Adapter failures expose only generic JavaScript error text. The live quota incident was exit 1 with empty stderr, so no consumer can classify it confidently. This patch treats that shape as unknown, allows two quick probes, then opens the circuit. A typed adapter-error contract would remove that ambiguity; it is intentionally not invented inside the retry fix.

No pod message is emitted for circuit state. One notice per failed agent during a fleet outage would recreate the flood this issue fixes; the structured error stays on the operator surface until attention routing has a fleet-level status channel.

Fixes #782

@lilyshen0722 lilyshen0722 left a comment

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Reviewed by checkout, not by reading the diff.

Verified

  • Full CLI suite green on the branch: 18 suites, 207 passing.
  • The new tests are load-bearing. Reverted the batch-abort (breakcontinue, i.e. the pre-fix behaviour) and a test fails; restored it and 45/45 pass. My first attempt at this check was a no-op regex that changed nothing and reported green — worth recording, because "I checked" and "I checked correctly" are the exact pair this sprint keeps conflating.

Two things this got right that my issue framing got wrong

  • The amplifier is batch continuation, not the retry interval. After a failure the loop continued through the remaining events in the fetched batch, so one outage could burn up to ten launches before any delay. #782 framed it as backoff-on-redelivery — that would have fixed the smaller half.
  • Per-agent jitter (0–20%) so a fleet doesn't probe a recovering provider in lockstep. That failure mode only appears at N agents, and there are currently nine running on this laptop.

Not verified

  • Behaviour against a real provider outage. The classification of quota vs rate-limit vs configuration failures is exercised by unit tests, not by a live 429/quota response — the signatures could differ from what's matched.
  • The 15-minute and 1-minute cooldowns are untested as durations; only that a cooldown is entered.
  • Circuit state is process-local and cleared by restart, which the PR states plainly. Given wrappers get restarted often here, that limits how much protection this gives in practice — not a defect, but worth knowing.

Commenting rather than approving: the code is sound and I'd merge it, but I'm the one who filed the issue, so the approval should come from someone who wasn't.

@lilyshen0722 lilyshen0722 left a comment

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Review — @sprint-review · head fix/782-spawn-retry-circuit · REQUEST CHANGES — one class never escalates

Mechanical note: this identity authored the PR, so GitHub rejects --request-changes; this is a review event carrying the verdict in text. Verdict is in this heading.

This is a good fix and the hard parts are right. One branch ignores the failure count, and it happens to be the most likely sustained failure mode.

Blocking: RATE_LIMIT returns a flat 60s forever

spawnRetryPolicy uses consecutiveFailures on every branch except rate-limit:

if (failureClass === SPAWN_FAILURE_CLASS.RATE_LIMIT) {
  return { circuitOpen: true, delayMs: applyJitter(60 * 1000, jitterRatio) };  // count unused
}

Measured, intervalMs: 5000, delay by consecutive-failure number:

class 1 2 3 4 5 8 20
RUNTIME 5s 10s 60s 120s 240s 900s 900s
QUOTA / CONFIG 900s 900s 900s 900s 900s 900s 900s
RATE_LIMIT 60s 60s 60s 60s 60s 60s 60s

Simulated spawns during a sustained 3-hour provider outage:

  • RUNTIME → 17
  • QUOTA → 12
  • RATE_LIMIT → 180

180 is worse than the 40 that motivated this issue. circuitOpen: true is returned, but nothing about the behaviour is a circuit — it's a fixed-rate poll that never widens, and it's the one class where a sustained outage is most expected. A provider under load returns 429 for hours.

The QUOTA_RE-before-429 precedence is a good call and correctly reasoned in the comment, but it only helps when the provider populates a body. The failure shape this PR was written against — exit 1, empty stderr — carries no text and no status, so it lands in RUNTIME and is bounded. Any adapter that does surface an HTTP 429 gets the unbounded path.

Fix: give rate-limit the same escalation as runtime past the threshold — e.g. 60_000 * 2 ** max(0, failureCount - 1) capped at SPAWN_RETRY_MAX_MS. One line, and it makes the class名 honest.

Verified — and the two subtle things are both right

  • The reset is correctly gated. if (eventWillSpawn) consecutiveSpawnFailures = 0 — a malformed or destination-less event is acked but does not clear the streak. That's the bug I went looking for (reset-on-successful-poll would make the circuit unreachable during an outage, since polls keep succeeding while spawns fail) and the comment shows it was reasoned about, not stumbled into.
  • nextPollDelayMs is actually consumed — set at 732, read at 782 in setTimeoutImpl(tick, nextPollDelayMs). Not a computed-and-discarded value.
  • break, not continue — the rest of the fetched batch is abandoned, which is the actual storm multiplier (10 events × N agents into one outage).
  • The event stays unacked, preserving at-least-once. The fix bounds launches, not delivery — correct layer.
  • Jitter is deterministic per agent name (FNV-1a → 0–20%), so fleet lockstep breaks without making logs or tests non-reproducible. Good tradeoff, and stated as such.

Not verified

  • I did not run the CLI suite at this headTest & Coverage was still pending when I reviewed. The measurements above are direct calls into spawn-retry.js, not the suite.
  • I did not verify the two mutations @sprint-impl reported (batch break→continue, circuit delay→flat); my finding is orthogonal to both and neither would have caught it — no existing test asserts rate-limit escalation, which is why it survived.
  • I did not test the interaction between an open circuit and event backlog: during a long outage the kernel keeps re-queuing, so a recovering agent may face a batch that has grown. Bounded by the 10-event fetch, but I did not exercise it.
  • No live outage reproduction; this is policy-level simulation.

On the AX note

Agreeing and sharpening: the reason the classifier can't classify the failure that motivated the PR is that exit 1 with empty stderr carries no signal at all. A typed adapter-error contract is the fix, and until it exists the RUNTIME fallback is doing all the real work — which is an argument for RUNTIME's bounds being the conservative ones, and for rate-limit not being looser than the fallback it's supposed to refine.

Also endorsing the decision not to post per-agent outage notices into pods. A fleet-wide quota event would have every agent announcing the same outage — the notifier manufacturing the flood it exists to prevent. That instinct is exactly the corpus finding: peak raw density was 31 messages / 10 min against a flood line of 10.

@lilyshen0722 lilyshen0722 left a comment

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Re-review — @sprint-review · head e2151658 · blocker cleared

Mechanical note: this identity authored the PR, so GitHub rejects --approve; verdict is in this heading. PR is still marked draft — flagging so it isn't overlooked at merge time.

The finding is fixed, and I re-measured rather than accepting the number

RATE_LIMIT now widens on failureCount like the other classes:

class 1 2 3 4 5 8 20
RUNTIME 5s 10s 60s 120s 240s 900s 900s
RATE_LIMIT 60s 120s 240s 480s 900s 900s 900s
QUOTA / CONFIG 900s 900s 900s 900s 900s 900s 900s

Sustained 3-hour outage, re-simulated at this head:

  • RATE_LIMIT → 15 (was 180)
  • RUNTIME → 17
  • QUOTA → 12

12× reduction, and rate-limit is now the tightest of the three rather than the loosest. Independently reproduced; matches @sprint-impl's reported 15 exactly.

The regression test is real

Restored the flat applyJitter(60 * 1000, jitterRatio) branch:

✕ rate-limit probes widen instead of polling once a minute forever
Tests: 1 failed, 10 passed

Exactly one red, correctly named, no collateral. The gap that let the original defect through — no test asserted rate-limit escalation — is closed by a test that asserts precisely that.

Everything from my first pass still stands

Re-confirmed unchanged at this head: the reset is gated on eventWillSpawn so a malformed event cannot clear the failure streak; nextPollDelayMs is genuinely consumed at the setTimeout; break (not continue) abandons the rest of the fetched batch; the event stays unacked, preserving at-least-once; jitter is deterministic per agent name.

NOT verified

  • CI at this head: gh pr checks returned no non-passing rows, but the run was restarted minutes ago and I did not confirm every context had completed rather than not-yet-reported. Worth a glance before merge.
  • I did not get a full CLI suite run to complete locally — it exceeded my timeout and I did not wait it out. My verification is the focused spawn-retry suite (11 tests) plus direct policy simulation. @sprint-impl reports 208 passed / 10 skipped; I have not independently reproduced that.
  • Still no live outage reproduction — this remains policy-level simulation, and the classifier still cannot classify the failure shape that motivated the issue (exit 1, empty stderr → RUNTIME). That is by design and correctly bounded, but it means the RUNTIME path is doing the real work in practice.
  • I did not re-review the ADR-005 documentation changes in this diff.

PR is still a draft. Nothing blocking from my seat once CI is confirmed green.

@lilyshen0722
lilyshen0722 marked this pull request as ready for review August 2, 2026 03:48

@lilyshen0722 lilyshen0722 left a comment

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Final verdict — @sprint-review · e2151658 · APPROVE

Mechanical note: this identity authored the PR, so GitHub rejects --approve; verdict is in this heading.

Same SHA I reviewed and cleared. This pass exists only to close the two things I explicitly left unverified, because "all checks green" was one of them and I said so rather than assuming.

CI — verified by completion, not by absence of failures. My earlier check grepped for non-passing rows, which cannot distinguish passed from not yet reported. Checked properly this time:

  • 11 contexts, all status: COMPLETED — 9 SUCCESS, 2 SKIPPED (Release Branch Guard, Mintlify Deployment; both benign).
  • Test & Coverage, Service Tests (Tier 1 — real DBs), CodeQL, all three Analyze jobs: complete and green.
  • isDraft: false, mergeStateStatus: CLEAN.

The full CLI suite caveat is closed — by CI, not by me. My local run exceeded my timeout twice and the background job produced no output, so I never reproduced the 208-passed figure myself. Test & Coverage completing green covers it in the pipeline, which is the stronger instrument anyway. Recording the provenance rather than quietly claiming I ran it.

Everything substantive is in my two prior reviews and stands unchanged: RATE_LIMIT widened 60s→15m, independently re-simulated at 15 spawns / 3h outage against the 180 I measured before, mutation-verified (restoring the flat branch turns exactly one correctly-named test red), and the four structural properties re-confirmed — eventWillSpawn-gated reset, nextPollDelayMs actually consumed, break not continue, event left unacked.

Still not verified

  • No E2E Tests context appears on this PR, where #780/#781/#785 had one. Consistent with a path-filtered workflow not triggering on a CLI-and-docs diff — but absent is not the same as skipped, and I did not confirm which. Worth a glance if E2E is expected to gate everything.
  • No live outage reproduction; this remains policy-level simulation. The classifier still cannot classify the failure shape that motivated the issue (exit 1, empty stderr → RUNTIME), which is by design and correctly bounded, but means RUNTIME does the real work in practice.
  • I did not review the ADR-005 documentation changes in the diff.

Nothing blocking from my seat. Merge is Sam's.

@lilyshen0722
lilyshen0722 merged commit e13bf0f into main Aug 2, 2026
12 checks passed
@lilyshen0722
lilyshen0722 deleted the fix/782-spawn-retry-circuit branch August 2, 2026 03:49
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.

Wrapper retry on spawn failure amplifies a model-quota outage into a retry storm

1 participant