Repository navigation
fix(run): forward wrapper signals, log folded agent keys, opt-in --max-tokens budget - #1655
RaghwendraSingh wants to merge 3 commits into
Conversation
The wrapper ran the compiled binary with `spawnSync`, which installs no signal handling: `kill <wrapper pid>` ended the wrapper and left the binary running as an orphan (observed: a `run` kept going past its budget after its driver killed the wrapper). - Spawn the binary asynchronously and forward SIGTERM to it. SIGINT and SIGHUP are forwarded only when the wrapper has no controlling terminal: a terminal delivers those to the whole foreground process group, so the binary already has them, and a second SIGINT makes `run` skip its shutdown flush. - Exit semantics are unchanged: the child's exit code is propagated; a child killed by a signal exits 128+signal with the same stderr line; a spawn error exits 1 with its message. - The binary is resolved once (env override, cached binary, then the package search), since `run()` no longer exits synchronously. - Tests in `test/bin/` drive the wrapper with a fake binary that records the signal it receives. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
`ConfigAgentV1.normalize` copies every top-level key it does not interpret into `options`, so a typo such as `budget` for `steps` was accepted without a word. The fold itself is relied on and stays: `AgentConfig` is published with additional properties allowed, `run` reads an agent's `audience` from `options`, and legacy markdown agents carry provider request options as top-level keys that `migrateAgent` moves into `request.body`. - core: export `knownKeys` and `unknownKeys()` next to the schema so loaders can name what the fold took. - opencode: the markdown agent and mode loaders, and the JSON config loader for its `agent`/`mode` sections, log a WARN naming the agent, the source, the folded keys and the known keys. - Tests: a known key is accepted as itself; an unknown key is folded and reported; keys nested under `options` keep working and are not reported. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…essions Opt-in; without the flag nothing changes. The budget is the billed weight of every `step-finish` of the root session and of every session descended from it (task children and their children): input + output + cache writes + 0.1 × cache reads. A child is linked through `session.created`/`session.updated` or through the parent's task tool part, whichever arrives first. Reaching the limit takes the hard time-deadline path: the same server abort, the same one-shot `REPLY_AFTER_BUDGET` finalization allowance, the same `budget_exhausted` event (`limit_tokens`, `tokens_used`) and the same exit status: 0 when the finalization answers, 1 otherwise. `termination` and the trace carry `max_tokens`, `tokens_used` and `token_budget_exhausted` only when a token budget is set. A finalization turn is never cut off by the budget it is closing out. - `run-token-budget.ts` is the pure accounting (fork-only module). - `budget-finalization.test.ts`: child-session usage counts, exhaustion is reported once, the reason is reported, no flag means no change; the tool-omission subprocess test gains a token variant. - `run --help` snapshot updated for the new flag. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
|
Important Draft PR not reviewedDraft PRs are not automatically reviewed by default.
To automatically review draft PRs, update your CodeRabbit configuration: reviews:
auto_review:
drafts: true
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. |
|
👋 This PR was automatically closed by our quality checks. Common reasons:
If you believe this was a mistake, please open an issue explaining your intended contribution and a maintainer will help you. |
1 similar comment
|
👋 This PR was automatically closed by our quality checks. Common reasons:
If you believe this was a mistake, please open an issue explaining your intended contribution and a maintainer will help you. |
|
Independent re-verification (2026-11-04, fresh session, same head Baseline on a clean
Difference in the failure set: none. |
Run-level proof at e8a47a7Three local checks plus one real-gateway run (approved cap: 200,000 billable tokens, enforced by an external meter that hard-kills the run). "main" = A. Wrapper forwards SIGTERM (
|
| main | fix | |
|---|---|---|
| child alive 2 s after TERM to wrapper | yes (orphan) | no |
child received TERM (got-TERM logged) |
no | yes |
| wrapper exit code | 143 (wrapper killed by the signal) | 143 (child's exit status) |
passthrough ALTIMATE_CODE_BIN_PATH=/bin/sh … -c 'exit 7' |
7 | 7 |
Note: the sleeper waits with sleep 300 & wait $!. With a plain foreground sleep 300, bash holds the trap until sleep returns, so the fix case seems to hang for 300 s even though the signal was delivered.
B. Unknown agent key is reported (budget instead of steps)
Project config: agent capped (mode: primary, steps: 3, tools {"*": false, "bash": true}) plus "budget": 1000. Command: debug config --print-logs --log-level WARN, run with that project as the cwd (debug config has no --dir).
main, stderr: (empty). The key is folded into options.budget without a message.
fix, stderr:
[WARN] service=default agent config: keys outside the agent schema were folded into options agent=capped source=<home>/pass16/project-b/.altimate-code/altimate-code.json keys=budget known=name,model,variant,prompt,description,temperature,top_p,mode,hidden,color,steps,maxSteps,options,permission,disable,tools
The resolved config is the same on both builds (budget still lands in options), so behavior does not change. Only the warning is new.
C. --max-tokens 60000 across a task child: real gateway, one run
Agents: parent (bash + task, task limited to worker) and subagent worker (bash only). Task: delegate echo w1…echo w8 to worker, then echo step 1…echo step 40, then reply DONE. Model altimate-backend/altimate-default.
Result: the budget never fired. The run finished at 30,149 billed tokens, below the 60,000 limit, so this run does not exercise the stop/finalization path. The model batched the 40 echo calls as parallel tool calls: 41 tool calls in only 6 root steps, which cost far less than one call per step.
What the run does show is that the child's tokens are counted in the run's total:
| session | steps | input | output | cache read | cache write | billed |
|---|---|---|---|---|---|---|
root (parent) |
6 | 12,371 | 3,166 | 46,864 | 0 | 20,223 |
child (worker, via task) |
9 | 5,323 | 731 | 38,714 | 0 | 9,925 |
| total (local DB) | 15 | 30,149 |
termination event from --format json:
{"type":"termination","why_model_stopped":"explicit-done","why_harness_stopped":"none","done_reason":"explicit_done","elapsed_s":44.7,"time_budget_deadline":"none","max_tokens":60000,"tokens_used":30149,"token_budget_exhausted":false}tokens_used (30,149) is exactly root + child from the session store. The root alone was 20,223. No budget_exhausted event, final text DONE, exit code 0. The backstop meter matched (30,148 with integer rounding) and did not trigger.
Total gateway spend for this proof: 30,149 billable tokens (one run).
Still unproven at run level: budget_exhausted firing, a single finalization, and the exit code after the limit is reached. The unit tests in test/cli/run/budget-finalization.test.ts cover these. Proving them on the gateway needs a second run with a lower --max-tokens (e.g. 15000) or a task that cannot be parallelized. That run was not done here because only one gateway run was approved.
Second run: the token budget stops the runOne more real-gateway run at Changes from run 1: Result: the limit fired. The run crossed 15,000 at the end of the root step that held the
Events from
Cosmetic, not blocking: a token stop goes through the hard-deadline abort (by design, Gateway spend for this run: 21,888 billable tokens. Total for both proof runs: 52,037. |
Issue for this PR
Closes #1653
Type of change
What does this PR do?
Three focused commits for headless
rununder a driver that enforces a cap.1.
fix(bin): forward termination signals to the bun child—bin/altimate-coderan the binary withspawnSync, sokill <wrapper pid>ended the wrapper and left the binary running as an orphan (observed: a run kept going past its budget). The wrapper now spawns asynchronously and forwards SIGTERM. SIGINT and SIGHUP are forwarded only when the wrapper has no controlling terminal (/dev/ttycannot be opened): a terminal delivers those to the whole foreground process group, so the binary already has them, and a second SIGINT makesrunskip its 2 s shutdown flush (#1332). The wrapper keeps waiting for the child in every case, so the exit semantics are unchanged: child exit code propagated; child killed by a signal →128+signalwith the samealtimate-code terminated by <signal>line; spawn failure → exit 1 with the error message. The binary is now resolved once (env override, cached binary, then the package search) becauserun()no longer exits synchronously. NODE_PATH / ALTIMATE_BIN_DIR logic is untouched. One cosmetic difference: Node's own message for a missing binary readsspawn … ENOENTinstead ofspawnSync … ENOENT.2.
fix(config): log the agent config keys that are folded into options—ConfigAgentV1.normalizecopies every top-level key it does not interpret intooptions, sobudget: 100(meant assteps) was accepted silently. Branch taken: WARN and keep the fold, because the fold is relied on:AgentConfigis published inpackages/sdk/openapi.jsonwithadditionalProperties(the schema isStructWithRest(Record<string, Any>)), so rejecting would change a documented contract.run.tsreads an agent'saudiencefromentry.options?.audience; a top-levelaudience: executivein an agent file only works through the fold.ConfigAgentV1inpackages/core/src/config/plugin/agent.tswhenever any frontmatter key is outside the v2 set, andmigrateAgentmovesoptionsintorequest.body: top-level provider options (reasoningEffort: high) reach the request through the fold.git ls-files | grep -E '(agent|agents|mode|modes)/.*\.md$'→ none) or JSON config (git grep -l '"agent"\s*:' -- '*.json'→ onlyopenapi.json) uses an unknown key, and every inline agent in tests uses known keys,options, or v2 keys (request/system/permissions/disabled); so nothing shipped depends on it, but the three runtime paths above do.Core exports
knownKeysandunknownKeys()beside the schema; the markdown agent/mode loaders and the JSON config loader (agentandmodesections) logagent config: keys outside the agent schema were folded into optionswith the agent, source, folded keys and known keys throughLog.Default.warn(file log /--print-logs, never the TUI).3.
feat(run): optional --max-tokens budget across the run and its task sessions— opt-in; without the flag nothing changes (no new fields interminationor the trace). Budget = input + output + cache writes + 0.1 × cache reads, summed over everystep-finishof the root session and of every session descended from it (linked throughsession.created/session.updatedparentIDor the parent's task tool part, whichever arrives first). Reaching it takes the hard time-deadline path: the same server abort (x-altimate-run-budget: hard-deadline), the same one-shottakeBudgetFinalizationAllowance/REPLY_AFTER_BUDGETfinalization, the samebudget_exhaustedevent ({ limit_tokens, tokens_used }next tolimit_seconds), the samewhy_harness_stopped: "budget-exhausted"and the same exit status (0 when the finalization answers, 1 otherwise).terminationand the trace gainmax_tokens,tokens_used,token_budget_exhaustedonly when the flag is set. A finalization turn is never cut off by the budget it is closing out. The accounting lives in the fork-onlyrun-token-budget.ts. The finalization request carriesfinalization-no-downgrade(there was no soft phase that lowered effort), which still runs tool-free.Interactive (TUI) mode is untouched by all three.
How did you verify your code works?
No-regression protocol: the touched suites were run on the clean worktree at
origin/main(2d59604e66) and again after the three commits; the failing sets are compared below.Suites:
packages/opencode:test/cli/,test/session/prompt.test.ts,test/bin/,test/config/config.test.ts,test/agent/agent.test.ts;packages/core:test/config/agent.test.ts,test/agent.test.ts,test/config/config.test.ts(+ the newtest/config/agent-v1-keys.test.tsafter).Failing before (clean
origin/main):opencode run: JSON output artifacts > --time-budget cuts off a running tool and finalizes once (no answer)opencode run: JSON output artifacts > --time-budget delivers one soft directive at the next slow-tool boundaryFailing after (this branch):
Run 1 (all suites;
test/cli/1192 pass / 3 fail / 1200,prompt.test.ts69 pass / 0 fail,test/bin/6 pass,config.test.ts+agent.test.ts174 pass, core 33 pass):opencode run: budget finalization subprocess > a silent budget finalization produces an honest status once and exits 1opencode run: JSON output artifacts > --time-budget cuts off a running tool and finalizes once (no answer)opencode run: JSON output artifacts > --time-budget delivers one soft directive at the next slow-tool boundaryRun 2 (
test/cli/again, 1192 pass / 3 fail):opencode run: JSON output artifacts > --time-budget cuts off a running tool and finalizes once (no answer)opencode run: JSON output artifacts > --time-budget delivers one soft directive at the next slow-tool boundaryopencode run: JSON output artifacts > time exhaustion during a silent-turn followup still gets one budget finalizationDiff of the failing sets: the two known
--time-budgetcases fromjson-output.test.tsfail before and after. Each after-run added one different timing-sensitive subprocess test that is not in the before set and does not run through the new code (neither passes--max-tokens):a silent budget finalization produces an honest status once and exits 1— a 60 s subprocess timeout (expected exit 1, got -1 after 60011ms, no stdout) while 96 files ran concurrently (that run took 211 s vs 161 s before). In isolation on this branch: 3/3 pass; the wholebudget-finalization.test.ts: 62/62 pass.time exhaustion during a silent-turn followup still gets one budget finalization—--time-budget 2counts process startup, so the second request is sometimes the finalization instead of the silent-turn reply. Measured in isolation: this branch 10/12 pass, cleanorigin/main11/12 pass (and fix(session): keep the cached system prompt prefix stable across steps #1631 reported it failing 2/8 onmain). The onerun.tschange on that path istimeExhausted()also readingtokenBudget?.exhausted, which isundefinedwithout the flag.So the sets are not byte-identical across runs, but the only differences are these two load/startup-timing flakes, each of which passes in isolation and one of which fails at the same rate on
main. Fullbun testoutput of all three runs was kept locally and can be attached on request.test/bin/altimate-code-wrapper.test.ts(SIGTERM forwarded + child exit code propagated; SIGINT and SIGHUP forwarded when the wrapper has no controlling terminal, viadetached; normal exit code; child killed by SIGKILL → 137 with the same stderr line; missing binary → 1).packages/core/test/config/agent-v1-keys.test.tsand three cases intest/config/config.test.ts(known key accepted, unknown key folded and named, keys underoptionsnot reported).budget-finalization.test.ts: the 46 existing cases are unchanged; added unit cases (child-session usage counts, grandchild admitted when the chain closes, task-part link, other sessions and repeated steps ignored, exhaustion reported once, no limit → never exhausted, token abort recovers only through an answered finalization) and atokenvariant of the tool-omission subprocess test (--max-tokens 100against a 110-token step:budget_exhausted { limit_tokens: 100, tokens_used: 110 }, tool-free finalization,terminationfields present; the turn/time variants assert the fields are absent).run --helpsnapshot updated for--max-tokens.bun run typecheckinpackages/opencodeandpackages/core: clean.bun run script/check-tracker-leaks.ts: clean.bun run script/upstream/analyze.ts --markers --base origin/main --strict: "All custom code in upstream-shared files is properly marked" (3 files checked).Cost side effects
None added.
--max-tokenscan only reduce spend (it stops a run that would otherwise continue); it adds no model calls, queries or resources. The config warning is one log line per folded key set at config load.Screenshots / recordings
N/A
Checklist
🤖 Generated with Claude Code
Summary by cubic
Fixes three problems in headless
run: killing the wrapper no longer orphans the compiled binary, folded agent config keys are logged instead of silently accepted, and a new opt-in--max-tokensflag caps billed tokens across the run and its task sessions. The TUI is untouched.Changes
bin/altimate-codenow spawns the binary asynchronously and forwards SIGTERM; SIGINT and SIGHUP are forwarded only when the wrapper has no controlling terminal, since a terminal already delivers those to the whole foreground process group. Exit semantics are unchanged: child exit code propagates, signal death exits128+signalwith the same message, and a missing binary exits 1.budgetforsteps) fall outside the agent schema and get folded intooptions. The fold stays —AgentConfigallows additional properties,runreadsaudiencefromoptions, and legacy markdown agents rely on it.run --max-tokens Nsums input + output + cache writes + 0.1 × cache reads over the root session and every descended task session. Exhaustion takes the hard time-deadline path: samebudget_exhaustedevent (limit_tokens,tokens_used), one-shot finalization, and exit 0 when the finalization answers, 1 otherwise. Without the flag nothing changes;terminationand the trace only carrymax_tokens,tokens_used, andtoken_budget_exhaustedwhen set.Written for commit e8a47a7. Summary will update on new commits.