Skip to content

fix(run): forward wrapper signals, log folded agent keys, opt-in --max-tokens budget - #1655

Draft
RaghwendraSingh wants to merge 3 commits into
mainfrom
fix/run-token-budget-and-signals
Draft

RaghwendraSingh wants to merge 3 commits into
mainfrom
fix/run-token-budget-and-signals

Conversation

@RaghwendraSingh

@RaghwendraSingh RaghwendraSingh commented Nov 4, 2026 •

Copy link
Copy Markdown

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:

  • AgentConfig is published in packages/sdk/openapi.json with additionalProperties (the schema is StructWithRest(Record<string, Any>)), so rejecting would change a documented contract.
  • run.ts reads an agent's audience from entry.options?.audience; a top-level audience: executive in an agent file only works through the fold.
  • Legacy markdown agents go through ConfigAgentV1 in packages/core/src/config/plugin/agent.ts whenever any frontmatter key is outside the v2 set, and migrateAgent moves options into request.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' → only openapi.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 1
  • 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

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 boundary
  • opencode 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 whole budget-finalization.test.ts: 62/62 pass.
  • Run 2: time exhaustion during a silent-turn followup still gets one budget finalization — --time-budget 2 counts process startup, so the second request is sometimes the finalization instead of the silent-turn reply. Measured in isolation: this branch 10/12 pass, clean origin/main 11/12 pass (and fix(session): keep the cached system prompt prefix stable across steps #1631 reported it failing 2/8 on main). The one run.ts change on that path is timeExhausted() also reading tokenBudget?.exhausted, which is undefined without 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, via detached; normal exit code; child killed by SIGKILL → 137 with the same stderr line; missing binary → 1). packages/core/test/config/agent-v1-keys.test.ts and three cases in test/config/config.test.ts (known key accepted, unknown key folded and named, keys under options not 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 a token variant of the tool-omission subprocess test (--max-tokens 100 against a 110-token step: budget_exhausted { limit_tokens: 100, tokens_used: 110 }, tool-free finalization, termination fields present; the turn/time variants assert the fields are absent).
  • run --help snapshot updated for --max-tokens.
  • bun run typecheck in packages/opencode and packages/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-code now 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 exits 128+signal with 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. budget for steps) fall outside the agent schema and get folded into options. The fold stays — AgentConfig allows additional properties, run reads audience from options, and legacy markdown agents rely on it.
  • run --max-tokens N sums input + output + cache writes + 0.1 × cache reads over the root session and every descended task session. Exhaustion takes the hard time-deadline path: same budget_exhausted event (limit_tokens, tokens_used), one-shot finalization, and exit 0 when the finalization answers, 1 otherwise. Without the flag nothing changes; termination and the trace only carry max_tokens, tokens_used, and token_budget_exhausted when set.

Written for commit e8a47a7. Summary will update on new commits.

View guided diff Turn on auto-fix

RaghwendraSingh and others added 3 commits November 4, 2026 02:31
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>
@coderabbitai

coderabbitai Bot commented Nov 4, 2026

Copy link
Copy Markdown

Important

Draft PR not reviewed

Draft PRs are not automatically reviewed by default.

  • Trigger a manual review

To automatically review draft PRs, update your CodeRabbit configuration:

reviews:
  auto_review:
    drafts: true
  • Autofix · Keep fixing CodeRabbit findings and required CI, and resolving merge conflicts

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.

Share

@github-actions

github-actions Bot commented Nov 4, 2026

Copy link
Copy Markdown

👋 This PR was automatically closed by our quality checks.

Common reasons:

  • New GitHub account with limited contribution history
  • PR description doesn't meet our guidelines
  • Contribution appears to be AI-generated without meaningful review

If you believe this was a mistake, please open an issue explaining your intended contribution and a maintainer will help you.

1 similar comment
@github-actions

github-actions Bot commented Nov 4, 2026

Copy link
Copy Markdown

👋 This PR was automatically closed by our quality checks.

Common reasons:

  • New GitHub account with limited contribution history
  • PR description doesn't meet our guidelines
  • Contribution appears to be AI-generated without meaningful review

If you believe this was a mistake, please open an issue explaining your intended contribution and a maintainer will help you.

@RaghwendraSingh

Copy link
Copy Markdown
Author

Independent re-verification (2026-11-04, fresh session, same head e8a47a7259).

Baseline on a clean origin/main (2d59604), then the branch, using the same suites: opencode test/cli/run, test/cli/run-accounting.test.ts, test/cli/help, test/config, test/agent; core test/config; plus the new test/bin on the branch.

  • main: opencode 869 pass / 2 fail, core 46 / 0.
  • branch: opencode 887 pass, core 50 / 0, test/bin 6 / 0.
  • (fail) set on both, with budget-finalization.test.ts + json-output.test.ts run in isolation 3x on each side:
    • --time-budget cuts off a running tool and finalizes once (no answer)
    • --time-budget delivers one soft directive at the next slow-tool boundary
  • Branch: exactly that pair in 3/3 runs. main: that pair in 3/3, plus time-budget finalization omits tools and rejects a model-requested write in run 3, so main flakes on the same files under load. Full-set runs at load average about 6 also showed other timing flakes ((partial answer), silent-turn followup, --max-turns 1 hitting the 60 s harness timeout), none of which reproduce in isolation.

