Skip to content

fix(subagent): credit provider-paused time against the wall-clock ceiling - #797

Merged
griffinwork40 merged 4 commits into
mainfrom
fix/pause-aware-subagent-ceiling
Aug 1, 2026
Merged

fix(subagent): credit provider-paused time against the wall-clock ceiling#797
griffinwork40 merged 4 commits into
mainfrom
fix/pause-aware-subagent-ceiling

Conversation

@griffinwork40

Copy link
Copy Markdown
Owner

The incident

On 2026-07-31 a code-backed /forge run lost 1h49m of work. Verified from ~/.afk/agent-framework/forge-telemetry.jsonl and witness traces:

Time (UTC) Event
01:16:47 Provider returns HTTP 429, retryAfterMs: 6792000; runtime logs usage_limit_pause with resetsAt: 2026-07-31T03:10:00.000Z (~113-min account-level pause)
02:26:21.307 Forge forks forge-rework-2 with no explicit timeoutMs → inherits SUBAGENT_DEFAULT_TIMEOUT_MS (2,700,000 ms)
~02:34 Idle watchdog does not fire at its 8-min window — the runtime recognized the pause and correctly extended the idle deadline
03:11:21.307 Child dies — exactly dispatch + 2,700,000 ms, 81 seconds after the pause reset — with a bare wall-clock TimeoutError

The idle watchdog consumed the pause signal correctly. The wall clock ignored the very same signal and killed the child anyway. The child had spent 43m38s of its 45-minute budget parked and received roughly 81 seconds of actual working time.

Net effect: a fork parked by a provider-imposed pause longer than its ceiling is guaranteed to die, no matter what the caller does. The only knob bounds wall time, so there was no way to express "bound the child's working time". Callers cannot pre-compute a safe timeoutMs because the pause window is unknowable at dispatch; the only escape was timeoutMs: 0 (fully unbounded), which is worse.

The decision this revises

.afk/plans/subagent-idle-watchdog.md L88 states verbatim:

idle watchdog is the tighter first-to-fire bound; wall-clock stays as the un-resettable ceiling. Both run concurrently via Promise.race.

#672 built pause-awareness into a separate IdleWatchdog specifically so it would not touch withTimeout. This PR is a deliberate revision of that shipped architectural decision, not a gap-fill.

The revision honors the purpose of the invariant — a fork's lifetime must have a guaranteed finite, predictable upper bound — rather than its letter, which made the actually-desired bound inexpressible. The ceiling remains un-resettable by the child; it is now movable only by the provider.

Design: bounded credit, not a reset

The budget now bounds working time:

effective ceiling = timeoutMs + min(provider-parked time, SUBAGENT_MAX_PAUSE_EXTENSION_MS)

Crediting parked time — rather than asking "is a pause in effect right now?" — is load-bearing. In the incident the park ended 81s before the deadline, so a right-now check grants nothing and the child dies exactly as before. My first implementation made precisely that mistake; the incident-replay test caught it.

Guarantees:

  1. Provider-only signal. Only paused (with resetsAt) and rate_limit (with retryAfterMs) — the same events IdleWatchdog already consumes, originating in the provider retry layer. Child content, tokens, thinking, tool activity, message, stream_retry grant nothing. The child cannot author the only signal that moves the ceiling.
  2. Each grant bounded by the reported window. A pause self-closes once its own stated window elapses, so an unresumed park stops accruing at its stated end. Never open-ended.
  3. Absolute cap: SUBAGENT_MAX_PAUSE_EXTENSION_MS = 2h. Justification: the OAuth subscription park this defends against is itself bounded (plan doc L64, "subscription park, ≤2h"), and the observed real park was ~113 min (1h53m), which 2h covers with ~7 min of headroom. Choosing the provider's own maximum park means a legitimately parked child survives any pause the provider can impose, while a child parked by anything else is still bounded. Worst-case lifetime at the default budget: 45min + 2h = 2h45m — finite and predictable, reached only when a provider actively reports being parked that entire span.
  4. Diagnosable failure. Firing under pause now yields ... [pause-aware ceiling: base budget 2700000ms; pause extension granted Nms across K extensions (cap 7200000ms); last provider pause: paused (usage-limit, resetsAt=...)] instead of a bare Operation timed out after 2700000ms.
  5. No-pause behaviour is unchanged. With no pause events the extender grants nothing: same single timer, same fire time, same error message string, same TimeoutError.timeoutMs, same controller-abort semantics. Pinned by an explicit byte-for-byte test against the no-extender path.

