fix(run): forward wrapper signals, log folded agent keys, opt-in --max-tokens budget
Issue for this PR
Closes #1653
Type of change
- Bug fix
- New feature
- Refactor / code improvement
- Documentation
What does this PR do?
Three focused commits for headless run under a driver that enforces a cap.
1. fix(bin): forward termination signals to the bun child — bin/altimate-code ran the binary with spawnSync, so kill <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/tty cannot be opened): a terminal delivers those to the whole foreground process group, so the binary already has them, and a second SIGINT makes run skip 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+signal with the same altimate-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) because run() no longer exits synchronously. NODE_PATH / ALTIMATE_BIN_DIR logic is untouched. One cosmetic difference: Node's own message for a missing binary reads spawn … ENOENT instead of spawnSync … ENOENT.
2. fix(config): log the agent config keys that are folded into options — ConfigAgentV1.normalize copies every top-level key it does not interpret into options, so budget: 100 (meant as steps) 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.- Legacy markdown agents go through
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. - Grep evidence: no tracked agent file (
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 knownKeys and unknownKeys() beside the schema; the markdown agent/mode loaders and the JSON config loader (agent and mode sections) log agent config: keys outside the agent schema were folded into options with the agent, source, folded keys and known keys through Log.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 in termination or the trace). Budget = input + output + cache writes + 0.1 × cache reads, summed over every step-finish of the root session and of every session descended from it (linked through session.created/session.updated parentID or 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-shot takeBudgetFinalizationAllowance / REPLY_AFTER_BUDGET finalization, the same budget_exhausted event ({ limit_tokens, tokens_used } next to limit_seconds), the same why_harness_stopped: "budget-exhausted" and the same exit status (0 when the finalization answers, 1 otherwise). termination and the trace gain max_tokens, tokens_used, token_budget_exhausted only 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-only run-token-budget.ts. The finalization request carries finalization-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 new test/config/agent-v1-keys.test.ts after).
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 boundary
Failing after (this branch):
Run 1 (all suites; test/cli/ 1192 pass / 3 fail / 1200, prompt.test.ts 69 pass / 0 fail, test/bin/ 6 pass, config.test.ts+agent.test.ts 174 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 boundary
Run 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 finalization
Diff of the failing sets: the two known --time-budget cases from json-output.test.ts fail 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):
- Run 1:
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. - Run 2:
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 #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. Full bun test output of all three runs was kept locally and can be attached on request.
- New tests:
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).- No model-gateway runs; no skill publish.
Cost side effects
None added. --max-tokens can 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
- I have tested my changes locally
- I have not included unrelated changes in this PR
- I checked for cost side effects (model calls, queries or cloud resources that grow with user data) and described them, or wrote "none"
🤖 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-tokens flag 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.- The markdown agent/mode loaders and the JSON config loader warn when top-level keys (e.g.
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.