Repository navigation
feat: controls for unattended workers — usage-limit detection, end-attempt budgets, wall-time limits - #425
Merged
Conversation
rsharath
approved these changes
Oct 25, 2026
…tempt budgets, extra env, wall-time deadlines
Session-level primitives a loop controller will drive (loop design §6, §8,
§12.11). Each stands alone; default behavior is unchanged.
- providers/usage-limit.ts: classifyUsageLimit() tells a usage limit or a
rate limit (with its reset time) from an ordinary failure, keyed on the
shapes Claude Code, Codex, gemini-cli, Gemini, OpenAI and qwen-code
actually emit. Session records it as lastTurnInfraError on every failed
turn and clears it on the next success; Claude's rate_limit_event now
surfaces as a provider event so the exact reset instant is kept.
- onBudgetExhausted: "end_attempt" denies the call that finds the
autonomous budget spent, interrupts the turn, settles idle and sets
budgetExhausted, instead of parking at waiting_approval ("park" stays
the default).
- extraEnv: caller env for every process-backed backend, merged last but
never over a credential, vendor home, HOME/PATH, proxy/TLS or
process-loading variable (explicit denylist; the create fails on one).
- supervisor.ts: supervise(session, { wallMs, graceMs }) interrupts on
expiry regardless of tool state, then terminates the backend
(Session.terminateBackend, sharing stall recovery's hard stop) if the
session has not settled within the grace period.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…ini-cli's "Access resets at" - codexErrorDetail() lifts the v2 TurnError codexErrorInfo variant (usageLimitExceeded, rateLimitExceeded, httpConnectionFailed's status) and its resets_at / resetsAt onto the error event's new `detail`; the classifier reads the structured reset ahead of any prose time. describeCodexError's display text is unchanged. - gemini-cli's "Access resets at 3:30 PM PDT." (getResetTimeMessage) is parsed in host time; the short zone name is not a zone id and is ignored. - extraEnv's doc states it stays in memory and the loop controller re-supplies it when it re-arms a worker after a restart. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
1. A Claude rate_limit "rejected" that overage is carrying no longer counts,
and the rate-limit-event fallback never relabels a failure another
recognizer explains (prompt too long, the spend ceiling).
2. Claude's result text on a success-subtype is_error turn is carried as
NormalizedTurnResult.resultText; it and the turn's assistant text are
read only with CLI-shaped rules (classifyCliNotice: the SDK's
USAGE_LIMIT_ERROR_PREFIXES or a leading "API Error:"). The generic
openers now need the limit noun within four words, so model replies
such as "You've reached your goal: the rate limit test passes" stay null.
3. lastTurnInfraError is cleared when a turn starts.
4. Under end_attempt, a spent budget keeps denying every tool call —
later prompts and backend-started turns included — until a new
autonomous budget; ending the attempt also stops background tasks
where the backend can (Claude: stopTask).
5. #sendInner resets per-turn controls only when a turn actually starts,
not for a message that joins the running one.
6. extraEnv names must be ^[A-Z][A-Z0-9_]{0,63}$; the denylist adds
tool-config, pager/editor, shell-startup, temp-dir, TZ, RUST_* knobs and
the GIT_/NPM_CONFIG_/PYTHON/RUBY/PERL/JAVA/JDK/CLOUDSDK_/LC_ namespaces.
Documented as daemon-controlled only.
7. SessionProvider.enforcesToolBudget (false for codex, whose autonomous
mode is approvalPolicy "never") and Session.toolBudgetEnforced;
end_attempt on such a backend logs a warning.
8. interrupt() leaves alone a run that took over while it awaited the
backend; hard-stop teardowns are bounded (10s default, overridable on
terminateBackend and supervise).
9. terminateBackend snapshots the process tree under the backend's own
pids before the teardown and SIGKILLs what outlives it (process-tree.ts).
Measured on the real Claude CLI: tool shells are reaped on abort and
interrupt, survive a SIGKILL of the CLI (each runs in its own process
group), and self-detached commands survive everything.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
1. A person can take over a spent end_attempt worker: any explicit setMode from a non-system sender (checked before the same-mode early return) or a new turn a person starts lifts budgetExhausted. Session info carries budgetExhausted, and the denial says how to re-arm. 2. killSurvivors kills, from one fresh ps table, every confirmed survivor and its whole current tree — including children spawned after the snapshot. 3. Process identity adds the start time (Linux /proc/<pid>/stat field 22, macOS ps lstart); ps runs with -ww and without COLUMNS. 4. The Claude CLI pid is found by exact argv tokens (--resume=<id>, --session-id=<id>, and their two-token spelling), not a substring. 5. The scheduled end-attempt interrupt is bound to the turn and background tasks at denial time and no-ops if the turn moved on; tasks whose stop failed stay in the background set. 6. classifyCliNotice also reads the reply's last block, and the session passes the accumulator's last text block. 7. extraEnv denylist adds RUSTC_WRAPPER, RUSTFLAGS, CARGO_*, GOFLAGS, REQUESTS_CA_BUNDLE, CURL_CA_BUNDLE, SSH_AUTH_SOCK, SHELLOPTS, BASHOPTS, PS4. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
saucam
force-pushed
the
feat/loops-worker-controls
branch
from
October 25, 2026 02:55
782c65b to
0771905
Compare
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
What this adds
Session-level controls an unattended worker needs, so a loop can run agents for hours without a person watching (docs/loop-engineering-design.md §6, §8, §12.11). By default every session behaves exactly as before; a controller opts in.
resets_at), gemini-cli and qwen-code. It ignores replies that merely talk about rate limits, and ignores rejected rate-limit events while the account is on overage.onBudgetExhausted: "end_attempt", the next tool call is denied, the turn ends, and background tasks are stopped. The session ends idle rather than waiting on an approval. A person taking over the session clears the lock by setting a mode or sending a message. Codex's autonomous mode skips approvals, so it reports that it can't enforce the budget, and the controller falls back to wall-time limits.supervise(session, { wallMs })interrupts at the deadline and, if the backend doesn't settle, kills it, including tool processes left behind. The process kill matches pid, command line and start time, so a reused pid is never touched. Self-detached commands are outside its reach, as documented.extraEnvreaches every process-backed backend. Names are validated, and an extensive denylist stops it overriding credentials or injecting code execution (BASH_ENV,GIT_*,RIPGREP_CONFIG_PATH, …).No loop uses these yet; the controller lands separately.
Review
Two independent correctness and security audit rounds, all findings folded in.
Full suite: 3874 pass / 0 fail.
For agents: implementation
providers/usage-limit.ts:classifyUsageLimit,classifyCliNotice(CLI-shaped prefixes only, last-block aware), and reset parsing (epoch, relative, wall-clock with zone, DST).Session.lastTurnInfraError, cleared at turn start.rate_limit_eventis surfaced as a provider event and is overage-aware. ClauderesultTextis carried on is_error success turns.codexErrorDetailfor structured resets.SessionOptions.onBudgetExhausted"park" (default) or "end_attempt".budgetExhaustedlock is cleared by a non-systemsetModeor prompt.stopBackgroundTasks.SessionProvider.enforcesToolBudget(false on codex) andSession.toolBudgetEnforced.SessionOptions.extraEnv, names matching^[A-Z][A-Z0-9_]{0,63}$.providers/env.ts; the constructor throws on a refused key, and providers re-filter.supervisor.ts:supervise().Session.terminateBackend()and the shared#hardStop, with a 10 s teardown bound.process-tree.ts: snapshot before teardown, thenkillSurvivorsover a freshps -wwtable. It matches pid, args and start time (/procstat field 22 orlstart) and kills descendants.🤖 Generated with Claude Code