Shared arithmetic (no duplication)

pausedWindowMs-style logic is extracted into src/agent/subagent/pause-window.ts, consumed by both IdleWatchdog and the new ceiling so the two bounds cannot drift apart. IDLE_WATCHDOG_PAUSE_SLACK_MS is now an alias of the shared PAUSE_WINDOW_SLACK_MS. All 20 pre-existing idle-watchdog tests pass unchanged.

Files

File Δ Note
src/agent/timeout.ts +91/-10 (134 LOC) Additive TimeoutExtender seam; consulted only at the deadline
src/agent/subagent/pause-window.ts new, 88 LOC Shared pause arithmetic + slack constant
src/agent/subagent/pause-ceiling.ts new, 309 LOC PauseAwareCeiling + SUBAGENT_MAX_PAUSE_EXTENSION_MS
src/agent/subagent/handle.ts +29 Attach extender to withTimeout; feed the stream
src/agent/subagent/idle-watchdog.ts +18/-8 Delegate to shared arithmetic

Every source file stays under the 350-LOC limit; new concerns went into new files rather than growing existing ones.

Tests

pause-ceiling.test.ts (14), pause-window.test.ts (10), handle.test.ts (+3 wiring):

  • ceiling fires at the normal deadline with no pause event (regression guard: unchanged behaviour)
  • error message byte-for-byte identical to the no-extender path when no pause was seen
  • ceiling extends on paused carrying resetsAt
  • ceiling extends on rate_limit carrying retryAfterMs
  • credit bounded by the reported window when a park never resumes
  • accumulated extension capped by the absolute cap, then the ceiling fires (EXHAUSTED in the message)
  • content / thinking / tool_use_detail / tool_result / stream_retry never extend (anti-gaming)
  • resumed banks the closed pause's credit and stops further accrual
  • repeated rate_limits that never pair with resumed do not credit working time (see below)
  • pause with no knowable window grants nothing; overlapping signals keep the furthest-out end
  • timeout error names the pause context when firing under pause
  • a throwing extender never strands the wait
  • incident replay: 45-min budget vs the real ~43m38s parked span — asserts the child is still alive at 03:11:21.307Z (where it previously died) and that the bound stays ≤ cap
  • handle-level wiring: a real fork survives a streamed paused, and does not extend on ordinary content

Adversarial review caught a real bug (2nd commit)

I dispatched an independent read-only verifier to re-derive finiteness, anti-gaming, no-pause identity, and refactor safety. It confirmed three and found a genuine merge-blocker in my first commit:

A pause was closed only on resumed — but resumed is emitted only on the OAuth path (retry-layer.ts:415,512); a transient rate_limit never has one. That pause stayed open forever, and since a later signal widens the window by the elapsed gap, each new throttle retroactively credited the ordinary working time between throttles. A 5s retry-after seen every 10 min credited the full 10 min. Finiteness was never at risk (the cap still bound it) but the budget's meaning was.

Fixed in 0053360: a pause self-closes once its reported window elapses. Verified — 4×5s throttles across 40 min now credit 140s (the reported windows), not 40 minutes. Regression test added. The same review also prompted a 1s floor on grants (avoids millions of ~1ms timer re-arms) and an expired vs still open distinction in the error text.

Verification

Gate Result
pnpm lint (tsc --noEmit) clean, exit 0
pnpm vitest run src/agent/subagent/ src/agent/timeout.test.ts 131 passed (9 files)
pnpm test (full) 723 files, 13772 passed, 2 skipped

