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

Draft
RA
9h ago
+657-31

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 #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.