Difference in the failure set: none. tsgo --noEmit is clean in both packages; check-tracker-leaks.ts and analyze.ts --markers --strict pass. Re-checked the unknown-key decision: test/agent/agent.test.ts on main already asserts that top-level random_property/another_random fold into options, so warning and keeping the fold is correct.

@RaghwendraSingh

Copy link
Copy Markdown
Author

Run-level proof at e8a47a7

Three local checks plus one real-gateway run (approved cap: 200,000 billable tokens, enforced by an external meter that hard-kills the run). "main" = origin/main (2d59604e66; no diff to eacb84a4 in bin/altimate-code, src/config, src/cli, packages/core/src/v1/config). "fix" = this branch at e8a47a7259, clean worktree.

A. Wrapper forwards SIGTERM (bin/altimate-code)

Child = a bash script that traps TERM, logs got-TERM and exits 143. Command: ALTIMATE_CODE_BIN_PATH=<sleeper> node <wrapper> --version &, then kill -TERM <wrapper pid> after 2 s.

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.

@RaghwendraSingh

Copy link
Copy Markdown
Author

Second run: the token budget stops the run

One more real-gateway run at e8a47a7259 (clean worktree; approved backstop 200,000 billable tokens, enforced by the external meter, which did not need to fire). This covers what run 1 left open: budget_exhausted firing, a single finalization, and the exit code.

Changes from run 1: --max-tokens 15000, and a task that can't be batched. The parent first delegates echo w1…echo w8 to worker via task. Then it runs a 40-step counter.txt increment chain, one bash call per reply, where each step reads the previous step's output. Same agents (parent: bash + task; worker: bash only), model altimate-backend/altimate-default, --format json.

Result: the limit fired. The run crossed 15,000 at the end of the root step that held the task call. The harness aborted that work, sent one finalization prompt, got a final text answer, and exited 0.

session steps input output cache read billed
root (parent), step 1 (task call) 1 7,932 206 0 8,138
child (worker, via task) 9 5,329 817 38,726 10,019
at the stop 18,157
root, finalization step 1 3,469 262 0 3,731
total (local DB) 11 21,888

Events from --format json, in order:

step_start
tool_use        task (completed: worker ran w1..w8)
budget_exhausted {"limit_tokens":15000,"tokens_used":18157}
time_budget_abort_warning {"error":{"name":"MessageAbortedError","data":{"message":"Run time budget hard deadline reached"}}}
silent_turn_reply {"failure":null}
step_start
text            "Only the first part of the request is done; the counter sequence was never started, so I'm not replying DONE. ..."
termination     {"why_model_stopped":"stop","why_harness_stopped":"budget-exhausted","done_reason":"none","elapsed_s":34.6,"time_budget_deadline":"none","max_tokens":15000,"tokens_used":21888,"token_budget_exhausted":true}
  • budget_exhausted fired once. tokens_used 18,157 = root step 1 (8,138) + child (10,019), the same root + child sum that run 1 showed. The overshoot past 15,000 is the size of that one step. The check runs when a root step finishes, and that step included the whole subagent. The child alone (10,019) never reached the limit.
  • Exactly one finalization. The root session has a single synthetic user message ("The run's work budget has been exhausted. Do not start more work or call tools. …") and one assistant step after it (finish: stop, no tool calls). No counter.txt was created, so no work ran after the stop.
  • A final text answer followed. It reports honestly: the delegated part finished, the counter chain was not started, and it does not say DONE.
  • Exit code 0. This matches --time-budget exhaustion. The token stop calls accounting.onBudgetExhausted("time"), and fatal clears only through completeBudgetFinalization(true) (an answered finalization). The --time-budget 5 subprocess tests in test/cli/run/budget-finalization.test.ts expect exit 0 for an answered finalization. The unit test "a token abort recovers the exit status only through an answered finalization, like a time abort" pins the same path. A silent finalization stays fatal (exit 1). stderr had only the two budget warnings.
  • The backstop meter read 21,887 at the end, which matches the DB (21,888) up to rounding. It never came close to 200,000.

Cosmetic, not blocking: a token stop goes through the hard-deadline abort (by design, run.ts ~L878), so the stream shows time_budget_abort_warning and the abort message "Run time budget hard deadline reached" while time_budget_deadline is "none". budget_exhausted and termination.token_budget_exhausted identify the cause correctly. A token-specific label on the abort would make the stream easier to read.

Gateway spend for this run: 21,888 billable tokens. Total for both proof runs: 52,037.

This branch has not been deployed

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

Projects

None yet

Development

Successfully merging this pull request may close these issues.

run: wrapper orphans the child on kill, unknown agent keys fold silently, no token budget

1 participant