Skip to content

feat: controls for unattended workers — usage-limit detection, end-attempt budgets, wall-time limits - #425

Merged
saucam merged 4 commits into
mainfrom
feat/loops-worker-controls
Oct 25, 2026
Merged

saucam merged 4 commits into
mainfrom
feat/loops-worker-controls

Conversation

@saucam

@saucam saucam commented Oct 25, 2026

Copy link
Copy Markdown
Collaborator

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.

  • Usage limits are recognized. When a turn fails because a subscription or API allowance is spent, the session records it as an infrastructure error with its reset time where the backend gives one. A loop can then wait for the reset instead of spending an attempt. The classifier matches the real strings from the Claude Agent SDK and CLI, Codex (including its structured 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.
  • An attempt can end when its tool budget is spent. With 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.
  • Wall-time limits that hold. 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.
  • Per-session extra environment (daemon-controlled). extraEnv reaches 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.

  • Round 1 fixed: usage limits misread from overage events and from short assistant replies; stale infra errors; budget denial bypassed by backend-started or joined turns; denylist gaps that allowed code execution; codex budget enforcement; races in interrupt and teardown; processes surviving a backend kill.
  • Round 2 fixed: lockout on takeover; children spawned after the kill snapshot; pid identity; exact Claude pid matching; turn-bound interrupts.

Full suite: 3874 pass / 0 fail.

For agents: implementation
  • Usage limits:
    • 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.
    • The Claude rate_limit_event is surfaced as a provider event and is overage-aware. Claude resultText is carried on is_error success turns.
    • codexErrorDetail for structured resets.
  • Tool budget:
    • SessionOptions.onBudgetExhausted "park" (default) or "end_attempt".
    • The budgetExhausted lock is cleared by a non-system setMode or prompt.
    • The scheduled interrupt is bound to its turn, with stopBackgroundTasks.
    • SessionProvider.enforcesToolBudget (false on codex) and Session.toolBudgetEnforced.
  • Extra env:
    • SessionOptions.extraEnv, names matching ^[A-Z][A-Z0-9_]{0,63}$.
    • Denylist in providers/env.ts; the constructor throws on a refused key, and providers re-filter.
  • Wall time:
    • supervisor.ts: supervise().
    • Session.terminateBackend() and the shared #hardStop, with a 10 s teardown bound.
    • process-tree.ts: snapshot before teardown, then killSurvivors over a fresh ps -ww table. It matches pid, args and start time (/proc stat field 22 or lstart) and kills descendants.
    • Claude pid found by exact argv tokens.

🤖 Generated with Claude Code

saucam and others added 4 commits October 25, 2026 10:48
…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
saucam force-pushed the feat/loops-worker-controls branch from 782c65b to 0771905 Compare October 25, 2026 02:55
@saucam
saucam merged commit 8f126af into main Oct 25, 2026
4 checks passed
@saucam
saucam deleted the feat/loops-worker-controls branch October 25, 2026 03:02
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