Skip to content

feat(server): direct child threads resume on their own after a usage limit - #72

Merged
lukemaj merged 4 commits into
mainfrom
feat/71-resume-direct-children
Oct 1, 2026
Merged

lukemaj merged 4 commits into
mainfrom
feat/71-resume-direct-children

Conversation

@lukemaj

@lukemaj lukemaj commented Sep 29, 2026 •

Copy link
Copy Markdown
Contributor

What: A spawn_thread child that stops on a usage limit now gets one automatic "continue" once the limit lifts, and its parent gets one line saying so. Prism jobs are still left to Model Router.
Why: #63 skipped every sub.* thread, so a direct child waited for the user to relay "continue". The displayed resetsAt can also be hours later than when requests work again.
So what: The planner gets an independent review of the exact head and merges (the user approved the merge once CI and review pass).

Closes #71.

Rebased onto main after #101-#103 (2026-10-01)

  • Conflicts in handlers.ts: kept both imports (Deferred, Duration), and added the reply-waiter step to the bridge loop fix(server): report-back reaches the parent exactly once across a restart (#94) #103 introduced (subscribe, restart catch-up, then drain). Every event still passes through bridge, usage-limit resume, then reply waiters, in that order. The rest of feat(server): direct child threads resume on their own after a usage limit #72 merged unchanged: the resume watcher, the Model Router job-thread check (it stays until Remove the Model Router runner hooks from Chromeria #97) and the parent notice.
  • Conflicts in docs/fork-features.md: kept Report-back reaches the parent exactly once across a server restart #94/interrupt_thread: stop a thread's running turn #95 and added Resume direct-spawn child threads after a usage limit #71 to the child-threads entry. The Issue link now points at toolboxmd/chromeria/issues/71, where Resume direct-spawn child threads after a usage limit #71 lives. childUsageLimitResume.test.ts sits next to handlers.testFixtures.ts.
  • New: interrupt_thread cancels a pending auto-continue. A dispatcher interrupts a limit-hit child before replacing it (that is what the tool description tells it to do). That child is already failed, so there is no turn to stop, but the pending continue would still have restarted it next to its replacement. interrupt_thread now interrupts that pending resume and marks the stopped turn, so a later limit error for that turn schedules none. The tool description says so. A running child that interrupt_thread stops ends interrupted, not error, so the watcher never sees it, and the marked turn covers a provider that reports an error for it anyway.
  • Review fixes on dc908d278:
    • Only usage-limit errors are continued. A turn error schedules a continue only when its own lastError reads as a usage limit (isUsageLimitError: the adapters' "Claude usage limit reached", "Claude stopped: a usage limit blocked the request", "Codex usage limit reached", "Grok usage limit reached"). The exhausted-window check still applies on top. An unrelated runtime error while a window is exhausted schedules nothing.
    • Resume only once a reading shows the limit lifted (review on b201a83b4). At a future reset plus the margin, the watcher re-reads the usage windows and resumes only when none is exhausted. A window still at 100 % is re-read every 5 minutes, and a reply on the instance can still resume it early when allowed.
    • A past resetsAt no longer ends the watch. An exhausted window whose reset time has already passed is a stale reading. The watcher keeps waiting for a reply on the instance (when an early resume is allowed) and re-reads the windows every 5 minutes (RESET_RECHECK_MS). It resumes at a future reset or once no window is exhausted, never while the limit still shows, so a stale reading cannot loop. A provider with no exhausted window at all is still not-limited.
  • Report-back after a continue: the failed turn is reported as failed (no report), and the continued turn's reply is reported once, also after a restart (fix(server): report-back reaches the parent exactly once across a restart (#94) #103's restore and catch-up do not resend it).

How direct children are told apart from Prism jobs

Model Router opens every job thread with a first user message [model-router job <id> <kind> on route <route>; planner thread <id>]. The web Agents panel already groups "Prism spawns" by this tag (apps/web/src/components/AgentThreadTree.logic.ts). The server now reads the same tag (isRouterJobMessage, usageLimitResume.ts).

I checked the live DB read-only on 2026-09-29: 42 sub.* threads carry the tag. None of them still has a model-router … title, because titles get regenerated. So the title regex that staleTurnMonitor.ts uses would match no live job, and it can't be the marker. Children spawned by a planner with a role (for example …reviewer-…) are direct spawn_thread children, so they get resumed.

Behaviour

  • Whichever comes first resumes the thread: (a) resetsAt plus 1 minute, or (b) the first finished assistant reply that any thread gets from the same provider instance after the 5 s settle delay. The settle delay filters out replies that were already in flight when the limit hit.
  • Both triggers now apply to every thread the watcher handles, top-level threads included. It is one code path, and the pessimistic resetsAt affects top-level threads the same way.
  • There is no retry loop. If an early resume fails again (for example on a limit that only this model has), the next resume waits for the reset. The thread's own next reply clears that.
  • Stands down as before if the thread got a new turn first or was archived. Non-limit errors (provider not exhausted) are never resumed.
  • The parent gets one [Subagent <title> (thread <id>) resumed automatically: …] message per resume.

Proof

Node v24.13.1, at head 5bc0e4c12 (on origin/main 5f2dae583).

  • usageLimitResume.test.ts and childUsageLimitResume.test.ts: Tests 17 passed (17), three runs in a row.
    • New at 5bc0e4c12: sends nothing at the reset while the window still shows exhausted. The window keeps showing 100 % past its reset; after three rechecks there is still no continue. Failing-before: with the watcher from b201a83b4 it fails with expected [ { threadId: 'thread-user', …(2) } ] to deeply equal [] (it resumed at the reset anyway). resumes once, a minute after the limit resets and the spent-early-resume case now have the window drop at its reset and resume at 21 minutes after re-reading it. The engine-level stub shows the window lifted after its reset, so the continued-child test resumes on a fresh reading.
    • Added at b201a83b4:
    • never continues a turn whose error is not a usage limit, even while a window is exhausted (real engine): the child fails with "Provider process exited with code 1." while the session window is exhausted; after a reply on the instance and four hours, no continue and no parent notice. Failing-before: without the error check it fails with expected [ Array(1) ] to deeply equal [].
    • keeps watching and resumes at the next reply on the instance (exhausted window with a reset 10 minutes in the past): resumes lifted at the 3-minute reply. Failing-before: with the old watcher it fails with expected 'not-limited' to equal 'lifted'.
    • sends nothing while the window still shows exhausted, then resumes once it lifts (early resume spent): nothing is sent for two hours of replies elsewhere; once the windows stop being exhausted, it resumes once at the next recheck. Failing-before: expected 'not-limited' to equal 'reset'.
    • isUsageLimitError accepts the four adapter messages and rejects other errors.
  • Earlier in this PR, still passing: an interrupted limit-hit child is never continued (failing-before without the cancel: expected [ Array(1) ] to deeply equal []); a continued child's reply is reported once, also after a restart; a direct child resumes at the first reply on its instance and the Prism job is left alone.
  • vp test run src/mcp/ src/orchestration/Layers/ProviderCommandReactor.test.ts src/server.test.ts src/prism (apps/server): Test Files 28 passed (28), Tests 472 passed (472)
  • tsc --noEmit (apps/server): exit 0. vp fmt --check and vp lint --report-unused-disable-directives on the changed files: clean. vp run knip:check: clean. bash scripts/fork-check.sh --upstream upstream: fork-features: OK (19 features). fork-check: OK.

Not done: no resume was observed on the running app, because it was not restarted or redeployed.

Elon record

Requirements and who asked: The user (2026-09-29, #71) never wants to relay "continue" to a direct worker after a limit. The planner (2026-10-01): an interrupted child must not be auto-continued, there must be no duplicate report after a continue, and (independent review) only usage-limit errors are continued, a past reset time must not end the watch, and a reset resumes only once the window shows it lifted. Resume at the earlier of resetsAt + about 1 minute or the first success on the same provider instance (user's brief). Prism jobs stay with Model Router (#61).
Deleted: The title-based job marker (it matches no live job). A new layer, server.ts edit, contracts change, UI, settings and restart persistence. A separate code path for top-level threads. Per-model matching for the early trigger: the loop guard covers per-model limits without it.
Bottleneck: After a limit, a direct child idles until a human notices and relays "continue". The displayed reset (15:40Z) was 2.5 h behind the first working request on the same instance (13:10:41Z).
Checked myself: Read the adapters' usage-limit error texts (ClaudeAdapter.ts, codexUsageLimits.ts, XAiAcpExtension.ts) for the classifier; reverted each review fix to see its test fail. After the rebase, reproduced the interrupt-then-auto-continue double writer as a test (red without the cancel, green with it) and the continue-then-report path across a restart. Earlier: live DB (read-only): the child …01b1a94bbcfd failed at 13:10:29.552Z on claudeAgent / claude-opus-5-5. Its parent on the same instance and model finished replies at 13:10:30Z and 13:10:41Z. The first user message tag marks 42 of 42 router threads, and the title marks 0. I ran the tests, the mutation checks, the typecheck, lint and fork-check.sh shown above.

Done by Claude Opus 5.5 in Claude Code.

🤖 Generated with Claude Code

@github-actions github-actions Bot added the vouch:trusted PR author is trusted by repo permissions or the VOUCHED list. label Sep 29, 2026
@github-actions

github-actions Bot commented Sep 29, 2026 •

Copy link
Copy Markdown
Contributor

Thread transfer impact

✅ Thread transfer remains within every enforced ceiling.

Provider Metric Main baseline This PR Impact PR ceiling
Codex Total thread wire 13.5 KiB 13.5 KiB −6 B (−0.0%) 15.1 KiB ✅
Codex Thread snapshot wire 7.1 KiB 7.1 KiB −12 B (−0.2%) 7.3 KiB ✅
Codex Live turn WebSocket wire 6.4 KiB 6.5 KiB +6 B (+0.1%) 7.8 KiB ✅
Codex Live turn WebSocket decoded 56.2 KiB 56.2 KiB 0 B (0.0%) 66.4 KiB ✅
Codex Live turn messages 9 9 0 (0.0%) 21 ✅
Claude Total thread wire 13.5 KiB 13.5 KiB +2 B (+0.0%) 15.1 KiB ✅
Claude Thread snapshot wire 7.1 KiB 7.1 KiB +3 B (+0.0%) 7.3 KiB ✅
Claude Live turn WebSocket wire 6.5 KiB 6.4 KiB −1 B (−0.0%) 7.8 KiB ✅
Claude Live turn WebSocket decoded 57.0 KiB 57.0 KiB 0 B (0.0%) 66.4 KiB ✅
Claude Live turn messages 9 9 0 (0.0%) 21 ✅

Baseline: 5f2dae5 · PR result: 5bc0e4c · Source CI: success

Scenario and decoded snapshot size

10 historical turns, 5 command tools per turn, 878.9 KiB retained MCP result per historical turn, and a 1.05 MiB retained result in the measured turn.

  • Codex decoded thread snapshot: 113.9 KiB
  • Claude decoded thread snapshot: 114.6 KiB

Updated in place by a trusted workflow. PR artifacts are strictly validated and never executed.

@lukemaj

lukemaj commented Oct 1, 2026

Copy link
Copy Markdown
Contributor Author

Merge plan (user approved 2026-10-01): after the #92 stack (#101 merged, #102 and #103 in review) lands, rebase this onto main (it now conflicts), rerun CI, get an independent review on the exact head, then squash-merge and stage the build. The check that tells Model Router job threads apart from direct spawns stays until #97 removes the runner hooks; #97 deletes it here too.

@lukemaj

lukemaj commented Oct 1, 2026

Copy link
Copy Markdown
Contributor Author

Independent review of dc908d2: request changes

  1. apps/server/src/mcp/toolkits/threads/handlers.ts:579 schedules a resume for every thread.session-set error without checking that the failed turn's error was a usage-limit error. If the provider snapshot still shows an exhausted window while a child fails for an unrelated provider/runtime error, it will also be automatically continued after that window resets or another thread replies. This violates Resume direct-spawn child threads after a usage limit #71's requirement that non-limit errors are never resumed.

  2. apps/server/src/mcp/toolkits/threads/usageLimitResume.ts:45 returns no reset when an exhausted window's resetsAt is already past; line 101 then ends the watcher without subscribing for the first successful reply. If the provider still reports 100% with a stale/past reset timestamp when the limit error is handled, a later successful reply on that same instance proves the limit lifted but this child is never continued.

lukemaj and others added 3 commits October 1, 2026 16:06
…limit (#71)

A spawn_thread child that stopped on a usage limit waited until the user
typed "continue" to the planner and the planner relayed it: #63 skipped
every sub.* thread to leave Prism jobs to Model Router.

The resume watcher now skips only Prism job threads, recognized by Model
Router's "[model-router job ...]" first user message (titles get
regenerated, so they are no marker). A resume fires at the earlier of the
displayed reset plus a minute, or the first reply any thread gets from the
same provider instance after the settle delay: on 2026-09-29 the displayed
reset was 2.5 hours later than when requests worked again. A thread whose
early resume fails again waits for the reset, so a per-model limit cannot
turn replies elsewhere into a retry loop. A resumed child's parent gets
one line saying so.

Done by Claude Opus 5.5 in Claude Code.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
A dispatcher interrupts a limit-hit child before replacing it, but that
child is already failed, so interrupt_thread had nothing to stop while the
pending automatic continue would still restart it next to its replacement.
interrupt_thread now cancels that pending continue and marks the stopped
turn so a later limit error for it schedules none.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…atching (#71)

Only a turn whose own error reads as a usage limit schedules a continue;
an unrelated runtime error while a usage window is exhausted is left
alone. An exhausted window whose reset time already passed no longer ends
the watch: it keeps waiting for a reply on the instance and re-reads the
windows every 5 minutes, resuming at a future reset or once none is
exhausted, so a stale reading cannot turn into a continue loop.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
@lukemaj
lukemaj force-pushed the feat/71-resume-direct-children branch from 0641c6f to b201a83 Compare October 1, 2026 14:08
@lukemaj

lukemaj commented Oct 1, 2026

Copy link
Copy Markdown
Contributor Author

Independent review of b201a83: request changes

  1. apps/server/src/mcp/toolkits/threads/usageLimitResume.ts:128 trusts a future resetsAt without re-reading the window after the wait. If the provider still reports usedPercent >= 100 after that timestamp plus the one-minute margin, the watcher resumes anyway, contrary to the stated “never while the limit still shows” behavior. The existing “resumes once, a minute after the limit resets” test keeps the provider at 100% and confirms this path; re-read and stand down or keep watching if it remains exhausted.

The new error-text gate matches the current Claude, Codex, and Grok adapter messages. OpenCode passes through its server-provided data.message without normalization, so other wording can miss auto-continue; that is a conservative omission (no continue), and the exhausted-window check also guards the recognized text.

)

At a future reset plus the margin the watcher resumed without reading the
usage windows again, so a window still at 100 % got a continue anyway. It
now re-reads them and resumes only when none is exhausted; otherwise it
keeps re-reading every 5 minutes, and a reply on the instance can still
resume it early.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
@lukemaj

lukemaj commented Oct 1, 2026

Copy link
Copy Markdown
Contributor Author

Independent review of 5bc0e4c: approve

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

size:L vouch:trusted PR author is trusted by repo permissions or the VOUCHED list.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Resume direct-spawn child threads after a usage limit

1 participant