For reviewers to scrutinize

  • The cap value (2h). Defensible but a judgment call; it is the one number here worth arguing about.
  • SubagentHandleImpl.pauseCeiling is per-run instance state, mirroring lastStreamedContent, because run() owns withTimeout while the stream loop lives in streamToFinalMessage.
  • Latent behaviour change surfaced by the refactor: a rate_limit with retryAfterMs: Infinity previously passed Infinity > 0 into setTimeout, which Node clamps to 1ms → spurious instant idle-fire. The shared guard now treats it as "no knowable window". Strictly a fix, but it is an observable change in that edge case.
  • retryAfterMs > 2^31-1 still clamps inside IdleWatchdog.arm — pre-existing, untouched here.

…ling

A fork parked by a provider-imposed pause longer than its wall-clock budget
was GUARANTEED to die, no matter what the caller did. On 2026-07-31 a /forge
run lost 1h49m of work this way: the provider parked the account at 01:16:47Z
(HTTP 429, retryAfterMs 6792000, resetsAt 03:10:00Z — a ~113 min pause), the
child forked at 02:26:21.307Z inheriting SUBAGENT_DEFAULT_TIMEOUT_MS, and it
died at 03:11:21.307Z — exactly dispatch + 2,700,000 ms, 81 seconds AFTER the
pause reset. It had spent 43m38s of its 45-minute budget parked and got ~81
seconds of actual working time.

The idle watchdog did NOT fire at its 8-minute window, which proves the
runtime recognized the pause and correctly extended the idle deadline. The
wall clock ignored the very same signal and killed the child anyway. Callers
cannot pre-compute a safe timeoutMs because the pause window is unknowable at
dispatch; the only escape was timeoutMs: 0 (fully unbounded), which is worse.

WHY THIS REVISES A SHIPPED DECISION

.afk/plans/subagent-idle-watchdog.md:88 locked the wall-clock as the
"un-resettable ceiling", and #672 deliberately built pause-awareness into
IdleWatchdog alone so it would NOT touch withTimeout. This is a deliberate
revision of that decision, not a gap-fill. It honors the PURPOSE of the
invariant — a fork's lifetime must have a guaranteed finite, predictable upper
bound — rather than its letter, which made "bound the child's WORKING time"
inexpressible.

The budget now bounds working time:
  effective ceiling = timeoutMs + min(provider-parked time, cap)

Crediting parked time (rather than asking "is a pause in effect right now?")
is load-bearing: in the incident the park ended 81s BEFORE the deadline, so a
right-now check grants nothing and the child still dies.

Properties preserved:
- Only provider-reported pause signals (`paused` w/ resetsAt, `rate_limit` w/
  retryAfterMs) grant credit — the same events IdleWatchdog consumes. Child
  content, tokens, thinking, and tool activity NEVER extend the ceiling, so it
  remains un-resettable by the child: it cannot author the only moving signal.
- Each grant is bounded by the provider's own reported window, never
  open-ended; an unresumed park stops accruing at its stated end.
- Total accumulated extension is capped by SUBAGENT_MAX_PAUSE_EXTENSION_MS
  (2h — the provider's own maximum subscription park, covering the observed
  ~113 min with headroom). Once exhausted the ceiling fires as it does today.
  Worst case with the default budget is 45min + 2h = 2h45m: finite, predictable.
- On fire under pause the TimeoutError names the pause context instead of a
  bare "Operation timed out after 2700000ms".
- With no pause events, behaviour and error message are byte-for-byte identical.

Pause arithmetic is extracted to a shared pause-window module so the idle
watchdog and the ceiling cannot drift apart (all 20 existing idle-watchdog
tests pass unchanged). New concerns live in new files, keeping every source
file under the 350 LOC limit.
…time

Adversarial review of the previous commit found a real defect. A pause was
closed only on `resumed`, but `resumed` is emitted ONLY on the OAuth park path
(`retry-layer.ts:415,512`) — a transient `rate_limit` NEVER has a matching
`resumed`. Such a pause therefore stayed open forever, and because a later
signal widens the window by the elapsed gap (`elapsedIntoPause + windowMs`),
each new throttle retroactively credited the ordinary WORKING time between
throttles: a 5s retry-after seen every 10 min credited the full 10 min,
quietly inflating the effective budget toward `timeoutMs + cap`.

