Skip to content

Remove the in-loop no-progress watchdogs that were failing healthy runs - #3447

Merged
manucorporat merged 9 commits into
mainfrom
fix/remove-false-positive-stall-watchdogs
Aug 24, 2026
Merged

Remove the in-loop no-progress watchdogs that were failing healthy runs#3447
manucorporat merged 9 commits into
mainfrom
fix/remove-false-positive-stall-watchdogs

Conversation

@manucorporat

Copy link
Copy Markdown
Contributor

The problem

Two 90s bounds ran for the whole model stream — one on silence between engine frames (MODEL_STREAM_NO_PROGRESS_TIMEOUT_MS), one on a tool input whose byte count stopped growing (ACTION_PREPARATION_NO_PROGRESS_TIMEOUT_MS) — plus a zero-byte tool-input restart tripwire.

Each inferred a dead stream from the absence of a particular event. That inference cannot be made on the Anthropic transport:

// @anthropic-ai/sdk core/streaming.js
if (sse.event === 'ping') { continue; }

The SDK drops the provider's ping keepalives before any consumer sees them, with no opt-out — RawMessageStreamEvent has no ping variant at all. So a model composing a large tool argument is byte-for-byte indistinguishable from a wedged socket.

And that is normal operation, not an edge case. Only a tool declared for eager input streaming emits input_json_delta while its arguments are generated. Everything else produces tool-input-start and then nothing until the whole blob is ready — for a large file write or a long structured result, that is minutes of legitimate silence.

Evidence

In one production deployment, 2 of 27 one-shot analyst runs completed. The dominant terminal states were stale_run and truncated, with real progress consistently stopping during the final large tool call. Guards added for reliability were the thing taking it away.

Three failure modes in actionPreparationDeadlineAt() specifically:

  • trackActiveToolInput was called only when bytes increase, so a frame carrying no new bytes was not progress.
  • With no tool input yet carrying bytes, the deadline fell back to earliestStartedAt + 90s — an absolute anchor that never moves. "Slow" became "dead" deterministically at 90,000ms.
  • noProgressDeadlineAt() took the min of that and the model-stream clock, so lastModelStreamProgressAt could be updating on every frame — positive proof nothing was wedged — and it could not save the round.

What this removes

  • MODEL_STREAM_NO_PROGRESS_TIMEOUT_MS and its deadline
  • ACTION_PREPARATION_NO_PROGRESS_TIMEOUT_MS, its deadline, the earliestStartedAt fallback, and the Math.min override
  • The zero-byte restart tripwire (ACTION_PREPARATION_ZERO_BYTE_RESTART_LIMIT, noteZeroByteToolInputStart, resetZeroByteToolInputRestart)
  • The two run-lifecycle invariants asserting an ordering between those bounds and the run-manager backstop

+181 / −315.

What still catches a real wedge

None of these can fire on slow-but-healthy work:

  • the engine's own FIRST_STREAM_EVENT_TIMEOUT_MS (120s) — a stream that opens and never speaks
  • the run-manager backstop, for the segments outside the stream
  • the per-tool timeout, which bounds tool execution at the tool
  • the chunk / run budget, which bounds cost rather than health
  • the stale reaper, which bounds worker liveness

One in-loop bound survives: the pre-first-frame cap on the clamped hosted foreground runtime, where the ~57s platform wall arrives before the engine's 120s abort could. The first real frame releases it, so long first tokens, long thinking, long tool inputs and long outputs are all past it by construction. Off that runtime there is no in-loop deadline at all.

The trade, stated explicitly

model_stream start/end still suspends the run-manager's 150s backstop, and that suspension used to be justified by the inner watchdog bounding the window. With the watchdog gone, an in-stream wedge after the first frame is caught by the run budget rather than at 90s.

That is slower on a genuine mid-stream wedge, and it is the correct direction: no clock in the loop could tell that case apart from a model writing a large tool call. The comment in types.ts was updated so the old justification does not stand unchallenged.

Tests

Five tests asserted the deleted behaviour. They are rewritten to pin the inverse:

  • a tool input going quiet for 5 minutes is not checkpointed, and the text after it still lands
  • a zero-byte tool input staying quiet for 10 minutes is not checkpointed
  • a hung first event has no in-loop bound on non-hosted or background runtimes
  • a gap after the first event is never bounded in-loop, even on hosted foreground

src/agent/, src/app-config/, src/jobs/: 1,588 passing. Failures elsewhere in core (client, cli, eject, ingestion) are pre-existing — they fail identically on a clean tree from an unbuilt @agent-native/toolkit and a missing xlsx.

