fix(subagents,workflows): add attempt watchdog and request-incompatible fallback classification - #1581
Conversation
Implementation NotesTask: Investigate and fix GitHub issue #1580 in the Atomic monorepo. Work in a separate git worktree named ../atomic-issue-1580-model-fallbacks (create it from origin/main if needed). Determine why the rest of the model fallback candidates did not get tried. Implement the required behavior: if none of the configured fallback candidates are applicable/usable for the current request/context, fall back to the currently selected user model rather than stopping without an applicable fallback. Preserve existing fallback behavior when candidates are applicable. Add or update focused tests that reproduce the issue and verify the selected user model fallback path. Update relevant user-facing docs in packages/coding-agent/docs and package changelog(s) under ## [Unreleased] if behavior changes. Use Bun commands only (bun test, bun run typecheck/lint as appropriate). Create a pull request when complete with a clear summary and validation evidence. Running Notes
Implementation Decisions
Continuation Update — Reviewer Precedence Fix
Continuation Update — HTTP 413 Payload Too Large
Validation Outcomes
Blockers / Deferred
QA E2E Video
|
|
Preview deployment for your docs. Learn more about Mintlify Previews.
💡 Tip: Enable Workflows to automatically generate PRs for you. |
Review:
|
Code Review — PR #1581: advance model fallback on request-incompatible errorsThanks for this — the classifier fix is well-targeted and the regression coverage is genuinely thorough (status fields, numeric/string codes, message regexes, and the crucial "incompatibility must not outrank refusals/cancellations" precedence tests). The reordering of direct-message classification in the workflows 🔴 Scope: two distinct changes bundled under one issueThe PR's stated purpose (#1580) is the 🟠 Behavioral change: empty candidate list now produces zero spawn attemptsIn
🟠 Broad message/code patterns may over-fallback on genuinely fatal requests
Separately, a couple of message patterns are broad enough to risk false positives:
These only fire on the error path (after refusal/cancel precedence), so the blast radius is limited, but tightening 🟡 Duplicated
|
Review:
|
|
Re: the review's scope concern — resolved by expanding this branch to actually fix the hang rather than downgrading to |
- Idle watchdog now treats an in-flight tool execution as activity so a slow, quiet tool call (long build/test run with no interim output) is not falsely killed as a stalled attempt; the wall-clock cap still bounds such attempts. Wired into both the foreground and background spawn paths. - Extract collectKnownModelProviders() helper to replace the four duplicated modelRegistry provider-derivation expressions. - Align the subagents classifier's direct-message precedence with the workflows classifier (direct message after nested cause/diagnostic traversal, before outer status/code) and add a cross-package conformance test so the two classifier copies cannot silently drift. - Remove @ts-nocheck from the request-incompatible classifier test. - Add watchdog tests: tool-active idle deferral (foreground and background), post-tool stall, wall-clock cap, SIGTERM->SIGKILL escalation. - Comment the intentional '!== undefined' empty-candidates guard in subagent-runner-step.ts; document tool-activity semantics in subagents.md; update changelogs. Refs: #1581 Assistant-model: Claude Fable 5
- Idle watchdog now treats an in-flight tool execution as activity so a slow, quiet tool call (long build/test run with no interim output) is not falsely killed as a stalled attempt; the wall-clock cap still bounds such attempts. Wired into both the foreground and background spawn paths. - Extract collectKnownModelProviders() helper to replace the four duplicated modelRegistry provider-derivation expressions. - Align the subagents classifier's direct-message precedence with the workflows classifier (direct message after nested cause/diagnostic traversal, before outer status/code) and add a cross-package conformance test so the two classifier copies cannot silently drift. - Remove @ts-nocheck from the request-incompatible classifier test. - Add watchdog tests: tool-active idle deferral (foreground and background), post-tool stall, wall-clock cap, SIGTERM->SIGKILL escalation. - Comment the intentional '!== undefined' empty-candidates guard in subagent-runner-step.ts; document tool-activity semantics in subagents.md; update changelogs. Refs: #1581 Assistant-model: Claude Fable 5
da61b59 to
6d8197a
Compare
Review:
|
Treat request/context incompatibility failures as fallbackable in workflow and subagent model fallback chains so Atomic advances through configured candidates and reaches the current selected model when needed. Adds focused regression coverage for HTTP 400/413/422, request-too-large/context-window/unsupported-tool signals, and non-retryable refusal/cancellation precedence. AI-Assisted-By: GPT-5.5
Assistant-model: GPT-5.5
Assistant-model: GPT-5.5
- Idle watchdog now treats an in-flight tool execution as activity so a slow, quiet tool call (long build/test run with no interim output) is not falsely killed as a stalled attempt; the wall-clock cap still bounds such attempts. Wired into both the foreground and background spawn paths. - Extract collectKnownModelProviders() helper to replace the four duplicated modelRegistry provider-derivation expressions. - Align the subagents classifier's direct-message precedence with the workflows classifier (direct message after nested cause/diagnostic traversal, before outer status/code) and add a cross-package conformance test so the two classifier copies cannot silently drift. - Remove @ts-nocheck from the request-incompatible classifier test. - Add watchdog tests: tool-active idle deferral (foreground and background), post-tool stall, wall-clock cap, SIGTERM->SIGKILL escalation. - Comment the intentional '!== undefined' empty-candidates guard in subagent-runner-step.ts; document tool-activity semantics in subagents.md; update changelogs. Refs: #1581 Assistant-model: Claude Fable 5
6d8197a to
d437806
Compare
Review: model-attempt watchdog + request-incompatible fallback (#1580)Overall this is a strong, well-scoped fix. The hang is addressed at the right layer (per-attempt watchdog wired into both spawn paths), the synthetic timeout is genuinely retryable, and the tests/docs/changelogs are thorough. A few notes below, one of which I think is a real behavior regression worth addressing. 🔴 Foreground/background asymmetry on empty candidates (likely regression)The foreground path deliberately distinguishes "every candidate was filtered out" from "there were no candidates to begin with":
const modelsToTry = candidates.length > 0 ? candidates : (rawCandidates.length === 0 ? [undefined] : []);So a default-model agent (no explicit The background path does not make that distinction. With the guard now keyed on
const candidates = step.modelCandidates !== undefined ? step.modelCandidates : step.model ? [step.model] : [undefined];
...
if (!finalResult && candidates.length === 0 && modelAttempts.length > 0) { /* "No spawnable..." */ }
Before this PR the 🟡 Minor
✅ Strengths
Note: I was unable to execute the test suite in this environment, so I'm relying on the PR's stated |
Address PR #1581 review feedback: - Background runner now mirrors the foreground empty-candidates distinction: an empty modelCandidates array with no pre-spawn skipped attempts means no candidates were ever configured (no primary, no fallbacks, no current model), so one default-model attempt is spawned instead of silently exiting 1 with no error and no spawn. A filtered-to-empty list (which always carries skipped attempts) is still respected and surfaced as an error. - Watchdog escape hatch: ATOMIC_SUBAGENT_ATTEMPT_IDLE_TIMEOUT_MS / ATOMIC_SUBAGENT_ATTEMPT_TIMEOUT_MS set to 0 (or negative) now disable the corresponding per-attempt timeout; non-numeric values remain ignored and documented as such. - Tests: background default-attempt regression, idle-disabled via env, non-numeric/negative env resolution; docs + changelogs updated. Refs: #1581 Assistant-model: Claude Fable 5
|
Addressed the review feedback in 776c4b0: 🔴 Foreground/background asymmetry on empty candidates — fixed in 🟡 🟡 Quiet reasoning turns / duplicated classifier tables — acknowledged; no code change. The idle window stays overridable (and now fully disableable) for providers with long silent generations, and the cross-package conformance test remains the guard for the classifier copies.
|
Review:
|
- Document the abnormal-tool-end caveat at both isToolActive callsites: if a tool never emits its end event the idle watchdog is deferred indefinitely and the wall-clock cap is the sole backstop; add a foreground test proving the wall cap terminates such attempts. - Document that ATOMIC_SUBAGENT_ATTEMPT_KILL_GRACE_MS intentionally cannot be disabled (0/negative/non-numeric fall back to the default) in the resolver, subagents.md, and both changelogs. - Note in the classifier conformance suite that it enforces behavioral (not structural) parity over a finite corpus, that shared-module extraction is blocked by the package split, and that both copies plus a fixture must be updated together; add a fixture documenting the known benign-"too large" message false positive. - Split the watchdog test suite (shared helpers + separate pre-spawn candidate-filtering suite) to stay under the 500-line file gate. Refs: #1581 Assistant-model: Claude Fable 5
|
Addressed the second-pass feedback in 723d21b: 1. Cost tradeoff of 2. Breadth of the bare 3. Two classifier copies — extracting a shared module is blocked by the package split (the copies must not import each other), so the conformance suite header now explicitly states its limitation: it enforces behavioral parity over a finite corpus only, notes the copies are structurally different, and instructs that any rule change must update both copies plus add a corpus fixture. 4. 5. 6. Also split the watchdog test suite into shared helpers + a separate pre-spawn candidate-filtering suite to stay under the repo's 500-line file gate. |
Review: attempt watchdog + fallback classifier (#1581)Reviewed the full diff (new Note: What's solid
Maintainability — the main concern
Correctness / cost nits (non-blocking)
Minor
Nice work — the fix is thoughtful and the invariants are unusually well-commented. The classifier-duplication question is the one thing I'd want a follow-up answer on before it calcifies. |
Summary
Fixes the #1580 stall end-to-end and hardens model fallback chains in subagents and workflows.
Issue #1580 reported a parallel subagent run stuck at
0/3 donefor 2h34m: the first candidate failed fast (missing API key, already retryable), but the second candidate's request hung with 0 turns/0 tokens, andawait runPiStreaming(...)had no idle watchdog or per-attempt timeout — so the attempt (and the whole batch) blocked until manual abort. Separately, request/context-incompatibility failures (HTTP 400/413/422, context-length overflow, unsupported tool/parameter) were classified non-retryable, so a chain that did fail with one of those never reached the current user-selected model as the last resort.Closes #1580
Changes
Per-attempt idle watchdog + wall-clock cap (the #1580 hang fix)
packages/subagents/src/runs/shared/attempt-watchdog.ts: every model attempt now runs under an idle/no-progress watchdog (default 5 min, reset on every streamed child event/stdout/stderr activity) and an absolute wall-clock cap (default 60 min). On trip, the child is killed (SIGTERM → SIGKILL after a 3s grace) and the attempt resolves with a synthetic"...timed out..."failure that the existing classifier treats as retryable, so the fallback loop advances to the next candidate instead of blocking forever.subagent-runner-step.ts/subagent-runner-streaming.ts) and the foreground path (execution-run-sync.ts/execution-attempt.ts), including chain/parallel executors.ATOMIC_SUBAGENT_ATTEMPT_IDLE_TIMEOUT_MS,ATOMIC_SUBAGENT_ATTEMPT_TIMEOUT_MS, andATOMIC_SUBAGENT_ATTEMPT_KILL_GRACE_MS. Setting the idle or wall-clock override to0(or a negative value) now disables that timeout entirely; non-numeric values remain ignored and fall back to the default.Pre-spawn candidate filtering
packages/subagents/src/runs/shared/model-candidate-filter.ts: candidates whose provider is known and affirmatively has no configured API key/auth are skipped before spawning a child, recorded as skippedmodelAttemptsso the decision is visible in artifacts. Unknown/custom providers are still attempted (they already fail fast), and the current user-selected model appended as last resort is never filtered out.collectKnownModelProviders()inpackages/subagents/src/shared/model-info.tsto replace four duplicated model-registry provider-derivation expressions.subagent-runner-step.ts) now mirrors the foreground's empty-candidates handling: an emptymodelCandidatesarray with no pre-spawn skipped attempts means no candidates were ever configured (no primary, no fallbacks, no current model), so it spawns one default-model attempt instead of silently exiting with no attempt and no error. A list that was filtered down to empty (which always carries skipped attempts) is still respected and surfaced as an error.request_incompatiblefallback classificationrequest_incompatibleModelFallbackFailureKindto bothpackages/workflows/src/runs/shared/model-fallback-failures.tsandpackages/subagents/src/runs/shared/model-fallback.ts, included in each package'sFALLBACKABLE_FAILURE_KINDS.invalid_request/bad_request/too_large-style codes and messages asrequest_incompatiblevia status codes, error codes, and message patterns.model-fallback-classifier-conformance.test.ts) runs a shared failure corpus through both to guard against future silent drift.Key files
packages/subagents/src/runs/shared/attempt-watchdog.ts(new)packages/subagents/src/runs/shared/model-candidate-filter.ts(new)packages/subagents/src/runs/shared/model-fallback.tspackages/workflows/src/runs/shared/model-fallback-failures.tspackages/subagents/src/runs/background/{subagent-runner-step,subagent-runner-streaming}.tspackages/subagents/src/runs/foreground/{execution-run-sync,execution-attempt,chain-execution*}.tspackages/subagents/src/shared/model-info.tsTests, docs, changelogs
test/unit/subagents-attempt-watchdog.test.ts: watchdog trips on a stalled child and the fallback loop advances with a synthetic retryable failure; idle timer resets on child activity and on in-flight tool execution (no false kills, foreground and background); wall cap trips independently of tool activity; SIGTERM→SIGKILL escalation; pre-spawn filter skips known keyless providers but never unknown providers or the current-model last resort; background default-attempt regression when no candidates are configured; idle/wall timeout disabled via0/negative env override; non-numeric env values ignored.test/unit/model-fallback-request-incompatible.test.ts,test/unit/model-fallback-classifier-conformance.test.ts,test/unit/subagents-model-fallback.test.ts,test/unit/stage-runner-fallback-resume.test.ts: classifier behavior, subagents/workflows classifier parity, and an end-to-end workflow fallback loop reaching the current user-selected model.packages/coding-agent/docs/subagents.md(watchdog/timeouts/tool-activity deferral/pre-spawn filtering/env disable escape hatch) andpackages/coding-agent/docs/workflows.md(request-incompatible fallback).[Unreleased]inpackages/coding-agent,packages/subagents, andpackages/workflows.Validation
bun test test/unit/subagents-attempt-watchdog.test.ts test/unit/subagents-model-fallback.test.ts test/unit/model-fallback-request-incompatible.test.ts test/unit/model-fallback-classifier-conformance.test.ts test/unit/stage-runner-fallback-resume.test.ts— all passbun run test:unit— green (run in the pre-push hook)bun run typecheck/bun run lint— passbun run check:file-length— pass (watchdog and filter extracted into new ≤100-line modules; no file pushed over the 500-line gate)QA E2E video
No QA E2E video applies; this is backend runner/classifier behavior with no browser/UI scenario. The watchdog and fallback-loop regression tests provide executable proof.