Finiteness was never at risk (the absolute cap still bound it), but the
budget's MEANING was: the whole point is to credit only time the provider
actually said it was parked.

A pause is now closed as soon as its own reported window has elapsed —
checked on every pause signal and at the deadline — so credit can only ever
reflect provider-stated park time. Verified: 4 x 5s throttles spread over
40 min now credit 140s (the reported windows incl. slack), not 40 min.

Also from the same review:
- Floor each grant at MIN_EXTENSION_GRANT_MS (1s). A deadline landing ~1ms
  after a pause opened would otherwise grant ~1ms at a time and re-arm the
  timer millions of times across a long park. Never exceeds the remaining cap,
  so the worst-case bound is unchanged.
- `describe()` now reports a stale pause as `expired` rather than
  `still open`, so the error text cannot misreport a long-dead throttle.

Independently re-derived and confirmed by the same review: worst-case total
wait is timeoutMs + SUBAGENT_MAX_PAUSE_EXTENSION_MS (2h45m at the default);
no child-authored event can grant extension; and the no-pause path is
byte-for-byte identical to before.
@vercel

vercel Bot commented Jul 31, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
agent-afk-docs Ready Ready Preview Jul 31, 2026 8:15pm

…cing the runner

The 'prints the quota line with its deadline once the binding window is
hot' test builds a deadline of exactly Date.now() + 12*60_000 and asserts
the rendered footer contains 'resets in 12m'. resetClause() in
quota-footer.ts recomputes msLeft against a fresh now captured later, at
render time, and formatResetCountdown() floors msRemaining/60_000 to whole
minutes. Any wall-clock elapsed time between snapshot and render can push
msLeft to 719_999ms, which floors to 11m instead of 12m — an exact-floor
boundary assertion racing the test runner's speed.

Freeze the clock in this describe block's beforeEach/afterEach so the
deadline arithmetic is exact instead of racing the runner. This flake
pre-existed on main and is unrelated to this PR's actual changes; the
identical fix was already verified green on the sibling PR #793 branch.
@griffinwork40

Copy link
Copy Markdown
Owner Author