Note for reviewers

One implementation detail worth a look: setTimeout(…, Infinity) is coerced to 1ms by Node, which would have turned "no bound" into the tightest bound there is. nextEngineEventWithNoProgressTimeout now awaits the iterator directly when the deadline is not finite.

Not included

runToolTimeoutCeilingMs derives from the 780s chat ceiling while a background automation's real budget is 580s (backgroundRunHardTimeoutMs − headroom), so the per-tool timeout is dead code on that path and the chunk boundary wins. Real bug, separate fix.

Two 90s bounds ran for the whole model stream — one on silence between
engine frames, one on a tool input whose byte count stopped growing — plus
a zero-byte restart tripwire. Each inferred a dead stream from the absence
of a particular event, and that inference cannot be made on the Anthropic
transport: the SDK drops the provider's `ping` keepalives before any
consumer sees them (`core/streaming.js`: `if (sse.event === 'ping')
continue;`, with no opt-out). A model composing a large tool argument is
therefore byte-for-byte indistinguishable from a wedged socket.

That is normal operation. Only a tool declared for eager input streaming
emits anything while its arguments are generated, so a long file write or a
long structured result is a content-silent window sized by the argument. In
one production deployment 2 of 27 one-shot analyst runs completed; the
guards added for reliability were the thing taking it away.

Also removed: the `earliestStartedAt` fallback, which anchored the
action-preparation deadline to a start time it never advanced past, and the
`Math.min` that let that deadline override a demonstrably live stream.

One in-loop bound survives — the pre-first-frame cap on the clamped hosted
foreground runtime, where the ~57s platform wall arrives before the
engine's 120s abort could. The first real frame releases it, so long first
tokens, long thinking, long tool inputs and long outputs are past it by
construction.

Real failures keep the bounds that key off evidence rather than absence:
the engine's own first-event abort, the run-manager backstop outside the
stream, per-tool execution timeouts, the chunk budget, and the stale
reaper. The trade is explicit: an in-stream wedge after the first frame is
now caught by the run budget rather than at 90s, because no clock here
could tell it apart from a model writing a large tool call.
@github-actions

github-actions Bot commented Aug 23, 2026

Copy link
Copy Markdown
Contributor

Here's a visual recap of what changed:

Visual recap

Open the full interactive recap

`runAgentLoop` re-derived the chunk budget from `resolveRunSoftTimeoutMs`,
which answers with the generic background chat ceiling (13 min). A
background AUTOMATION does not have that budget: its own hard abort minus
headroom is 10min - 20s, so the per-tool ceiling came out at 12m55s inside
a run that had 9m40s. Every per-tool timeout on that path was therefore
unreachable and the chunk boundary won instead — the exact inversion
`RUN_TOOL_TIMEOUT_HEADROOM_MS` exists to prevent, reintroduced by guessing
at a number the caller already knew.

`runAgentLoopWithResume` now passes its resolved `timeoutMs` down as
`runSoftTimeoutMs`, and the ceiling is derived from that when present. `0`
(local dev, no soft-timeout regime) keeps the existing fallback.

Test asserts both halves: the chat ceiling produces a tool ceiling ABOVE an
automation's budget, and the automation's own budget produces one below it.
builder-io-integration[bot]

This comment was marked as outdated.

@manucorporat

Copy link
Copy Markdown
Contributor Author

Follow-up: the tool-timeout ceiling (pushed in 8e208fe)

runAgentLoop re-derived the chunk budget instead of taking the caller's. It asked resolveRunSoftTimeoutMs for the generic background chat ceiling (13 min) — but a background automation's budget is its own hard abort minus headroom (10 min − 20s = 9m40s). The per-tool ceiling came out at 12m55s inside a run that had 9m40s, so every per-tool timeout on that path was dead code and the chunk boundary won instead. That is the exact inversion RUN_TOOL_TIMEOUT_HEADROOM_MS exists to prevent.

runAgentLoopWithResume now passes its resolved timeoutMs down; 0 (local dev) keeps the old fallback. Regression test asserts both halves.

On the two remaining failure modes

I looked at both and did not add fixes for them, because the evidence says they are downstream of what this PR already deletes rather than separate bugs.

truncated (10 of 27 runs in the affected deployment)truncated is set when a run ends at a continuation boundary rather than finishing. Every auto_continue event in that production database is reason: "no_progress". Those checkpoints came from the watchdogs this PR removes, so this should fall away with them.

stale_run (14 of 27) — the shape is consistent across all fourteen: real progress stops at 188–345s, the heartbeat continues well past it (one for 3,309s), then the terminal event lands ~90–110s after the last heartbeat. Progress stopping while the run-manager keeps heartbeating is what the checkpoint/continue cycle looks like from outside.

Two things I could not establish and am not guessing at:

  • why one run heartbeated for 55 minutes when the automation runner's hard abort is 10 minutes — if the runner's process is gone its setTimeout is gone too, so something else was keeping that row alive;
  • why the analyst run I traced in detail had zero persisted auto_continue events despite checkpointNoProgress always emitting one before closing the bracket.

Both are worth instrumenting rather than patching. stale_run today records no liveness numbers at all, so a correct reap and a false one are indistinguishable after the fact — that is the thing to fix next, and it belongs in its own change.

…runs

CI failed on this branch with every test passing: 12,465 green, one worker
exited unexpectedly, exit 1. Two causes, both mine.

The tests I rewrote left `runAgentLoop` promises pending forever. Their
engine hung on `new Promise(() => {})` and ignored `abortSignal`, so
aborting the controller settled nothing and the vitest worker was torn down
with the fork still live. Added `abortableHangingEngine`, which returns when
the caller aborts, and each of those tests now awaits the run before
restoring timers.

And twelve more tests still pinned the deleted action-preparation
machinery — the zero-byte restart tripwire, the byte-growth deadline, the
stalled-input tracking across snapshots. The first of them hung outright
once nothing was left to checkpoint it, which is what killed the worker.
They assert behaviour this branch deliberately removes, so they go with it.

The five that assert a checkpoint must NOT fire are kept: "keeps a model
stream alive when non-heartbeat events continue", the three
abandoned-zero-byte-id cases, and "keeps assembling a large action input
while bytes keep streaming". Those are exactly the property this branch is
for, and they still hold.

production-agent.spec.ts: 253 passing, no unhandled errors.
builder-io-integration[bot]

This comment was marked as outdated.

builder-io-integration[bot]

This comment was marked as outdated.

`stale_run` is the largest terminal outcome on the one-shot automation path
— 14 of 27 runs in one production deployment — and the row records nothing
about why. `error_detail` is the same fixed sentence for every reap, so a
correct reap and a false one are indistinguishable after the fact, and every
question worth asking needed a number nobody stored.

The reap now records: which of the three stale windows applied, whether the
row had a dispatch payload to recover from, time since heartbeat, time since
last progress, whether the in-flight grace was in play and for how long, and
the run's age measured to its liveness basis rather than to the reap.

The field that matters is `hbAheadOfProgress`. A worker that died takes its
heartbeat with it, so heartbeat and progress stop together and it reads ~0. A
worker still running while the agent loop stops producing keeps heartbeating
and it grows without bound — one production run showed the heartbeat 3,000s
past the last progress, and nothing recorded that, so nothing could act on
it. Those are opposite bugs that look identical in `agent_runs` today.

ONE diagnostic write, not two: `diag_stage` holds a single value, so an
independent forensics write would have overwritten the recovery outcome that
already lands there — trading "did a successor get created" for "why did it
die". Both fit on one line, so the recovery outcome keeps its stage name and
leading position and the numbers are appended.

Numbers, booleans and an enum only — no prompt, no result, no user content —
so this obeys the same privacy rule as event properties and log lines, and a
test asserts every token matches `key=<number|enum>`. `staleWindowMsForRow`
mirrors `backgroundAwareStaleCutoffSql`, which decides the same thing in SQL
because it must run inside the conditional UPDATE; a test pins all three
cases so the two cannot drift.

src/agent, src/app-config, src/jobs: 1,769 passing.
builder-io-integration[bot]

This comment was marked as outdated.

@steve8708 steve8708 left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Awesome

builder-io-integration[bot]

This comment was marked as outdated.

Three review findings on this PR, all real.

1. Resumed rounds kept the invocation's budget. `stableOpts` carries the
   full `timeoutMs`, but round 2+ runs inside `roundTimeoutMs` — what is
   left after earlier rounds spent wall-clock. Clamping a per-tool timeout
   against the full window put it above the round containing it, so the
   round timer won and the per-tool timeout was unreachable. Each round now
   gets its own budget.

2. The main chat handler resolved its soft timeout for `startRun` and then
   let the loop re-derive one. An app-configured chunk shorter than the
   generic hosted/background ceiling therefore got per-tool timeouts longer
   than the chunk containing them. Resolved once, passed to both — the same
   inversion `RUN_TOOL_TIMEOUT_HEADROOM_MS` exists to prevent, two call
   sites over from the automation case already fixed here.