Wave 1.5 verification complete. All file-state citations match the reviewed ref content (verified). The B1 absence claim ("PauseAwareCeiling emits zero trace events") is confirmed-absent — the single grep hit was a comment line (// emitted ONLY on the OAuth park path), not an actual emit call. No fabricated or diff-only citations; all findings stand. No critical/high findings surfaced, so the post-synthesis /shadow-verify gate is not triggered.

Since there are no cross-agent conflicts and all findings are ≤ medium severity, I'll synthesize inline (the synthesis is mechanical; dispatching a 7th session for 6 low-stakes findings would be overhead without value).


Review: PR #797 — pause-aware wall-clock ceiling for forked sub-agents

Target: fix/pause-aware-subagent-ceiling @ 36395feemain · +1166/-16, 9 files
Reviewed ref: 36395fee39b7e7015cffc933fa456b89201dab05 (clean working tree — file reads == reviewed ref)
Stated intent: PR title + body — credit provider-parked time against the wall-clock ceiling so a fork parked by a provider pause gets a full working budget; lifetime stays finite (budget + 2h cap) and un-resettable by the child; no-pause behaviour byte-for-byte unchanged.
Regime: full (1166 lines)

Summary

This revises a deliberately-shipped architectural decision (.afk/plans/subagent-idle-watchdog.md:88, "#672 kept pause-awareness out of withTimeout") because that split had a fatal real-world consequence: a /forge fork lost 1h49m when the wall clock ignored the same pause signal the idle watchdog honored. The design is sound: a new TimeoutExtender seam in timeout.ts, a PauseAwareCeiling policy that credits provider-parked time, and shared pause-window.ts arithmetic consumed by both bounds so they can't drift.

The reviewer-critical properties all verify clean:

  • Anti-gaming (security): paused/rate_limit/resumed events originate exclusively in the provider retry layer (retry-layer.ts, loop.ts, stream-consumer.ts); zero tool code in src/agent/tools/ authors them. Child content/thinking/tool events hit early-returns in onEvent and grant nothing. Capped at 2h regardless.
  • API-compat: All changes additive. WithTimeoutOptions.extender optional; TimeoutExtender new but not re-exported from agent/index.ts. IDLE_WATCHDOG_PAUSE_SLACK_MS 30000→alias of PAUSE_WINDOW_SLACK_MS (30000) — value-preserved, zero production importers.
  • No-pause identity: waitedMs equals timeoutMs exactly; describe() returns undefined when no pause observed → message byte-for-byte identical. Pinned by an explicit test.
  • Finiteness: grantedMs monotonic, bounded by 2h cap; recursive arm() is async setTimeout (no stack growth), bounded to ≤7200 re-arms.
  • IdleWatchdog refactor: arithmetic preserved; the only behavior change is the Number.isFinite guard that fixes a latent Infinity → 1ms spurious fire (a fix, called out in the PR body).

Findings

# Sev Conf Dimension Location Finding
1 medium high perf-observability src/agent/subagent/pause-ceiling.ts:114-309 PauseAwareCeiling emits zero trace/witness events for extension grants — the only observability is the terminal error string (describe()). Forensics for a future "child got N extensions totaling Xms" must reconstruct the timeline from the error alone, even though the handle already holds a traceWriter. Grep confirmed no emit/trace/witness call in the file.
2 medium high test-coverage src/agent/subagent/pause-ceiling.test.ts:239-263 No test covers pausedresumedfresh paused (re-open). The "banks a closed pause on resumed" test ends there; the composition of banked closedCreditMs with a re-opened pause's openPauseCreditMs() in onDeadline() is untested. Grep confirmed no such scenario.
3 medium high test-coverage src/agent/subagent/pause-ceiling.test.ts:313-326 The overlapping-signals test asserts only onDeadline() returns BUDGET synchronously — never wires through startGuarded/withTimeout, never checks totalGrantedMs, never verifies the short 60s signal's credit is subsumed by the long 90min park. The Math.max furthest-out branch is hit but its credit consequence is unverified end-to-end.
4 low high correctness src/agent/subagent/pause-ceiling.ts:259-275 The MIN_EXTENSION_GRANT_MS=1_000 floor over-grants by up to 999ms per deadline cycle: when uncreditedMs=100ms and remainingCap=2000ms, grantMs becomes 1000. Termination and the cap are unaffected (next cycle sees uncredited ≤ 0 and fires), but grantedMs/totalGrantedMs permanently exceeds actual creditable pause time by up to one floor-width — a slightly inflated diagnostic.
5 low high test-coverage src/agent/subagent/pause-ceiling.test.ts No test exercises the closeExpiredPause boundary at exactly Date.now() - pauseStartedAtMs === pauseWindowCapMs (the >= at line 241). The "bounds credit" test advances past it but doesn't pin the exact-boundary self-close.
6 nit high security·api-compat src/agent/subagent/pause-ceiling.ts:194-201 Two Date.now() reads in onEvent: pauseWindowMs(event) reads Date.now() internally, then line 201 reads Date.now() again for pauseStartedAtMs. Microscopic skew; credit stays bounded by pauseWindowCapMs so it's not a security issue, but anchoring both to one now would remove the inconsistency. Suggestion: const now = Date.now(); const windowMs = pauseWindowMs(event, now);.
7 nit high perf-observability src/agent/subagent/pause-ceiling.ts:81 The MIN_EXTENSION_GRANT_MS JSDoc frames the floor as preventing "millions of wakeups" but doesn't state the concrete worst case: 7_200_000 / 1_000 = 7200 re-arms across the cap's lifetime. Worth stating explicitly for future readers.

Conflicts: none (agents covered disjoint dimensions; no finding dedup needed).
Severity ordering: no invariant/severity-high tags apply — these are all mechanical (coverage/obs/diagnostic), sorted medium → low → nit within tier.

Spec-compliance (against stated intent)

Stated intent present → assessed. All five asserted guarantees are implemented:

  1. ✅ Provider-only signals extend; child events can't author pause events (verified by tracing event origination).
  2. ✅ Each grant bounded by reported window; closeExpiredPause self-closes unresumed parks (the resumed-is-OAuth-only bug was found and fixed pre-merge, commit 0053360, with a regression test).
  3. ✅ Absolute 2h cap; worst-case 45min + 2h — verified by the cap test + incident-replay test.
  4. ✅ Diagnosable error: describe() appends [pause-aware ceiling: …] with EXHAUSTED/expired/still-open state.
  5. ✅ No-pause identity byte-for-byte — pinned by an explicit test.

Unmet intent: none. Scope creep: none — the Infinity-vs-1ms guard fix and the 1s grant floor are both called out in the PR body as deliberate, not stealth changes.

Epistemic scope

  • Citations verified inline against reviewed ref 36395fee — all file-state citations matched content; none fabricated or diff-only. The B1 absence claim (no trace emits in pause-ceiling.ts) confirmed-absent (the one grep hit was a comment).
  • Stated intent: PR fix(subagent): credit provider-paused time against the wall-clock ceiling #797 title+body — spec-compliance assessed.
  • Not checked: I did not run the test suite (pnpm test) — this is a read-only review. The agent's epistemic note that the Infinity-retryAfterMs behavior change at the idle-watchdog boundary is untested at the integration layer stands; it is a documented fix and low-risk. I did not review the Telegram surface. I did not independently re-derive the provider retry-layer event-origination claims beyond confirming the grep results the security agent reported.

Merge decision: MERGE

No critical or high findings. The core invariant — finite, predictable, provider-only-extensible ceiling that fixes a real 1h49m-work-loss incident — is correctly implemented and well-tested, with anti-gaming verified and API changes fully additive. The five findings (two medium coverage/namability gaps, two low, three nit) are non-blocking follow-ups: add a paused→resumed→fresh-paused test, exercise the overlapping-signals credit end-to-end, and emit a subagent_pause_extension witness trace event so future incidents are reconstructable beyond the terminal error string. The post-synthesis /shadow-verify gate is not triggered (no critical/high).

Done — read-only review of PR #797 complete; no files were modified, committed, or pushed.


🤖 Posted by agent-afk /review --post github

…iling grant

PauseAwareCeiling granted bounded wall-clock extensions entirely
off-observation: the only record that a fork's budget had been moved was
the terminal timeout error string. Reconstructing "the child got N
extensions totaling Xms across this park" required the error alone —
exactly the forensics gap the next incident would need.

Add a pause_extension_granted session_phase witness event, emitted
fire-and-forget from the handle each time PauseAwareCeiling.onDeadline
grants a non-zero extension. The ceiling exposes an onGrant observer
(so it stays decoupled from the trace layer and pure/testable); the handle
wires a callback that emits the event carrying grantMs, totalGrantedMs,
remainingCapMs, grantCount, subagentId, and the pause description.

- trace: add pause_extension_granted to SessionPhaseName union + Zod
  enum + parity-test map (the union<->schema parity test enforces all three
  move together).
- pause-ceiling: new PauseExtensionGrantInfo interface + 3rd ctor param
  onGrant; invoked after grantedMs/grantCount update, wrapped so an
  observer fault can never strand the wait. No-pause path still grants
  nothing, never invokes onGrant (pinned by a new test).
- handle: pass the observer, closing over traceWriter + this.id; mirrors
  the idle_watchdog_fired emit pattern.

Tests: +2 unit tests (onGrant invoked with correct details on a grant;
onGrant NOT invoked when no pause observed). pause-ceiling 16, session-phase
48, idle-watchdog 20 (unchanged), handle 6 — all green.
@griffinwork40
griffinwork40 merged commit f6b33cf into main Aug 1, 2026
10 checks passed
@griffinwork40
griffinwork40 deleted the fix/pause-aware-subagent-ceiling branch August 1, 2026 00:50
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.

1 participant