3. After the first frame the in-loop deadline is infinite on every runtime,
   which is the point of this PR. But local dev and self-hosted resolve the
   soft timeout to 0, so the run-manager backstop is off and the engine's
   first-event bound has already been released: on the direct-provider
   engines a socket that wedged after the first frame had nothing left to
   catch it. builder-engine already solves this with a two-stage deadline;
   `createFirstEventAbortController` now does the same, so anthropic-engine
   and ai-sdk-engine get a 14-minute `STREAM_TOTAL_TIMEOUT_MS` on the whole
   call once the first frame releases the 120s first-event bound.

   This is a total-request bound, not a no-progress bound. It cannot fire on
   the healthy content-silent generation this PR exists to protect — the
   largest chunk any caller runs inside is the ~13min background soft
   timeout — and it does not reintroduce the absence-of-event inference.
   Which deadline fired is now reported per stage, so a mid-stream wedge is
   no longer described as a connection that never spoke.

   The AI SDK path also swallowed a deadline abort as a graceful
   `{type:"abort"}` part and fell through to a clean `end_turn`. Not gated
   on `sawFirstEvent` any more: the total deadline fires mid-stream by
   definition, and a truncated turn reported as a complete one is the
   failure mode this repo names first.

Also unblocks CI: the stale-reap forensics query discarded a rejection into
`""`, which reads as "reaped with nothing worth saying" — the exact
ambiguity those forensics exist to remove. An unreadable row now says so.
builder-io-integration[bot]

This comment was marked as outdated.

`fireTimeout` recorded `timeoutMessage` before checking whether the composed
controller had already been aborted, and `abortFromParent` left the deadline
armed. A provider that is slow to settle after a user Stop or a run-budget
abort could therefore fire the timer into an already-cancelled request and
set `didTimeout()` — which is precisely what anthropic-engine and
`classifyProviderError` read to tag a failure `provider_network_error` /
retryable. A cancellation would have come back as a resumable provider
error and been resumed.

The ordering is pre-existing, but it was close to harmless while
`markFirstEvent()` cleared the timer outright: nothing was armed after the
first frame. Arming a total deadline for the whole stream turned a narrow
race into a whole-stream one, so this is the new bound's bug to fix.

- a timeout is recorded only when this controller wins the abort race
- parent cancellation clears the outstanding deadline
- `markFirstEvent()` will not re-arm one on an aborted controller, so a
  frame still in flight when the Stop lands cannot resurrect the timer

Three tests, each verified to fail against the previous logic: cancellation
mid-stream while the provider settles, cancellation before the first event,
and a frame landing after cancellation.
builder-io-integration[bot]

This comment was marked as outdated.

The custom-agent mention path runs `runAgentLoop` directly inside the same
`startRun` chunk and on the same signal as the main loop, but without
`runSoftTimeoutMs`. The loop therefore fell back to re-deriving the generic
hosted/background ceiling, which can sit above the chunk those nested calls
actually run inside — so the per-tool timeout was unreachable and the chunk
boundary preempted it.

Fourth call site of this same shape in this PR. The recurring fix is a
symptom: `runAgentLoop` silently guessing a budget when the caller does not
supply one is the boundary, and every in-chunk caller having to remember to
pass it is how the next one gets missed. Making the budget non-optional, or
having `startRun` hand its resolved budget to the runFn, is the real fix and
is too wide for this PR — noted rather than smuggled in.

The standalone single-tool helper is unaffected: it has no surrounding
chunk, which is what the optional field is for.

@builder-io-integration builder-io-integration Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Builder reviewed your changes — looks good ✅

Review Details

Code Review Summary

This incremental review examined the latest custom-agent timeout-budget propagation along with stream timeout/cancellation behavior, resumed-round budgets, and run-store diagnostics. The previously open custom-agent comment was verified fixed and resolved: the resolved enclosing chunk budget now reaches both custom-agent subloops and the primary loop, keeping per-tool ceilings inside the actual run budget. The earlier first-event/total-stream cancellation protections and stale-run forensic coverage remain consistent, and the focused timeout, resume, run-store, run-manager, and production-agent suites passed (410 targeted lifecycle/run-store tests plus 253 production-agent tests).

No new confirmed bugs or regressions were found. This is standard risk because the PR changes shared agent lifecycle and timeout behavior.

🧪 Browser testing: Skipped — PR only modifies backend/config/docs/tests, no UI impact

@manucorporat
manucorporat merged commit 628b822 into main Aug 24, 2026
46 checks passed
@manucorporat
manucorporat deleted the fix/remove-false-positive-stall-watchdogs branch August 24, 2026 10:27
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