detached a11d3a0

feat(subagents): remove rollout budget, add 85% context nudge (#197)

Drop the cumulative weighted-token budget for resident children
(RolloutBudget, budget states, baselines, auto-generated handoffs).
Execution stays bounded through a finite MaxSteps default, and a
non-blocking reminder fires once when last-turn usage reaches 85%
of the model window. Journals write v3 and read v2 archives with
explicit translation.
commit
a11d3a037744fcef6cf6b057878a99f58513d132
author
bnema · · 2026-09-10 11:34 UTC
committer
GitHub · · 2026-09-10 11:34 UTC
parent
0b7037b
signature
GPG UnverifiedB5690EEEBB952194 The key is unknown
28 files · +901 -614
M README.md +2 -2
@@ -442,7 +442,7 @@ Resident subagents run alongside the main session as independent in-process agen
442442
443443 The fair global scheduler defaults to six concurrent turns, one active turn per child. `subagents.max_concurrent` overrides that limit. Queueing has no default deadline; a positive `subagents.queue_timeout` durably fails a prompt that waits too long for a slot. Required child work remains an obligation until an explicit successful follow-up.
444444
445-Each child receives a cumulative rollout budget equal to its resolved model's context window. Unfinished work that reaches the limit reports `budget_exhausted` with a saved partial handoff, rather than successful idle work. Retrieve it without a model call using `subagent_status` with `agent_id` and `include_result: true`. After checking the saved progress and side effects, an explicit resume grants a fresh same-size allowance while retaining the session, workspace, and cumulative cost. Required work stays unresolved until success. See [budget recovery](docs/subagents.md#lifecycle-and-persistence) for details.
445+Each child runs without a cumulative token budget; execution stays bounded through a finite per-turn step default. A child whose last-turn prompt reaches 85% of its model context window receives one non-blocking reminder to finish up, injected into its next provider request. Retrieve a terminal result without a model call using `subagent_status` with `agent_id` and `include_result: true`. After checking the saved progress and side effects, an explicit resume continues the retained session. Required work stays unresolved until success. See [lifecycle and persistence](docs/subagents.md#lifecycle-and-persistence) for details.
446446
447447 Worktree isolation captures a patch and changed-file list without applying it. Shared children use the host checkout. The available logical references are `subagent://<id>`, `subagent://<id>/history`, `subagent://<id>/result`, and `subagent://<id>/patch`.
448448
@@ -465,7 +465,7 @@ Opens a dialog with every persistent setting. `up`/`down` to navigate, `enter` o
465465 - **fast mode** — request the provider's fast tier where supported. It currently uses OpenAI's Fast service tier for OpenAI, OpenAI Responses, and OpenAI Codex models. Off by default; toggle it with `/fast` or `/settings`; changes apply on the next model call and persist as `fast_mode`. When a model profile is active, the value is saved in that profile and restored when switching profiles. Other providers currently return an unsupported-provider error. Fast mode may cost more and depends on the selected model/account.
466466 - **lsp in main session** — enable the built-in `lsp` tool and code diagnostics for the main agent. Enabled by default; changes persist as `lsp_enabled`.
467467 - **lsp in sub-agents** — allow newly spawned background sub-agents to use the built-in `lsp` tool. Enabled by default; changes persist as `subagent_lsp_enabled` and apply when a child starts.
468-- **auto-compact threshold** — choose `off`, `70%`, `80%`, `85%` (default), `90%`, or `95%` of the model's advertised context window. The selected percentage controls automatic compaction before and after interactive turns and before sub-agent follow-up turns, and persists as `auto_compact_threshold`. Context accounting includes uncached input plus provider-reported cache reads and writes, so cached turns still trigger compaction. Structural unfinished tails and truncated output continue as before. After a successful after-turn threshold compaction, zut may make at most two hidden, bounded recovery continuations when the assistant's visible text appears to promise future work; clear completed text settles normally, and a newer queued user request wins. An interrupted interactive session retains an outstanding handoff when resumed, including after portable export and import. Manual `/compact` does not silently start work. `off` disables percentage-based triggers but keeps manual `/compact` and automatic recovery from context-window and payload-too-large responses. Truncated-output continuation remains a separate priority rule.
468+- **auto-compact threshold** — choose `off`, `70%`, `80%`, `85%` (default), `90%`, or `95%` of the model's advertised context window. The selected percentage controls automatic compaction before and after interactive turns, and persists as `auto_compact_threshold`. Context accounting includes uncached input plus provider-reported cache reads and writes, so cached turns still trigger compaction. Structural unfinished tails and truncated output continue as before. After a successful after-turn threshold compaction, zut may make at most two hidden, bounded recovery continuations when the assistant's visible text appears to promise future work; clear completed text settles normally, and a newer queued user request wins. An interrupted interactive session retains an outstanding handoff when resumed, including after portable export and import. Manual `/compact` does not silently start work. `off` disables percentage-based triggers but keeps manual `/compact` and automatic recovery from context-window and payload-too-large responses. Truncated-output continuation remains a separate priority rule.
469469 - **jail new sessions by default**: start every new agent with tools confined to its working directory. Off by default. The setting applies to interactive, print, JSON, RPC, and background-agent runs, persists as `jail_by_default`, and immediately updates the current interactive session. `/jail` and `/unjail` remain session-scoped overrides and do not change this default.
470470 - **compact transcript rendering**: reduce visual chrome in the chat transcript. Tool calls render as a quiet header plus indented output instead of a bordered box, and sent messages render without padded background bubbles. Off by default. Changes apply immediately and persist to `config.json` as `compact_mode`.
471471 - **show loaded resources at startup**: list the active Zutfile agent (for `zut run`), loaded `AGENTS.md` paths, extensions, and user-installed skills in compact sections above the transcript. Built-in skills are omitted. Off by default. Changes apply immediately and persist to `config.json` as `show_instructions_at_startup`.
@@ -442,7 +442,7 @@ Resident subagents run alongside the main session as independent in-process agen
442 442
443 The fair global scheduler defaults to six concurrent turns, one active turn per child. `subagents.max_concurrent` overrides that limit. Queueing has no default deadline; a positive `subagents.queue_timeout` durably fails a prompt that waits too long for a slot. Required child work remains an obligation until an explicit successful follow-up.443 The fair global scheduler defaults to six concurrent turns, one active turn per child. `subagents.max_concurrent` overrides that limit. Queueing has no default deadline; a positive `subagents.queue_timeout` durably fails a prompt that waits too long for a slot. Required child work remains an obligation until an explicit successful follow-up.
444 444
445-Each child receives a cumulative rollout budget equal to its resolved model's context window. Unfinished work that reaches the limit reports `budget_exhausted` with a saved partial handoff, rather than successful idle work. Retrieve it without a model call using `subagent_status` with `agent_id` and `include_result: true`. After checking the saved progress and side effects, an explicit resume grants a fresh same-size allowance while retaining the session, workspace, and cumulative cost. Required work stays unresolved until success. See [budget recovery](docs/subagents.md#lifecycle-and-persistence) for details.445+Each child runs without a cumulative token budget; execution stays bounded through a finite per-turn step default. A child whose last-turn prompt reaches 85% of its model context window receives one non-blocking reminder to finish up, injected into its next provider request. Retrieve a terminal result without a model call using `subagent_status` with `agent_id` and `include_result: true`. After checking the saved progress and side effects, an explicit resume continues the retained session. Required work stays unresolved until success. See [lifecycle and persistence](docs/subagents.md#lifecycle-and-persistence) for details.
446 446
447 Worktree isolation captures a patch and changed-file list without applying it. Shared children use the host checkout. The available logical references are `subagent://<id>`, `subagent://<id>/history`, `subagent://<id>/result`, and `subagent://<id>/patch`.447 Worktree isolation captures a patch and changed-file list without applying it. Shared children use the host checkout. The available logical references are `subagent://<id>`, `subagent://<id>/history`, `subagent://<id>/result`, and `subagent://<id>/patch`.
448 448
@@ -465,7 +465,7 @@ Opens a dialog with every persistent setting. `up`/`down` to navigate, `enter` o
465 - **fast mode** — request the provider's fast tier where supported. It currently uses OpenAI's Fast service tier for OpenAI, OpenAI Responses, and OpenAI Codex models. Off by default; toggle it with `/fast` or `/settings`; changes apply on the next model call and persist as `fast_mode`. When a model profile is active, the value is saved in that profile and restored when switching profiles. Other providers currently return an unsupported-provider error. Fast mode may cost more and depends on the selected model/account.465 - **fast mode** — request the provider's fast tier where supported. It currently uses OpenAI's Fast service tier for OpenAI, OpenAI Responses, and OpenAI Codex models. Off by default; toggle it with `/fast` or `/settings`; changes apply on the next model call and persist as `fast_mode`. When a model profile is active, the value is saved in that profile and restored when switching profiles. Other providers currently return an unsupported-provider error. Fast mode may cost more and depends on the selected model/account.
466 - **lsp in main session** — enable the built-in `lsp` tool and code diagnostics for the main agent. Enabled by default; changes persist as `lsp_enabled`.466 - **lsp in main session** — enable the built-in `lsp` tool and code diagnostics for the main agent. Enabled by default; changes persist as `lsp_enabled`.
467 - **lsp in sub-agents** — allow newly spawned background sub-agents to use the built-in `lsp` tool. Enabled by default; changes persist as `subagent_lsp_enabled` and apply when a child starts.467 - **lsp in sub-agents** — allow newly spawned background sub-agents to use the built-in `lsp` tool. Enabled by default; changes persist as `subagent_lsp_enabled` and apply when a child starts.
468-- **auto-compact threshold** — choose `off`, `70%`, `80%`, `85%` (default), `90%`, or `95%` of the model's advertised context window. The selected percentage controls automatic compaction before and after interactive turns and before sub-agent follow-up turns, and persists as `auto_compact_threshold`. Context accounting includes uncached input plus provider-reported cache reads and writes, so cached turns still trigger compaction. Structural unfinished tails and truncated output continue as before. After a successful after-turn threshold compaction, zut may make at most two hidden, bounded recovery continuations when the assistant's visible text appears to promise future work; clear completed text settles normally, and a newer queued user request wins. An interrupted interactive session retains an outstanding handoff when resumed, including after portable export and import. Manual `/compact` does not silently start work. `off` disables percentage-based triggers but keeps manual `/compact` and automatic recovery from context-window and payload-too-large responses. Truncated-output continuation remains a separate priority rule.468+- **auto-compact threshold** — choose `off`, `70%`, `80%`, `85%` (default), `90%`, or `95%` of the model's advertised context window. The selected percentage controls automatic compaction before and after interactive turns, and persists as `auto_compact_threshold`. Context accounting includes uncached input plus provider-reported cache reads and writes, so cached turns still trigger compaction. Structural unfinished tails and truncated output continue as before. After a successful after-turn threshold compaction, zut may make at most two hidden, bounded recovery continuations when the assistant's visible text appears to promise future work; clear completed text settles normally, and a newer queued user request wins. An interrupted interactive session retains an outstanding handoff when resumed, including after portable export and import. Manual `/compact` does not silently start work. `off` disables percentage-based triggers but keeps manual `/compact` and automatic recovery from context-window and payload-too-large responses. Truncated-output continuation remains a separate priority rule.
469 - **jail new sessions by default**: start every new agent with tools confined to its working directory. Off by default. The setting applies to interactive, print, JSON, RPC, and background-agent runs, persists as `jail_by_default`, and immediately updates the current interactive session. `/jail` and `/unjail` remain session-scoped overrides and do not change this default.469 - **jail new sessions by default**: start every new agent with tools confined to its working directory. Off by default. The setting applies to interactive, print, JSON, RPC, and background-agent runs, persists as `jail_by_default`, and immediately updates the current interactive session. `/jail` and `/unjail` remain session-scoped overrides and do not change this default.
470 - **compact transcript rendering**: reduce visual chrome in the chat transcript. Tool calls render as a quiet header plus indented output instead of a bordered box, and sent messages render without padded background bubbles. Off by default. Changes apply immediately and persist to `config.json` as `compact_mode`.470 - **compact transcript rendering**: reduce visual chrome in the chat transcript. Tool calls render as a quiet header plus indented output instead of a bordered box, and sent messages render without padded background bubbles. Off by default. Changes apply immediately and persist to `config.json` as `compact_mode`.
471 - **show loaded resources at startup**: list the active Zutfile agent (for `zut run`), loaded `AGENTS.md` paths, extensions, and user-installed skills in compact sections above the transcript. Built-in skills are omitted. Off by default. Changes apply immediately and persist to `config.json` as `show_instructions_at_startup`.471 - **show loaded resources at startup**: list the active Zutfile agent (for `zut run`), loaded `AGENTS.md` paths, extensions, and user-installed skills in compact sections above the transcript. Built-in skills are omitted. Off by default. Changes apply immediately and persist to `config.json` as `show_instructions_at_startup`.
M docs/subagents.md +47 -42
@@ -56,7 +56,7 @@ subagents/<child-id>/
5656 owner.lock # host-ownership coordination; do not modify or remove
5757 .transcript-backup-* # retained private pre-repair transcript, when repaired
5858 metadata.json # rebuildable bounded state
59- result.json # latest outcome, final summary or partial handoff, artifacts
59+ result.json # latest outcome, final summary, artifacts
6060 patch.diff # optional worktree capture
6161 ```
6262
@@ -89,34 +89,41 @@ one turn for each child, and defaults to six concurrent turns. Set
8989 unlimited by default; a positive `subagents.queue_timeout` cancels an accepted
9090 prompt that has not received a slot and records its terminal failure durably.
9191 `subagents.allowed_tools` is an allowlist for child-visible tools and
92-`subagents.allowed_roots` limits eligible child workspaces. Each child receives
93-a cumulative weighted-token budget equal to its resolved model's context window.
94-Removed legacy settings, including `tui_subagent_position` and
95-`subagents.budget_ratio`, are ignored. Resident journals created before this
96-budget policy are incompatible: zut leaves them untouched on disk and excludes
97-them from automatic discovery without reporting an error at every startup.
98-They cannot be resumed; start a new child instead. Other journal recovery
99-errors are still reported.
100-
101-Budget accounting charges uncached input, cache writes, and output fully; cache
102-reads count at 25%. It is separate from active context-window usage and survives
103-follow-up turns and compaction. The TUI shows the child's current budget usage,
104-but does not alter its instructions, tools, or output cap before exhaustion.
105-Providers report exact input usage only after a response, so the terminal
106-response can make displayed usage exceed 100%; no later request is sent in that
107-turn. If the worker still needs another model step, it reports
108-`budget_exhausted`, not successful idle work. A final answer that completes on
109-the response crossing the limit remains successful and reports `completed`.
110-
111-Budget exhaustion publishes a terminal completion with a host-generated partial
112-handoff, capped at 16 KiB. It includes recent visible progress messages, tool
113-names and error flags, workspace and history references, and recovery guidance.
114-Raw tool arguments and output are omitted from this automatic handoff because
115-they may contain secrets; they remain retrievable in the child's durable history.
116-These are observations, not a claim that the task or verification finished;
117-older or truncated details also remain in the durable transcript. Worktree
118-changes are captured even on budget exhaustion, and the worktree is retained
119-for recovery. Shared-workspace changes are not rolled back.
92+`subagents.allowed_roots` limits eligible child workspaces. Resident children
93+have no cumulative token budget. Execution stays bounded through a finite
94+per-turn step default: a child whose parent resolution is unlimited runs with
95+`MaxSteps=50`; an explicit parent limit is preserved. Removed legacy
96+settings, including `tui_subagent_position` and `subagents.budget_ratio`, are
97+ignored. Resident journals written by older versions are read with explicit
98+translation (an old `budget_exhausted` outcome reconciles as failure) or, for
99+unknown versions, left untouched on disk and excluded from automatic
100+discovery. Incompatible journals cannot be resumed; start a new child
101+instead. Other journal recovery errors are still reported.
102+
103+Compatibility notes: new journals write version 3 and carry no budget
104+fields, baselines, or exhaustion states. Version 2 archives are read with
105+explicit translation and their transcripts are never rewritten by
106+translation. `result.json` keeps a deprecated `handoff` field that stays
107+empty for new results and remains readable for archives. `subagent_status`
108+no longer exposes `budget` or `budget_source` fields.
109+
110+### Context reminders
111+
112+When a child's last-turn prompt reaches 85% of its model context window, the
113+runner injects one non-blocking reminder into the next provider request:
114+
115+```text
116+Context usage is at NN% of the model window. Finish the current task, report
117+results, limits, and remaining verifications, and do not start broad new work.
118+```
119+
120+The reminder fires at most once per child runner and never blocks a turn.
121+It travels as developer context in the live request; the durable transcript
122+keeps only finalized history. Usage is the last-turn prompt size (input plus
123+cache read/write tokens) against the resolved context window; an unknown
124+window never triggers a reminder. The per-turn gauge resets when the host
125+restarts, so a restarted child is reminded again only once fresh usage past
126+the threshold arrives.
120127
121128 Retrieve the saved result without executing a model:
122129
@@ -125,16 +132,14 @@ Retrieve the saved result without executing a model:
125132 ```
126133
127134 Pass this to `subagent_status`, then reconcile the history and artifacts before
128-repeating any side effects. Explicit `subagent_resume` after terminal exhaustion
129-opens a fresh allowance of the **same size**, retaining the session, workspace,
130-and cumulative cost. This permits additional spending; it does not enlarge the
131-configured limit or automatically retry the task. The allowance baseline is
132-journaled and survives restart. Ordinary non-exhausted follow-ups continue using
133-the existing allowance.
135+repeating any side effects. Explicit `subagent_resume` after a terminal failure
136+continues the retained session, workspace, and cumulative cost. It does not
137+automatically retry the task. Ordinary follow-ups continue the existing
138+session.
134139
135140 `required: true` makes a delegated result an obligation of the parent turn.
136-Failed, cancelled, interrupted, and budget-exhausted required work remains
137-unresolved until an explicit successful follow-up. Reading a partial handoff or
141+Failed, cancelled, and interrupted required work remains
142+unresolved until an explicit successful follow-up. Reading a saved result or
138143 accepting a recovery prompt does not satisfy the obligation. A host restart never
139144 retries it automatically.
140145
@@ -175,14 +180,14 @@ The model-facing tools retain their logical names:
175180 returns a logical `subagent://<id>` reference.
176181 - `subagent_status` returns bounded current state for one child or the current
177182 set immediately. With `agent_id` and `include_result: true`, it also reads the
178- saved terminal result or partial handoff without model execution. Without this
183+ saved terminal result without model execution. Without this
179184 option it remains metadata-only; foreign-owned results cannot be read.
180185 Result-read errors distinguish foreign ownership, a missing saved result,
181186 and permission denial without exposing filesystem paths or saved content.
182187 - `subagent_stop` stops one live child.
183188 - `subagent_resume` accepts an explicit follow-up prompt for an existing child.
184- After terminal budget exhaustion, this grants a fresh same-size allowance
185- while preserving progress. Inspect the saved handoff before resuming.
189+ After a terminal failure, inspect the saved result before resuming; resume
190+ continues the retained session.
186191
187192 Child execution started by `subagent_spawn` is asynchronous unless it receives
188193 an explicit `wait` value. For an unwaited spawn, completion arrives through the
@@ -224,14 +229,14 @@ private local session data.
224229
225230 Shared children use the host working directory. With `isolation: "worktree"`,
226231 zut prepares a detached Git worktree and captures a patch and changed-file list
227-on successful completion or budget exhaustion. Budget-exhausted turns retain
232+on successful completion or non-cancelled failure. Failed turns retain
228233 partial work in the worktree, along with any captured patch and changed-file
229234 list. zut never applies a captured patch automatically. Failed or interrupted
230235 worktrees are retained for inspection; an idle successful worktree is cleaned
231236 when its child is explicitly stopped.
232237
233238 Use `subagent_status` with `agent_id` and `include_result: true` to retrieve the
234-saved handoff, `changed_files`, and `patch_ref` when a patch was captured. The
239+saved result, `changed_files`, and `patch_ref` when a patch was captured. The
235240 patch bytes are stored in `subagents/<child-id>/patch.diff` under the managed
236241 state root shown above; logical references are identifiers, not filesystem
237242 paths. Inspect the retained worktree and patch before resuming or applying
@@ -56,7 +56,7 @@ subagents/<child-id>/
56 owner.lock # host-ownership coordination; do not modify or remove56 owner.lock # host-ownership coordination; do not modify or remove
57 .transcript-backup-* # retained private pre-repair transcript, when repaired57 .transcript-backup-* # retained private pre-repair transcript, when repaired
58 metadata.json # rebuildable bounded state58 metadata.json # rebuildable bounded state
59- result.json # latest outcome, final summary or partial handoff, artifacts59+ result.json # latest outcome, final summary, artifacts
60 patch.diff # optional worktree capture60 patch.diff # optional worktree capture
61 ```61 ```
62 62
@@ -89,34 +89,41 @@ one turn for each child, and defaults to six concurrent turns. Set
89 unlimited by default; a positive `subagents.queue_timeout` cancels an accepted89 unlimited by default; a positive `subagents.queue_timeout` cancels an accepted
90 prompt that has not received a slot and records its terminal failure durably.90 prompt that has not received a slot and records its terminal failure durably.
91 `subagents.allowed_tools` is an allowlist for child-visible tools and91 `subagents.allowed_tools` is an allowlist for child-visible tools and
92-`subagents.allowed_roots` limits eligible child workspaces. Each child receives92+`subagents.allowed_roots` limits eligible child workspaces. Resident children
93-a cumulative weighted-token budget equal to its resolved model's context window.93+have no cumulative token budget. Execution stays bounded through a finite
94-Removed legacy settings, including `tui_subagent_position` and94+per-turn step default: a child whose parent resolution is unlimited runs with
95-`subagents.budget_ratio`, are ignored. Resident journals created before this95+`MaxSteps=50`; an explicit parent limit is preserved. Removed legacy
96-budget policy are incompatible: zut leaves them untouched on disk and excludes96+settings, including `tui_subagent_position` and `subagents.budget_ratio`, are
97-them from automatic discovery without reporting an error at every startup.97+ignored. Resident journals written by older versions are read with explicit
98-They cannot be resumed; start a new child instead. Other journal recovery98+translation (an old `budget_exhausted` outcome reconciles as failure) or, for
99-errors are still reported.99+unknown versions, left untouched on disk and excluded from automatic
100-100+discovery. Incompatible journals cannot be resumed; start a new child
101-Budget accounting charges uncached input, cache writes, and output fully; cache101+instead. Other journal recovery errors are still reported.
102-reads count at 25%. It is separate from active context-window usage and survives102+
103-follow-up turns and compaction. The TUI shows the child's current budget usage,103+Compatibility notes: new journals write version 3 and carry no budget
104-but does not alter its instructions, tools, or output cap before exhaustion.104+fields, baselines, or exhaustion states. Version 2 archives are read with
105-Providers report exact input usage only after a response, so the terminal105+explicit translation and their transcripts are never rewritten by
106-response can make displayed usage exceed 100%; no later request is sent in that106+translation. `result.json` keeps a deprecated `handoff` field that stays
107-turn. If the worker still needs another model step, it reports107+empty for new results and remains readable for archives. `subagent_status`
108-`budget_exhausted`, not successful idle work. A final answer that completes on108+no longer exposes `budget` or `budget_source` fields.
109-the response crossing the limit remains successful and reports `completed`.109+
110-110+### Context reminders
111-Budget exhaustion publishes a terminal completion with a host-generated partial111+
112-handoff, capped at 16 KiB. It includes recent visible progress messages, tool112+When a child's last-turn prompt reaches 85% of its model context window, the
113-names and error flags, workspace and history references, and recovery guidance.113+runner injects one non-blocking reminder into the next provider request:
114-Raw tool arguments and output are omitted from this automatic handoff because114+
115-they may contain secrets; they remain retrievable in the child's durable history.115+```text
116-These are observations, not a claim that the task or verification finished;116+Context usage is at NN% of the model window. Finish the current task, report
117-older or truncated details also remain in the durable transcript. Worktree117+results, limits, and remaining verifications, and do not start broad new work.
118-changes are captured even on budget exhaustion, and the worktree is retained118+```
119-for recovery. Shared-workspace changes are not rolled back.119+
120+The reminder fires at most once per child runner and never blocks a turn.
121+It travels as developer context in the live request; the durable transcript
122+keeps only finalized history. Usage is the last-turn prompt size (input plus
123+cache read/write tokens) against the resolved context window; an unknown
124+window never triggers a reminder. The per-turn gauge resets when the host
125+restarts, so a restarted child is reminded again only once fresh usage past
126+the threshold arrives.
120 127
121 Retrieve the saved result without executing a model:128 Retrieve the saved result without executing a model:
122 129
@@ -125,16 +132,14 @@ Retrieve the saved result without executing a model:
125 ```132 ```
126 133
127 Pass this to `subagent_status`, then reconcile the history and artifacts before134 Pass this to `subagent_status`, then reconcile the history and artifacts before
128-repeating any side effects. Explicit `subagent_resume` after terminal exhaustion135+repeating any side effects. Explicit `subagent_resume` after a terminal failure
129-opens a fresh allowance of the **same size**, retaining the session, workspace,136+continues the retained session, workspace, and cumulative cost. It does not
130-and cumulative cost. This permits additional spending; it does not enlarge the137+automatically retry the task. Ordinary follow-ups continue the existing
131-configured limit or automatically retry the task. The allowance baseline is138+session.
132-journaled and survives restart. Ordinary non-exhausted follow-ups continue using
133-the existing allowance.
134 139
135 `required: true` makes a delegated result an obligation of the parent turn.140 `required: true` makes a delegated result an obligation of the parent turn.
136-Failed, cancelled, interrupted, and budget-exhausted required work remains141+Failed, cancelled, and interrupted required work remains
137-unresolved until an explicit successful follow-up. Reading a partial handoff or142+unresolved until an explicit successful follow-up. Reading a saved result or
138 accepting a recovery prompt does not satisfy the obligation. A host restart never143 accepting a recovery prompt does not satisfy the obligation. A host restart never
139 retries it automatically.144 retries it automatically.
140 145
@@ -175,14 +180,14 @@ The model-facing tools retain their logical names:
175 returns a logical `subagent://<id>` reference.180 returns a logical `subagent://<id>` reference.
176 - `subagent_status` returns bounded current state for one child or the current181 - `subagent_status` returns bounded current state for one child or the current
177 set immediately. With `agent_id` and `include_result: true`, it also reads the182 set immediately. With `agent_id` and `include_result: true`, it also reads the
178- saved terminal result or partial handoff without model execution. Without this183+ saved terminal result without model execution. Without this
179 option it remains metadata-only; foreign-owned results cannot be read.184 option it remains metadata-only; foreign-owned results cannot be read.
180 Result-read errors distinguish foreign ownership, a missing saved result,185 Result-read errors distinguish foreign ownership, a missing saved result,
181 and permission denial without exposing filesystem paths or saved content.186 and permission denial without exposing filesystem paths or saved content.
182 - `subagent_stop` stops one live child.187 - `subagent_stop` stops one live child.
183 - `subagent_resume` accepts an explicit follow-up prompt for an existing child.188 - `subagent_resume` accepts an explicit follow-up prompt for an existing child.
184- After terminal budget exhaustion, this grants a fresh same-size allowance189+ After a terminal failure, inspect the saved result before resuming; resume
185- while preserving progress. Inspect the saved handoff before resuming.190+ continues the retained session.
186 191
187 Child execution started by `subagent_spawn` is asynchronous unless it receives192 Child execution started by `subagent_spawn` is asynchronous unless it receives
188 an explicit `wait` value. For an unwaited spawn, completion arrives through the193 an explicit `wait` value. For an unwaited spawn, completion arrives through the
@@ -224,14 +229,14 @@ private local session data.
224 229
225 Shared children use the host working directory. With `isolation: "worktree"`,230 Shared children use the host working directory. With `isolation: "worktree"`,
226 zut prepares a detached Git worktree and captures a patch and changed-file list231 zut prepares a detached Git worktree and captures a patch and changed-file list
227-on successful completion or budget exhaustion. Budget-exhausted turns retain232+on successful completion or non-cancelled failure. Failed turns retain
228 partial work in the worktree, along with any captured patch and changed-file233 partial work in the worktree, along with any captured patch and changed-file
229 list. zut never applies a captured patch automatically. Failed or interrupted234 list. zut never applies a captured patch automatically. Failed or interrupted
230 worktrees are retained for inspection; an idle successful worktree is cleaned235 worktrees are retained for inspection; an idle successful worktree is cleaned
231 when its child is explicitly stopped.236 when its child is explicitly stopped.
232 237
233 Use `subagent_status` with `agent_id` and `include_result: true` to retrieve the238 Use `subagent_status` with `agent_id` and `include_result: true` to retrieve the
234-saved handoff, `changed_files`, and `patch_ref` when a patch was captured. The239+saved result, `changed_files`, and `patch_ref` when a patch was captured. The
235 patch bytes are stored in `subagents/<child-id>/patch.diff` under the managed240 patch bytes are stored in `subagents/<child-id>/patch.diff` under the managed
236 state root shown above; logical references are identifiers, not filesystem241 state root shown above; logical references are identifiers, not filesystem
237 paths. Inspect the retained worktree and patch before resuming or applying242 paths. Inspect the retained worktree and patch before resuming or applying
M packages/agent/modes/resident_subagent_indicator.go +0 -3
@@ -101,9 +101,6 @@ func residentUsageMetadata(snapshot subagents.ResidentSnapshot) string {
101101 if context := tui.ContextUsageText(snapshot.ContextUsed, snapshot.ContextMax); context != "" {
102102 parts = append(parts, context)
103103 }
104- if snapshot.Budget.Limit > 0 {
105- parts = append(parts, fmt.Sprintf("budget:%s %d%%/%s", snapshot.Budget.State, snapshot.Budget.Percent, compactResidentTokens(snapshot.Budget.Limit)))
106- }
107104 return strings.Join(parts, " ")
108105 }
109106
@@ -101,9 +101,6 @@ func residentUsageMetadata(snapshot subagents.ResidentSnapshot) string {
101 if context := tui.ContextUsageText(snapshot.ContextUsed, snapshot.ContextMax); context != "" {101 if context := tui.ContextUsageText(snapshot.ContextUsed, snapshot.ContextMax); context != "" {
102 parts = append(parts, context)102 parts = append(parts, context)
103 }103 }
104- if snapshot.Budget.Limit > 0 {
105- parts = append(parts, fmt.Sprintf("budget:%s %d%%/%s", snapshot.Budget.State, snapshot.Budget.Percent, compactResidentTokens(snapshot.Budget.Limit)))
106- }
107 return strings.Join(parts, " ")104 return strings.Join(parts, " ")
108 }105 }
109 106
M packages/agent/modes/resident_subagent_indicator_test.go +8 -4
@@ -233,12 +233,16 @@ func TestRenderResidentSubagentActivityLinesRightAlignsUsageMetadataWhenItFits(t
233233 }
234234 }
235235
236-func TestResidentUsageMetadataShowsRolloutBudget(t *testing.T) {
236+func TestResidentUsageMetadataShowsContextOnly(t *testing.T) {
237237 metadata := residentUsageMetadata(subagents.ResidentSnapshot{
238- Budget: subagents.BudgetSnapshot{Used: 337_500, Limit: 500_000, Percent: 67, State: subagents.BudgetNormal},
238+ Usage: provider.Usage{InputTokens: 84_000, OutputTokens: 1_500},
239+ ContextUsed: 45_152, ContextMax: 272_000,
239240 })
240- if metadata != "budget:normal 67%/500k" {
241- t.Fatalf("budget metadata = %q", metadata)
241+ if strings.Contains(metadata, "budget:") {
242+ t.Fatalf("usage metadata carries budget = %q", metadata)
243+ }
244+ if !strings.Contains(metadata, "16.6%/272k") {
245+ t.Fatalf("usage metadata missing context gauge = %q", metadata)
242246 }
243247 }
244248
@@ -233,12 +233,16 @@ func TestRenderResidentSubagentActivityLinesRightAlignsUsageMetadataWhenItFits(t
233 }233 }
234 }234 }
235 235
236-func TestResidentUsageMetadataShowsRolloutBudget(t *testing.T) {236+func TestResidentUsageMetadataShowsContextOnly(t *testing.T) {
237 metadata := residentUsageMetadata(subagents.ResidentSnapshot{237 metadata := residentUsageMetadata(subagents.ResidentSnapshot{
238- Budget: subagents.BudgetSnapshot{Used: 337_500, Limit: 500_000, Percent: 67, State: subagents.BudgetNormal},238+ Usage: provider.Usage{InputTokens: 84_000, OutputTokens: 1_500},
239+ ContextUsed: 45_152, ContextMax: 272_000,
239 })240 })
240- if metadata != "budget:normal 67%/500k" {241+ if strings.Contains(metadata, "budget:") {
241- t.Fatalf("budget metadata = %q", metadata)242+ t.Fatalf("usage metadata carries budget = %q", metadata)
243+ }
244+ if !strings.Contains(metadata, "16.6%/272k") {
245+ t.Fatalf("usage metadata missing context gauge = %q", metadata)
242 }246 }
243 }247 }
244 248
M packages/agent/modes/resident_subagents_dialog_test.go +5 -3
@@ -100,12 +100,14 @@ func TestResidentResultStatusIncludesFinalSummary(t *testing.T) {
100100 }
101101 }
102102
103-func TestResidentResultStatusSanitizesHandoff(t *testing.T) {
103+func TestResidentResultStatusSanitizesDeprecatedHandoff(t *testing.T) {
104+ // Archived v2 results may still carry a handoff; the status line must
105+ // sanitize control sequences exactly like any other summary text.
104106 got := residentResultStatus("child", subagents.ResidentResult{
105- State: subagents.ResidentBudgetExhausted,
107+ State: subagents.ResidentFailed,
106108 Handoff: "Workspace: /tmp/\x1b[31mrepo\x1b[0m\x1b]52;c;secret\a\r\x00",
107109 })
108- if got != "budget_exhausted result child: Workspace: /tmp/repo" {
110+ if got != "failed result child: Workspace: /tmp/repo" {
109111 t.Fatalf("result status = %q", got)
110112 }
111113 }
@@ -100,12 +100,14 @@ func TestResidentResultStatusIncludesFinalSummary(t *testing.T) {
100 }100 }
101 }101 }
102 102
103-func TestResidentResultStatusSanitizesHandoff(t *testing.T) {103+func TestResidentResultStatusSanitizesDeprecatedHandoff(t *testing.T) {
104+ // Archived v2 results may still carry a handoff; the status line must
105+ // sanitize control sequences exactly like any other summary text.
104 got := residentResultStatus("child", subagents.ResidentResult{106 got := residentResultStatus("child", subagents.ResidentResult{
105- State: subagents.ResidentBudgetExhausted,107+ State: subagents.ResidentFailed,
106 Handoff: "Workspace: /tmp/\x1b[31mrepo\x1b[0m\x1b]52;c;secret\a\r\x00",108 Handoff: "Workspace: /tmp/\x1b[31mrepo\x1b[0m\x1b]52;c;secret\a\r\x00",
107 })109 })
108- if got != "budget_exhausted result child: Workspace: /tmp/repo" {110+ if got != "failed result child: Workspace: /tmp/repo" {
109 t.Fatalf("result status = %q", got)111 t.Fatalf("result status = %q", got)
110 }112 }
111 }113 }
M packages/agent/subagent_factory.go +30 -28
@@ -2,11 +2,9 @@ package agent
22
33 import (
44 "context"
5- "errors"
65 "fmt"
76 "strings"
87 "sync"
9- "sync/atomic"
108
119 "github.com/bnema/zut/packages/agent/subagents"
1210 "github.com/bnema/zut/packages/agent/tools"
@@ -38,12 +36,14 @@ func newResidentChildRunner(args Args, spec subagents.ResidentChildSpec, journal
3836 system = strings.TrimSpace(system) + "\n\n" + extra
3937 }
4038 }
41- if err := subagents.ValidateResidentBudget(spec); err != nil {
42- return nil, fmt.Errorf("resident child %q: %w", spec.ID, err)
43- }
44- budgetLimit := spec.BudgetLimit
4539 agent := core.NewAgent(resolved.NewClient(), resolved.Model, system, registry)
4640 agent.MaxSteps = resolved.MaxSteps
41+ if agent.MaxSteps <= 0 {
42+ // Resident execution must stay bounded once the cumulative rollout
43+ // budget is gone. An unlimited parent default must not propagate to
44+ // children; explicit parent limits are preserved above.
45+ agent.MaxSteps = 50
46+ }
4747 agent.ContextWindow = resolved.ContextWindow
4848 agent.MaxTokens = resolved.MaxOutput
4949 agent.Reasoning = resolved.Reasoning
@@ -54,8 +54,9 @@ func newResidentChildRunner(args Args, spec subagents.ResidentChildSpec, journal
5454 baseline = journal.ConfigureUsage(resolved.ContextWindow, resolved.AuthMethod == "oauth")
5555 agent.SeedCost(baseline.Usage)
5656 }
57- budget := subagents.NewRolloutBudget(budgetLimit, baseline.Usage)
58- configureResidentBudget(agent, budget)
57+ // The nudge reads the agent's own per-turn usage gauge. No cumulative
58+ // budget remains: execution stays bounded through the MaxSteps default.
59+ configureResidentContextNudge(agent, resolved.ContextWindow)
5960 rootCacheID := strings.TrimSpace(spec.RootCacheID)
6061 if rootCacheID == "" {
6162 // Journals written before root cache identity was persisted can still
@@ -83,18 +84,9 @@ func newResidentChildRunner(args Args, spec subagents.ResidentChildSpec, journal
8384 }
8485 turnCtx, cancel := context.WithCancel(ctx)
8586 defer cancel()
86- budget.SetBaseline(journal.BudgetBaseline())
87- startedExhausted := budget.Snapshot().State == subagents.BudgetExceeded
88- var budgetStopped atomic.Bool
8987 var journalErr error
9088 var journalMu sync.Mutex
9189 err := agent.Prompt(turnCtx, prompt, nil, func(event core.AgentEvent) {
92- if end, ok := event.(core.EvTurnEnd); ok && end.Err != nil && budget.Snapshot().State == subagents.BudgetExceeded {
93- budgetStopped.Store(true)
94- }
95- if usage, ok := event.(core.EvUsage); ok {
96- budget.Observe(usage.Cumulative)
97- }
9890 if err := journal.RecordAgentEvent(event); err != nil {
9991 journalMu.Lock()
10092 if journalErr == nil {
@@ -109,25 +101,35 @@ func newResidentChildRunner(args Args, spec subagents.ResidentChildSpec, journal
109101 if journalErr != nil {
110102 return fmt.Errorf("persist resident child transcript: %w", journalErr)
111103 }
112- if budget.Snapshot().State == subagents.BudgetExceeded {
113- if !startedExhausted && !budgetStopped.Load() && err == nil {
114- return nil
115- }
116- return errors.Join(err, subagents.ErrBudgetExceeded)
117- }
118104 return err
119105 }, nil
120106 }
121107
122-func configureResidentBudget(agent *core.Agent, budget *subagents.RolloutBudget) {
123- if agent == nil || budget == nil {
108+// residentContextNudgePercent is the single context-usage threshold for the
109+// resident reminder. It is a completion aid, not a cost guard: execution
110+// stays bounded through the finite MaxSteps default.
111+const residentContextNudgePercent = 85
112+
113+func configureResidentContextNudge(agent *core.Agent, contextMax int) {
114+ if agent == nil {
124115 return
125116 }
117+ // notified is owned by the runner closure and never reset from Resume
118+ // or journal paths. The injected developer-context message persists via
119+ // appendDynamicContext and is not replayed verbatim from the child
120+ // journal after restart; re-nudging after restart is acceptable.
121+ var notified bool
126122 agent.BeforeTurnContext = func(_ context.Context, _ int) (bool, string, string) {
127- if budget.Snapshot().State == subagents.BudgetExceeded {
128- return false, subagents.ErrBudgetExceeded.Error(), ""
123+ if notified || contextMax <= 0 {
124+ return true, "", ""
125+ }
126+ usage := agent.LastTurnUsage()
127+ used := usage.InputTokens + usage.CacheReadTokens + usage.CacheWriteTokens
128+ if used*100 < contextMax*residentContextNudgePercent {
129+ return true, "", ""
129130 }
130- return true, "", ""
131+ notified = true
132+ return true, "", fmt.Sprintf("Context usage is at %d%% of the model window. Finish the current task, report results, limits, and remaining verifications, and do not start broad new work.", used*100/contextMax)
131133 }
132134 }
133135
@@ -2,11 +2,9 @@ package agent
2 2
3 import (3 import (
4 "context"4 "context"
5- "errors"
6 "fmt"5 "fmt"
7 "strings"6 "strings"
8 "sync"7 "sync"
9- "sync/atomic"
10 8
11 "github.com/bnema/zut/packages/agent/subagents"9 "github.com/bnema/zut/packages/agent/subagents"
12 "github.com/bnema/zut/packages/agent/tools"10 "github.com/bnema/zut/packages/agent/tools"
@@ -38,12 +36,14 @@ func newResidentChildRunner(args Args, spec subagents.ResidentChildSpec, journal
38 system = strings.TrimSpace(system) + "\n\n" + extra36 system = strings.TrimSpace(system) + "\n\n" + extra
39 }37 }
40 }38 }
41- if err := subagents.ValidateResidentBudget(spec); err != nil {
42- return nil, fmt.Errorf("resident child %q: %w", spec.ID, err)
43- }
44- budgetLimit := spec.BudgetLimit
45 agent := core.NewAgent(resolved.NewClient(), resolved.Model, system, registry)39 agent := core.NewAgent(resolved.NewClient(), resolved.Model, system, registry)
46 agent.MaxSteps = resolved.MaxSteps40 agent.MaxSteps = resolved.MaxSteps
41+ if agent.MaxSteps <= 0 {
42+ // Resident execution must stay bounded once the cumulative rollout
43+ // budget is gone. An unlimited parent default must not propagate to
44+ // children; explicit parent limits are preserved above.
45+ agent.MaxSteps = 50
46+ }
47 agent.ContextWindow = resolved.ContextWindow47 agent.ContextWindow = resolved.ContextWindow
48 agent.MaxTokens = resolved.MaxOutput48 agent.MaxTokens = resolved.MaxOutput
49 agent.Reasoning = resolved.Reasoning49 agent.Reasoning = resolved.Reasoning
@@ -54,8 +54,9 @@ func newResidentChildRunner(args Args, spec subagents.ResidentChildSpec, journal
54 baseline = journal.ConfigureUsage(resolved.ContextWindow, resolved.AuthMethod == "oauth")54 baseline = journal.ConfigureUsage(resolved.ContextWindow, resolved.AuthMethod == "oauth")
55 agent.SeedCost(baseline.Usage)55 agent.SeedCost(baseline.Usage)
56 }56 }
57- budget := subagents.NewRolloutBudget(budgetLimit, baseline.Usage)57+ // The nudge reads the agent's own per-turn usage gauge. No cumulative
58- configureResidentBudget(agent, budget)58+ // budget remains: execution stays bounded through the MaxSteps default.
59+ configureResidentContextNudge(agent, resolved.ContextWindow)
59 rootCacheID := strings.TrimSpace(spec.RootCacheID)60 rootCacheID := strings.TrimSpace(spec.RootCacheID)
60 if rootCacheID == "" {61 if rootCacheID == "" {
61 // Journals written before root cache identity was persisted can still62 // Journals written before root cache identity was persisted can still
@@ -83,18 +84,9 @@ func newResidentChildRunner(args Args, spec subagents.ResidentChildSpec, journal
83 }84 }
84 turnCtx, cancel := context.WithCancel(ctx)85 turnCtx, cancel := context.WithCancel(ctx)
85 defer cancel()86 defer cancel()
86- budget.SetBaseline(journal.BudgetBaseline())
87- startedExhausted := budget.Snapshot().State == subagents.BudgetExceeded
88- var budgetStopped atomic.Bool
89 var journalErr error87 var journalErr error
90 var journalMu sync.Mutex88 var journalMu sync.Mutex
91 err := agent.Prompt(turnCtx, prompt, nil, func(event core.AgentEvent) {89 err := agent.Prompt(turnCtx, prompt, nil, func(event core.AgentEvent) {
92- if end, ok := event.(core.EvTurnEnd); ok && end.Err != nil && budget.Snapshot().State == subagents.BudgetExceeded {
93- budgetStopped.Store(true)
94- }
95- if usage, ok := event.(core.EvUsage); ok {
96- budget.Observe(usage.Cumulative)
97- }
98 if err := journal.RecordAgentEvent(event); err != nil {90 if err := journal.RecordAgentEvent(event); err != nil {
99 journalMu.Lock()91 journalMu.Lock()
100 if journalErr == nil {92 if journalErr == nil {
@@ -109,25 +101,35 @@ func newResidentChildRunner(args Args, spec subagents.ResidentChildSpec, journal
109 if journalErr != nil {101 if journalErr != nil {
110 return fmt.Errorf("persist resident child transcript: %w", journalErr)102 return fmt.Errorf("persist resident child transcript: %w", journalErr)
111 }103 }
112- if budget.Snapshot().State == subagents.BudgetExceeded {
113- if !startedExhausted && !budgetStopped.Load() && err == nil {
114- return nil
115- }
116- return errors.Join(err, subagents.ErrBudgetExceeded)
117- }
118 return err104 return err
119 }, nil105 }, nil
120 }106 }
121 107
122-func configureResidentBudget(agent *core.Agent, budget *subagents.RolloutBudget) {108+// residentContextNudgePercent is the single context-usage threshold for the
123- if agent == nil || budget == nil {109+// resident reminder. It is a completion aid, not a cost guard: execution
110+// stays bounded through the finite MaxSteps default.
111+const residentContextNudgePercent = 85
112+
113+func configureResidentContextNudge(agent *core.Agent, contextMax int) {
114+ if agent == nil {
124 return115 return
125 }116 }
117+ // notified is owned by the runner closure and never reset from Resume
118+ // or journal paths. The injected developer-context message persists via
119+ // appendDynamicContext and is not replayed verbatim from the child
120+ // journal after restart; re-nudging after restart is acceptable.
121+ var notified bool
126 agent.BeforeTurnContext = func(_ context.Context, _ int) (bool, string, string) {122 agent.BeforeTurnContext = func(_ context.Context, _ int) (bool, string, string) {
127- if budget.Snapshot().State == subagents.BudgetExceeded {123+ if notified || contextMax <= 0 {
128- return false, subagents.ErrBudgetExceeded.Error(), ""124+ return true, "", ""
125+ }
126+ usage := agent.LastTurnUsage()
127+ used := usage.InputTokens + usage.CacheReadTokens + usage.CacheWriteTokens
128+ if used*100 < contextMax*residentContextNudgePercent {
129+ return true, "", ""
129 }130 }
130- return true, "", ""131+ notified = true
132+ return true, "", fmt.Sprintf("Context usage is at %d%% of the model window. Finish the current task, report results, limits, and remaining verifications, and do not start broad new work.", used*100/contextMax)
131 }133 }
132 }134 }
133 135
M packages/agent/subagent_factory_test.go +219 -54
@@ -8,6 +8,7 @@ import (
88 "net/http"
99 "net/http/httptest"
1010 "strings"
11+ "sync"
1112 "sync/atomic"
1213 "testing"
1314 "time"
@@ -17,6 +18,168 @@ import (
1718 "github.com/bnema/zut/packages/provider"
1819 )
1920
21+func TestResidentChildRunnerDefaultsToFiniteMaxSteps(t *testing.T) {
22+ for _, tc := range []struct {
23+ name string
24+ maxSteps int
25+ want int
26+ }{
27+ {name: "unlimited parent defaults to bounded child", maxSteps: 0, want: 50},
28+ {name: "explicit parent limit preserved", maxSteps: 30, want: 30},
29+ } {
30+ t.Run(tc.name, func(t *testing.T) {
31+ var requests atomic.Int32
32+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
33+ requests.Add(1)
34+ w.Header().Set("content-type", "text/event-stream")
35+ writeOpenAIChunk(t, w, map[string]any{
36+ "choices": []any{map[string]any{
37+ "index": 0,
38+ "delta": map[string]any{"tool_calls": []any{map[string]any{
39+ "index": 0, "id": "call-loop", "type": "function",
40+ "function": map[string]string{"name": "read", "arguments": `{"path":"x"}`},
41+ }}},
42+ "finish_reason": "tool_calls",
43+ }},
44+ "usage": map[string]int{"prompt_tokens": 10, "completion_tokens": 1},
45+ })
46+ }))
47+ defer server.Close()
48+
49+ spec := subagents.ResidentChildSpec{
50+ ID: "maxsteps-child", SessionID: "maxsteps-session", Provider: "openai", Model: "gpt-4o",
51+ Tools: []string{"read"},
52+ }
53+ journal, err := subagents.OpenResidentJournal(t.TempDir(), spec.ID)
54+ if err != nil {
55+ t.Fatal(err)
56+ }
57+ t.Cleanup(func() { _ = journal.Close() })
58+ if err := journal.Accept(spec, "review"); err != nil {
59+ t.Fatal(err)
60+ }
61+ runner, err := newResidentChildRunner(Args{
62+ Provider: "openai", Model: "gpt-4o", APIKey: "synthetic", BaseURL: server.URL,
63+ MaxSteps: tc.maxSteps, CWD: t.TempDir(), NoContextFiles: true, NoSkill: true,
64+ }, spec, journal)
65+ if err != nil {
66+ t.Fatal(err)
67+ }
68+ err = runner(t.Context(), "review")
69+ if !errors.Is(err, core.ErrMaxSteps) {
70+ t.Fatalf("runner error = %v, want step-limit stop", err)
71+ }
72+ if !strings.Contains(err.Error(), fmt.Sprintf("%d", tc.want)) {
73+ t.Fatalf("runner error = %v, want limit %d", err, tc.want)
74+ }
75+ if got := requests.Load(); got != int32(tc.want) {
76+ t.Fatalf("provider requests = %d, want %d", got, tc.want)
77+ }
78+ })
79+ }
80+}
81+
82+func TestResidentChildRunnerInjectsContextReminderIntoNextRequest(t *testing.T) {
83+ var firstUsage atomic.Int64
84+ var captured []string
85+ var mu sync.Mutex
86+ var requests atomic.Int32
87+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
88+ number := requests.Add(1)
89+ var body map[string]any
90+ if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
91+ t.Errorf("decode request: %v", err)
92+ }
93+ encoded, _ := json.Marshal(body["messages"])
94+ mu.Lock()
95+ captured = append(captured, string(encoded))
96+ mu.Unlock()
97+ w.Header().Set("content-type", "text/event-stream")
98+ usage := map[string]int{"prompt_tokens": 10, "completion_tokens": 1}
99+ text := "second result"
100+ if number == 1 {
101+ text = "first result"
102+ usage = map[string]int{"prompt_tokens": int(firstUsage.Load()), "completion_tokens": 1}
103+ }
104+ writeOpenAIChunk(t, w, map[string]any{
105+ "choices": []any{map[string]any{"index": 0, "delta": map[string]string{"content": text}, "finish_reason": "stop"}},
106+ "usage": usage,
107+ })
108+ }))
109+ defer server.Close()
110+
111+ args := Args{Provider: "openai", Model: "gpt-4o", APIKey: "synthetic", BaseURL: server.URL, CWD: t.TempDir(), NoContextFiles: true, NoSkill: true}
112+ resolved, err := Resolve(args, true)
113+ if err != nil {
114+ t.Fatal(err)
115+ }
116+ if resolved.ContextWindow <= 0 {
117+ t.Fatalf("resolved context window = %d, want positive", resolved.ContextWindow)
118+ }
119+ // Cross the 85% threshold on the first turn.
120+ firstUsage.Store(int64(resolved.ContextWindow * 86 / 100))
121+ spec := subagents.ResidentChildSpec{
122+ ID: "nudge-child", SessionID: "nudge-session", Provider: "openai", Model: "gpt-4o",
123+ Tools: []string{"read"},
124+ }
125+ journal, err := subagents.OpenResidentJournal(t.TempDir(), spec.ID)
126+ if err != nil {
127+ t.Fatal(err)
128+ }
129+ t.Cleanup(func() { _ = journal.Close() })
130+ if err := journal.Accept(spec, "review"); err != nil {
131+ t.Fatal(err)
132+ }
133+ runner, err := newResidentChildRunner(args, spec, journal)
134+ if err != nil {
135+ t.Fatal(err)
136+ }
137+ if err := runner(t.Context(), "review"); err != nil {
138+ t.Fatal(err)
139+ }
140+ if err := runner(t.Context(), "follow up"); err != nil {
141+ t.Fatal(err)
142+ }
143+ if got := requests.Load(); got != 2 {
144+ t.Fatalf("provider requests = %d, want 2", got)
145+ }
146+ mu.Lock()
147+ defer mu.Unlock()
148+ if len(captured) != 2 {
149+ t.Fatalf("captured requests = %d, want 2", len(captured))
150+ }
151+ if strings.Contains(captured[0], "Context usage is at") {
152+ t.Fatalf("first request carries a reminder before any usage: %s", captured[0])
153+ }
154+ want := fmt.Sprintf("Context usage is at %d%%", firstUsage.Load()*100/int64(resolved.ContextWindow))
155+ if !strings.Contains(captured[1], want) {
156+ t.Fatalf("second request missing %q: %s", want, captured[1])
157+ }
158+ // The reminder travels as developer context in the live request; the
159+ // durable transcript keeps only finalized history.
160+ messages, err := subagents.ReadResidentTranscriptMessages(journal.Dir())
161+ if err != nil {
162+ t.Fatal(err)
163+ }
164+ var texts []string
165+ for _, message := range messages {
166+ for _, content := range message.Content {
167+ if block, ok := content.(provider.TextBlock); ok {
168+ texts = append(texts, block.Text)
169+ }
170+ }
171+ }
172+ joined := strings.Join(texts, "\n")
173+ for _, want := range []string{"first result", "second result"} {
174+ if !strings.Contains(joined, want) {
175+ t.Fatalf("journal history missing %q: %q", want, joined)
176+ }
177+ }
178+ if strings.Contains(joined, "Context usage is at") {
179+ t.Fatalf("journal transcript carries the reminder: %q", joined)
180+ }
181+}
182+
20183 func TestResidentChildRegistryUsesExactToolListAndForbidsDelegation(t *testing.T) {
21184 catalogue := core.Registry{
22185 "read": nil,
@@ -39,28 +202,56 @@ func TestResidentChildRegistryUsesExactToolListAndForbidsDelegation(t *testing.T
39202 }
40203 }
41204
42-func TestConfigureResidentBudgetOnlyStopsAtExhaustion(t *testing.T) {
205+func TestConfigureResidentContextNudgeRemindsOnceAtThreshold(t *testing.T) {
43206 agent := core.NewAgent(nil, "model", "system", core.Registry{"read": nil})
44- budget := subagents.NewRolloutBudget(1_000, provider.Usage{})
45- configureResidentBudget(agent, budget)
207+ configureResidentContextNudge(agent, 1_000)
46208
47- budget.Observe(provider.Usage{InputTokens: 900})
48- allowed, reason, contextText := agent.BeforeTurnContext(t.Context(), 1)
49- if !allowed || reason != "" || contextText != "" {
50- t.Fatalf("near-limit context = allowed=%t reason=%q context=%q", allowed, reason, contextText)
209+ check := func(usage provider.Usage, wantReminder bool) string {
210+ t.Helper()
211+ agent.SeedLastTurnUsage(usage)
212+ allowed, reason, contextText := agent.BeforeTurnContext(t.Context(), 1)
213+ if !allowed || reason != "" {
214+ t.Fatalf("nudge blocked turn: allowed=%t reason=%q", allowed, reason)
215+ }
216+ if wantReminder && !strings.Contains(contextText, "Context usage is at ") {
217+ t.Fatalf("missing reminder for usage %#v: %q", usage, contextText)
218+ }
219+ if !wantReminder && contextText != "" {
220+ t.Fatalf("unexpected reminder for usage %#v: %q", usage, contextText)
221+ }
222+ return contextText
223+ }
224+
225+ // Below the threshold: silent, including cache-heavy usage.
226+ check(provider.Usage{InputTokens: 849}, false)
227+ check(provider.Usage{InputTokens: 400, CacheReadTokens: 400, CacheWriteTokens: 49}, false)
228+ // First crossing: exactly one reminder carrying the computed percent.
229+ first := check(provider.Usage{InputTokens: 850}, true)
230+ if !strings.Contains(first, "85%") {
231+ t.Fatalf("reminder percent = %q, want 85%%", first)
51232 }
52- if len(agent.ToolsSnapshot()) != 1 || agent.MaxTokens != 0 {
53- t.Fatalf("near-limit agent = tools=%#v max_tokens=%d", agent.ToolsSnapshot(), agent.MaxTokens)
233+ // Later turns stay silent even as usage grows.
234+ check(provider.Usage{InputTokens: 970}, false)
235+}
236+
237+func TestConfigureResidentContextNudgeHandlesDirectJumpAndUnknownWindow(t *testing.T) {
238+ agent := core.NewAgent(nil, "model", "system", core.Registry{"read": nil})
239+ configureResidentContextNudge(agent, 1_000)
240+ agent.SeedLastTurnUsage(provider.Usage{InputTokens: 970})
241+ allowed, _, contextText := agent.BeforeTurnContext(t.Context(), 1)
242+ if !allowed || !strings.Contains(contextText, "97%") {
243+ t.Fatalf("jump reminder = allowed=%t context=%q, want one 97%% reminder", allowed, contextText)
54244 }
55245
56- budget.Observe(provider.Usage{InputTokens: 1_000})
57- allowed, reason, _ = agent.BeforeTurnContext(t.Context(), 2)
58- if allowed || reason != subagents.ErrBudgetExceeded.Error() {
59- t.Fatalf("exhausted context = allowed=%t reason=%q", allowed, reason)
246+ unknown := core.NewAgent(nil, "model", "system", core.Registry{"read": nil})
247+ configureResidentContextNudge(unknown, 0)
248+ unknown.SeedLastTurnUsage(provider.Usage{InputTokens: 970})
249+ if allowed, reason, contextText := unknown.BeforeTurnContext(t.Context(), 1); !allowed || reason != "" || contextText != "" {
250+ t.Fatalf("unknown window context = allowed=%t reason=%q context=%q, want silent", allowed, reason, contextText)
60251 }
61252 }
62253
63-func TestResidentChildRunnerKeepsToolsAtNinetyPercent(t *testing.T) {
254+func TestResidentChildRunnerKeepsToolsAcrossTurns(t *testing.T) {
64255 var requests atomic.Int32
65256 server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
66257 requestNumber := requests.Add(1)
@@ -99,7 +290,7 @@ func TestResidentChildRunnerKeepsToolsAtNinetyPercent(t *testing.T) {
99290 }))
100291 defer server.Close()
101292
102- runner, journal := newBudgetedResidentTestRunner(t, server.URL, 1_000)
293+ runner, journal := newResidentTestRunner(t, server.URL)
103294 if err := runner(t.Context(), "review"); err != nil {
104295 t.Fatal(err)
105296 }
@@ -111,7 +302,7 @@ func TestResidentChildRunnerKeepsToolsAtNinetyPercent(t *testing.T) {
111302 }
112303 }
113304
114-func TestResidentChildRunnerTerminatesAtBudgetLimit(t *testing.T) {
305+func TestResidentChildRunnerRunsHighUsageFollowUpWithoutBudget(t *testing.T) {
115306 var requests atomic.Int32
116307 server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
117308 requests.Add(1)
@@ -130,7 +321,7 @@ func TestResidentChildRunnerTerminatesAtBudgetLimit(t *testing.T) {
130321 }))
131322 defer server.Close()
132323
133- runner, journal := newBudgetedResidentTestRunner(t, server.URL, 1_000)
324+ runner, journal := newResidentTestRunner(t, server.URL)
134325 if err := runner(t.Context(), "review"); err != nil {
135326 t.Fatalf("runner error = %v, want terminal result", err)
136327 }
@@ -140,42 +331,16 @@ func TestResidentChildRunnerTerminatesAtBudgetLimit(t *testing.T) {
140331 if summary := latestResidentAssistantText(t, journal); summary != "best available result" {
141332 t.Fatalf("latest summary = %q", summary)
142333 }
143- if err := runner(t.Context(), "continue"); !errors.Is(err, subagents.ErrBudgetExceeded) {
144- t.Fatalf("exhausted follow-up error = %v", err)
145- }
146- if got := requests.Load(); got != 1 {
147- t.Fatalf("exhausted follow-up made %d provider requests, want 1 total", got)
334+ // Without a cumulative budget, a high-usage follow-up runs normally.
335+ if err := runner(t.Context(), "continue"); err != nil {
336+ t.Fatalf("follow-up error = %v, want success", err)
148337 }
149-}
150-
151-func TestResidentChildRunnerReportsBudgetStopAfterToolWork(t *testing.T) {
152- var requests atomic.Int32
153- server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
154- requests.Add(1)
155- w.Header().Set("content-type", "text/event-stream")
156- writeOpenAIChunk(t, w, map[string]any{
157- "choices": []any{map[string]any{
158- "index": 0,
159- "delta": map[string]any{"tool_calls": []any{map[string]any{
160- "index": 0, "id": "call-budget", "type": "function",
161- "function": map[string]string{"name": "read", "arguments": `{"path":"x"}`},
162- }}},
163- "finish_reason": "tool_calls",
164- }},
165- "usage": map[string]int{"prompt_tokens": 1200, "completion_tokens": 0},
166- })
167- }))
168- defer server.Close()
169- runner, _ := newBudgetedResidentTestRunner(t, server.URL, 1000)
170- if err := runner(t.Context(), "review"); !errors.Is(err, subagents.ErrBudgetExceeded) {
171- t.Fatalf("budget-stopped tool turn = %v, want ErrBudgetExceeded", err)
172- }
173- if got := requests.Load(); got != 1 {
174- t.Fatalf("requests = %d, want 1", got)
338+ if got := requests.Load(); got != 2 {
339+ t.Fatalf("provider requests = %d, want 2", got)
175340 }
176341 }
177342
178-func TestResidentChildRunnerResumesWithSameSizeAllowance(t *testing.T) {
343+func TestResidentChildRunnerResumesWithRetainedHistory(t *testing.T) {
179344 var requests atomic.Int32
180345 server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
181346 request := requests.Add(1)
@@ -208,7 +373,7 @@ func TestResidentChildRunnerResumesWithSameSizeAllowance(t *testing.T) {
208373 t.Cleanup(func() { _ = manager.Close(context.Background()) })
209374 completed := make(chan subagents.ResidentCompletion, 2)
210375 manager.SetCompletionObserver(func(c subagents.ResidentCompletion) { completed <- c })
211- spec := subagents.ResidentChildSpec{ID: "resume-budget", SessionID: "session", Provider: "openai", Model: "gpt-4o", BudgetLimit: 1000, BudgetSource: "model_context"}
376+ spec := subagents.ResidentChildSpec{ID: "resume-child", SessionID: "session", Provider: "openai", Model: "gpt-4o"}
212377 if _, err := manager.Spawn(t.Context(), spec, "review"); err != nil {
213378 t.Fatal(err)
214379 }
@@ -228,16 +393,16 @@ func TestResidentChildRunnerResumesWithSameSizeAllowance(t *testing.T) {
228393 }
229394 }
230395 snapshot, _ := manager.SnapshotFor(spec.ID)
231- if requests.Load() != 2 || snapshot.Budget.Used != 30 || snapshot.Budget.Limit != 1000 || snapshot.Usage.InputTokens != 1220 {
396+ if requests.Load() != 2 || snapshot.Usage.InputTokens != 1220 {
232397 t.Fatalf("requests=%d, snapshot=%#v", requests.Load(), snapshot)
233398 }
234399 }
235400
236-func newBudgetedResidentTestRunner(t *testing.T, baseURL string, limit int64) (subagents.ResidentTurnRunner, *subagents.ResidentJournal) {
401+func newResidentTestRunner(t *testing.T, baseURL string) (subagents.ResidentTurnRunner, *subagents.ResidentJournal) {
237402 t.Helper()
238403 spec := subagents.ResidentChildSpec{
239- ID: "budget-child", SessionID: "budget-session", Provider: "openai", Model: "gpt-4o",
240- Tools: []string{"read"}, BudgetLimit: limit, BudgetSource: "model_context",
404+ ID: "resident-child", SessionID: "resident-session", Provider: "openai", Model: "gpt-4o",
405+ Tools: []string{"read"},
241406 }
242407 journal, err := subagents.OpenResidentJournal(t.TempDir(), spec.ID)
243408 if err != nil {
@@ -8,6 +8,7 @@ import (
8 "net/http"8 "net/http"
9 "net/http/httptest"9 "net/http/httptest"
10 "strings"10 "strings"
11+ "sync"
11 "sync/atomic"12 "sync/atomic"
12 "testing"13 "testing"
13 "time"14 "time"
@@ -17,6 +18,168 @@ import (
17 "github.com/bnema/zut/packages/provider"18 "github.com/bnema/zut/packages/provider"
18 )19 )
19 20
21+func TestResidentChildRunnerDefaultsToFiniteMaxSteps(t *testing.T) {
22+ for _, tc := range []struct {
23+ name string
24+ maxSteps int
25+ want int
26+ }{
27+ {name: "unlimited parent defaults to bounded child", maxSteps: 0, want: 50},
28+ {name: "explicit parent limit preserved", maxSteps: 30, want: 30},
29+ } {
30+ t.Run(tc.name, func(t *testing.T) {
31+ var requests atomic.Int32
32+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
33+ requests.Add(1)
34+ w.Header().Set("content-type", "text/event-stream")
35+ writeOpenAIChunk(t, w, map[string]any{
36+ "choices": []any{map[string]any{
37+ "index": 0,
38+ "delta": map[string]any{"tool_calls": []any{map[string]any{
39+ "index": 0, "id": "call-loop", "type": "function",
40+ "function": map[string]string{"name": "read", "arguments": `{"path":"x"}`},
41+ }}},
42+ "finish_reason": "tool_calls",
43+ }},
44+ "usage": map[string]int{"prompt_tokens": 10, "completion_tokens": 1},
45+ })
46+ }))
47+ defer server.Close()
48+
49+ spec := subagents.ResidentChildSpec{
50+ ID: "maxsteps-child", SessionID: "maxsteps-session", Provider: "openai", Model: "gpt-4o",
51+ Tools: []string{"read"},
52+ }
53+ journal, err := subagents.OpenResidentJournal(t.TempDir(), spec.ID)
54+ if err != nil {
55+ t.Fatal(err)
56+ }
57+ t.Cleanup(func() { _ = journal.Close() })
58+ if err := journal.Accept(spec, "review"); err != nil {
59+ t.Fatal(err)
60+ }
61+ runner, err := newResidentChildRunner(Args{
62+ Provider: "openai", Model: "gpt-4o", APIKey: "synthetic", BaseURL: server.URL,
63+ MaxSteps: tc.maxSteps, CWD: t.TempDir(), NoContextFiles: true, NoSkill: true,
64+ }, spec, journal)
65+ if err != nil {
66+ t.Fatal(err)
67+ }
68+ err = runner(t.Context(), "review")
69+ if !errors.Is(err, core.ErrMaxSteps) {
70+ t.Fatalf("runner error = %v, want step-limit stop", err)
71+ }
72+ if !strings.Contains(err.Error(), fmt.Sprintf("%d", tc.want)) {
73+ t.Fatalf("runner error = %v, want limit %d", err, tc.want)
74+ }
75+ if got := requests.Load(); got != int32(tc.want) {
76+ t.Fatalf("provider requests = %d, want %d", got, tc.want)
77+ }
78+ })
79+ }
80+}
81+
82+func TestResidentChildRunnerInjectsContextReminderIntoNextRequest(t *testing.T) {
83+ var firstUsage atomic.Int64
84+ var captured []string
85+ var mu sync.Mutex
86+ var requests atomic.Int32
87+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
88+ number := requests.Add(1)
89+ var body map[string]any
90+ if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
91+ t.Errorf("decode request: %v", err)
92+ }
93+ encoded, _ := json.Marshal(body["messages"])
94+ mu.Lock()
95+ captured = append(captured, string(encoded))
96+ mu.Unlock()
97+ w.Header().Set("content-type", "text/event-stream")
98+ usage := map[string]int{"prompt_tokens": 10, "completion_tokens": 1}
99+ text := "second result"
100+ if number == 1 {
101+ text = "first result"
102+ usage = map[string]int{"prompt_tokens": int(firstUsage.Load()), "completion_tokens": 1}
103+ }
104+ writeOpenAIChunk(t, w, map[string]any{
105+ "choices": []any{map[string]any{"index": 0, "delta": map[string]string{"content": text}, "finish_reason": "stop"}},
106+ "usage": usage,
107+ })
108+ }))
109+ defer server.Close()
110+
111+ args := Args{Provider: "openai", Model: "gpt-4o", APIKey: "synthetic", BaseURL: server.URL, CWD: t.TempDir(), NoContextFiles: true, NoSkill: true}
112+ resolved, err := Resolve(args, true)
113+ if err != nil {
114+ t.Fatal(err)
115+ }
116+ if resolved.ContextWindow <= 0 {
117+ t.Fatalf("resolved context window = %d, want positive", resolved.ContextWindow)
118+ }
119+ // Cross the 85% threshold on the first turn.
120+ firstUsage.Store(int64(resolved.ContextWindow * 86 / 100))
121+ spec := subagents.ResidentChildSpec{
122+ ID: "nudge-child", SessionID: "nudge-session", Provider: "openai", Model: "gpt-4o",
123+ Tools: []string{"read"},
124+ }
125+ journal, err := subagents.OpenResidentJournal(t.TempDir(), spec.ID)
126+ if err != nil {
127+ t.Fatal(err)
128+ }
129+ t.Cleanup(func() { _ = journal.Close() })
130+ if err := journal.Accept(spec, "review"); err != nil {
131+ t.Fatal(err)
132+ }
133+ runner, err := newResidentChildRunner(args, spec, journal)
134+ if err != nil {
135+ t.Fatal(err)
136+ }
137+ if err := runner(t.Context(), "review"); err != nil {
138+ t.Fatal(err)
139+ }
140+ if err := runner(t.Context(), "follow up"); err != nil {
141+ t.Fatal(err)
142+ }
143+ if got := requests.Load(); got != 2 {
144+ t.Fatalf("provider requests = %d, want 2", got)
145+ }
146+ mu.Lock()
147+ defer mu.Unlock()
148+ if len(captured) != 2 {
149+ t.Fatalf("captured requests = %d, want 2", len(captured))
150+ }
151+ if strings.Contains(captured[0], "Context usage is at") {
152+ t.Fatalf("first request carries a reminder before any usage: %s", captured[0])
153+ }
154+ want := fmt.Sprintf("Context usage is at %d%%", firstUsage.Load()*100/int64(resolved.ContextWindow))
155+ if !strings.Contains(captured[1], want) {
156+ t.Fatalf("second request missing %q: %s", want, captured[1])
157+ }
158+ // The reminder travels as developer context in the live request; the
159+ // durable transcript keeps only finalized history.
160+ messages, err := subagents.ReadResidentTranscriptMessages(journal.Dir())
161+ if err != nil {
162+ t.Fatal(err)
163+ }
164+ var texts []string
165+ for _, message := range messages {
166+ for _, content := range message.Content {
167+ if block, ok := content.(provider.TextBlock); ok {
168+ texts = append(texts, block.Text)
169+ }
170+ }
171+ }
172+ joined := strings.Join(texts, "\n")
173+ for _, want := range []string{"first result", "second result"} {
174+ if !strings.Contains(joined, want) {
175+ t.Fatalf("journal history missing %q: %q", want, joined)
176+ }
177+ }
178+ if strings.Contains(joined, "Context usage is at") {
179+ t.Fatalf("journal transcript carries the reminder: %q", joined)
180+ }
181+}
182+
20 func TestResidentChildRegistryUsesExactToolListAndForbidsDelegation(t *testing.T) {183 func TestResidentChildRegistryUsesExactToolListAndForbidsDelegation(t *testing.T) {
21 catalogue := core.Registry{184 catalogue := core.Registry{
22 "read": nil,185 "read": nil,
@@ -39,28 +202,56 @@ func TestResidentChildRegistryUsesExactToolListAndForbidsDelegation(t *testing.T
39 }202 }
40 }203 }
41 204
42-func TestConfigureResidentBudgetOnlyStopsAtExhaustion(t *testing.T) {205+func TestConfigureResidentContextNudgeRemindsOnceAtThreshold(t *testing.T) {
43 agent := core.NewAgent(nil, "model", "system", core.Registry{"read": nil})206 agent := core.NewAgent(nil, "model", "system", core.Registry{"read": nil})
44- budget := subagents.NewRolloutBudget(1_000, provider.Usage{})207+ configureResidentContextNudge(agent, 1_000)
45- configureResidentBudget(agent, budget)
46 208
47- budget.Observe(provider.Usage{InputTokens: 900})209+ check := func(usage provider.Usage, wantReminder bool) string {
48- allowed, reason, contextText := agent.BeforeTurnContext(t.Context(), 1)210+ t.Helper()
49- if !allowed || reason != "" || contextText != "" {211+ agent.SeedLastTurnUsage(usage)
50- t.Fatalf("near-limit context = allowed=%t reason=%q context=%q", allowed, reason, contextText)212+ allowed, reason, contextText := agent.BeforeTurnContext(t.Context(), 1)
213+ if !allowed || reason != "" {
214+ t.Fatalf("nudge blocked turn: allowed=%t reason=%q", allowed, reason)
215+ }
216+ if wantReminder && !strings.Contains(contextText, "Context usage is at ") {
217+ t.Fatalf("missing reminder for usage %#v: %q", usage, contextText)
218+ }
219+ if !wantReminder && contextText != "" {
220+ t.Fatalf("unexpected reminder for usage %#v: %q", usage, contextText)
221+ }
222+ return contextText
223+ }
224+
225+ // Below the threshold: silent, including cache-heavy usage.
226+ check(provider.Usage{InputTokens: 849}, false)
227+ check(provider.Usage{InputTokens: 400, CacheReadTokens: 400, CacheWriteTokens: 49}, false)
228+ // First crossing: exactly one reminder carrying the computed percent.
229+ first := check(provider.Usage{InputTokens: 850}, true)
230+ if !strings.Contains(first, "85%") {
231+ t.Fatalf("reminder percent = %q, want 85%%", first)
51 }232 }
52- if len(agent.ToolsSnapshot()) != 1 || agent.MaxTokens != 0 {233+ // Later turns stay silent even as usage grows.
53- t.Fatalf("near-limit agent = tools=%#v max_tokens=%d", agent.ToolsSnapshot(), agent.MaxTokens)234+ check(provider.Usage{InputTokens: 970}, false)
235+}
236+
237+func TestConfigureResidentContextNudgeHandlesDirectJumpAndUnknownWindow(t *testing.T) {
238+ agent := core.NewAgent(nil, "model", "system", core.Registry{"read": nil})
239+ configureResidentContextNudge(agent, 1_000)
240+ agent.SeedLastTurnUsage(provider.Usage{InputTokens: 970})
241+ allowed, _, contextText := agent.BeforeTurnContext(t.Context(), 1)
242+ if !allowed || !strings.Contains(contextText, "97%") {
243+ t.Fatalf("jump reminder = allowed=%t context=%q, want one 97%% reminder", allowed, contextText)
54 }244 }
55 245
56- budget.Observe(provider.Usage{InputTokens: 1_000})246+ unknown := core.NewAgent(nil, "model", "system", core.Registry{"read": nil})
57- allowed, reason, _ = agent.BeforeTurnContext(t.Context(), 2)247+ configureResidentContextNudge(unknown, 0)
58- if allowed || reason != subagents.ErrBudgetExceeded.Error() {248+ unknown.SeedLastTurnUsage(provider.Usage{InputTokens: 970})
59- t.Fatalf("exhausted context = allowed=%t reason=%q", allowed, reason)249+ if allowed, reason, contextText := unknown.BeforeTurnContext(t.Context(), 1); !allowed || reason != "" || contextText != "" {
250+ t.Fatalf("unknown window context = allowed=%t reason=%q context=%q, want silent", allowed, reason, contextText)
60 }251 }
61 }252 }
62 253
63-func TestResidentChildRunnerKeepsToolsAtNinetyPercent(t *testing.T) {254+func TestResidentChildRunnerKeepsToolsAcrossTurns(t *testing.T) {
64 var requests atomic.Int32255 var requests atomic.Int32
65 server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {256 server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
66 requestNumber := requests.Add(1)257 requestNumber := requests.Add(1)
@@ -99,7 +290,7 @@ func TestResidentChildRunnerKeepsToolsAtNinetyPercent(t *testing.T) {
99 }))290 }))
100 defer server.Close()291 defer server.Close()
101 292
102- runner, journal := newBudgetedResidentTestRunner(t, server.URL, 1_000)293+ runner, journal := newResidentTestRunner(t, server.URL)
103 if err := runner(t.Context(), "review"); err != nil {294 if err := runner(t.Context(), "review"); err != nil {
104 t.Fatal(err)295 t.Fatal(err)
105 }296 }
@@ -111,7 +302,7 @@ func TestResidentChildRunnerKeepsToolsAtNinetyPercent(t *testing.T) {
111 }302 }
112 }303 }
113 304
114-func TestResidentChildRunnerTerminatesAtBudgetLimit(t *testing.T) {305+func TestResidentChildRunnerRunsHighUsageFollowUpWithoutBudget(t *testing.T) {
115 var requests atomic.Int32306 var requests atomic.Int32
116 server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {307 server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
117 requests.Add(1)308 requests.Add(1)
@@ -130,7 +321,7 @@ func TestResidentChildRunnerTerminatesAtBudgetLimit(t *testing.T) {
130 }))321 }))
131 defer server.Close()322 defer server.Close()
132 323
133- runner, journal := newBudgetedResidentTestRunner(t, server.URL, 1_000)324+ runner, journal := newResidentTestRunner(t, server.URL)
134 if err := runner(t.Context(), "review"); err != nil {325 if err := runner(t.Context(), "review"); err != nil {
135 t.Fatalf("runner error = %v, want terminal result", err)326 t.Fatalf("runner error = %v, want terminal result", err)
136 }327 }
@@ -140,42 +331,16 @@ func TestResidentChildRunnerTerminatesAtBudgetLimit(t *testing.T) {
140 if summary := latestResidentAssistantText(t, journal); summary != "best available result" {331 if summary := latestResidentAssistantText(t, journal); summary != "best available result" {
141 t.Fatalf("latest summary = %q", summary)332 t.Fatalf("latest summary = %q", summary)
142 }333 }
143- if err := runner(t.Context(), "continue"); !errors.Is(err, subagents.ErrBudgetExceeded) {334+ // Without a cumulative budget, a high-usage follow-up runs normally.
144- t.Fatalf("exhausted follow-up error = %v", err)335+ if err := runner(t.Context(), "continue"); err != nil {
145- }336+ t.Fatalf("follow-up error = %v, want success", err)
146- if got := requests.Load(); got != 1 {
147- t.Fatalf("exhausted follow-up made %d provider requests, want 1 total", got)
148 }337 }
149-}338+ if got := requests.Load(); got != 2 {
150-339+ t.Fatalf("provider requests = %d, want 2", got)
151-func TestResidentChildRunnerReportsBudgetStopAfterToolWork(t *testing.T) {
152- var requests atomic.Int32
153- server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
154- requests.Add(1)
155- w.Header().Set("content-type", "text/event-stream")
156- writeOpenAIChunk(t, w, map[string]any{
157- "choices": []any{map[string]any{
158- "index": 0,
159- "delta": map[string]any{"tool_calls": []any{map[string]any{
160- "index": 0, "id": "call-budget", "type": "function",
161- "function": map[string]string{"name": "read", "arguments": `{"path":"x"}`},
162- }}},
163- "finish_reason": "tool_calls",
164- }},
165- "usage": map[string]int{"prompt_tokens": 1200, "completion_tokens": 0},
166- })
167- }))
168- defer server.Close()
169- runner, _ := newBudgetedResidentTestRunner(t, server.URL, 1000)
170- if err := runner(t.Context(), "review"); !errors.Is(err, subagents.ErrBudgetExceeded) {
171- t.Fatalf("budget-stopped tool turn = %v, want ErrBudgetExceeded", err)
172- }
173- if got := requests.Load(); got != 1 {
174- t.Fatalf("requests = %d, want 1", got)
175 }340 }
176 }341 }
177 342
178-func TestResidentChildRunnerResumesWithSameSizeAllowance(t *testing.T) {343+func TestResidentChildRunnerResumesWithRetainedHistory(t *testing.T) {
179 var requests atomic.Int32344 var requests atomic.Int32
180 server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {345 server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
181 request := requests.Add(1)346 request := requests.Add(1)
@@ -208,7 +373,7 @@ func TestResidentChildRunnerResumesWithSameSizeAllowance(t *testing.T) {
208 t.Cleanup(func() { _ = manager.Close(context.Background()) })373 t.Cleanup(func() { _ = manager.Close(context.Background()) })
209 completed := make(chan subagents.ResidentCompletion, 2)374 completed := make(chan subagents.ResidentCompletion, 2)
210 manager.SetCompletionObserver(func(c subagents.ResidentCompletion) { completed <- c })375 manager.SetCompletionObserver(func(c subagents.ResidentCompletion) { completed <- c })
211- spec := subagents.ResidentChildSpec{ID: "resume-budget", SessionID: "session", Provider: "openai", Model: "gpt-4o", BudgetLimit: 1000, BudgetSource: "model_context"}376+ spec := subagents.ResidentChildSpec{ID: "resume-child", SessionID: "session", Provider: "openai", Model: "gpt-4o"}
212 if _, err := manager.Spawn(t.Context(), spec, "review"); err != nil {377 if _, err := manager.Spawn(t.Context(), spec, "review"); err != nil {
213 t.Fatal(err)378 t.Fatal(err)
214 }379 }
@@ -228,16 +393,16 @@ func TestResidentChildRunnerResumesWithSameSizeAllowance(t *testing.T) {
228 }393 }
229 }394 }
230 snapshot, _ := manager.SnapshotFor(spec.ID)395 snapshot, _ := manager.SnapshotFor(spec.ID)
231- if requests.Load() != 2 || snapshot.Budget.Used != 30 || snapshot.Budget.Limit != 1000 || snapshot.Usage.InputTokens != 1220 {396+ if requests.Load() != 2 || snapshot.Usage.InputTokens != 1220 {
232 t.Fatalf("requests=%d, snapshot=%#v", requests.Load(), snapshot)397 t.Fatalf("requests=%d, snapshot=%#v", requests.Load(), snapshot)
233 }398 }
234 }399 }
235 400
236-func newBudgetedResidentTestRunner(t *testing.T, baseURL string, limit int64) (subagents.ResidentTurnRunner, *subagents.ResidentJournal) {401+func newResidentTestRunner(t *testing.T, baseURL string) (subagents.ResidentTurnRunner, *subagents.ResidentJournal) {
237 t.Helper()402 t.Helper()
238 spec := subagents.ResidentChildSpec{403 spec := subagents.ResidentChildSpec{
239- ID: "budget-child", SessionID: "budget-session", Provider: "openai", Model: "gpt-4o",404+ ID: "resident-child", SessionID: "resident-session", Provider: "openai", Model: "gpt-4o",
240- Tools: []string{"read"}, BudgetLimit: limit, BudgetSource: "model_context",405+ Tools: []string{"read"},
241 }406 }
242 journal, err := subagents.OpenResidentJournal(t.TempDir(), spec.ID)407 journal, err := subagents.OpenResidentJournal(t.TempDir(), spec.ID)
243 if err != nil {408 if err != nil {
M packages/agent/subagent_runtime.go +0 -6
@@ -388,12 +388,6 @@ func (rt *subagentRuntime) buildResidentChildSpec(_ context.Context, request too
388388 }
389389 childTools = expandWebCapabilityTools(childTools, allTools, permitted)
390390 spec.Tools = childTools
391- budgetLimit, err := subagents.ContextBudgetLimit(resolved.ContextWindow)
392- if err != nil {
393- return subagents.ResidentChildSpec{}, err
394- }
395- spec.BudgetLimit = budgetLimit
396- spec.BudgetSource = "model_context"
397391 return spec, nil
398392 }
399393
@@ -388,12 +388,6 @@ func (rt *subagentRuntime) buildResidentChildSpec(_ context.Context, request too
388 }388 }
389 childTools = expandWebCapabilityTools(childTools, allTools, permitted)389 childTools = expandWebCapabilityTools(childTools, allTools, permitted)
390 spec.Tools = childTools390 spec.Tools = childTools
391- budgetLimit, err := subagents.ContextBudgetLimit(resolved.ContextWindow)
392- if err != nil {
393- return subagents.ResidentChildSpec{}, err
394- }
395- spec.BudgetLimit = budgetLimit
396- spec.BudgetSource = "model_context"
397 return spec, nil391 return spec, nil
398 }392 }
399 393
M packages/agent/subagent_runtime_test.go +14 -3
@@ -2,7 +2,9 @@ package agent
22
33 import (
44 "context"
5+ "encoding/json"
56 "slices"
7+ "strings"
68 "testing"
79
810 "github.com/bnema/zut/packages/agent/subagents"
@@ -93,7 +95,7 @@ func TestResidentChildSpecTracksFastMode(t *testing.T) {
9395 }
9496 }
9597
96-func TestResidentChildSpecUsesSelectedModelContextForBudget(t *testing.T) {
98+func TestResidentChildSpecCarriesNoCumulativeBudget(t *testing.T) {
9799 runtime := newSubagentRuntime(subagentRuntimeConfig{
98100 Args: Args{}, Root: t.TempDir(), RepoRoot: t.TempDir(),
99101 Provider: "openai", Model: "gpt-5.6-sol", ContextWindow: 42_000,
@@ -104,8 +106,17 @@ func TestResidentChildSpecUsesSelectedModelContextForBudget(t *testing.T) {
104106 if err != nil {
105107 t.Fatal(err)
106108 }
107- if spec.BudgetLimit != 500_000 || spec.BudgetSource != "model_context" {
108- t.Fatalf("child model budget = %#v", spec)
109+ if len(spec.Tools) != 1 || spec.Tools[0] != "read" {
110+ t.Fatalf("child spec tools = %#v, want resolved tool list", spec)
111+ }
112+ // The budget fields are gone from the spec struct; the serialized
113+ // form must carry no budget bytes either.
114+ encoded, err := json.Marshal(spec)
115+ if err != nil {
116+ t.Fatal(err)
117+ }
118+ if strings.Contains(string(encoded), "budget") {
119+ t.Fatalf("child spec carries budget = %s", encoded)
109120 }
110121 }
111122
@@ -2,7 +2,9 @@ package agent
2 2
3 import (3 import (
4 "context"4 "context"
5+ "encoding/json"
5 "slices"6 "slices"
7+ "strings"
6 "testing"8 "testing"
7 9
8 "github.com/bnema/zut/packages/agent/subagents"10 "github.com/bnema/zut/packages/agent/subagents"
@@ -93,7 +95,7 @@ func TestResidentChildSpecTracksFastMode(t *testing.T) {
93 }95 }
94 }96 }
95 97
96-func TestResidentChildSpecUsesSelectedModelContextForBudget(t *testing.T) {98+func TestResidentChildSpecCarriesNoCumulativeBudget(t *testing.T) {
97 runtime := newSubagentRuntime(subagentRuntimeConfig{99 runtime := newSubagentRuntime(subagentRuntimeConfig{
98 Args: Args{}, Root: t.TempDir(), RepoRoot: t.TempDir(),100 Args: Args{}, Root: t.TempDir(), RepoRoot: t.TempDir(),
99 Provider: "openai", Model: "gpt-5.6-sol", ContextWindow: 42_000,101 Provider: "openai", Model: "gpt-5.6-sol", ContextWindow: 42_000,
@@ -104,8 +106,17 @@ func TestResidentChildSpecUsesSelectedModelContextForBudget(t *testing.T) {
104 if err != nil {106 if err != nil {
105 t.Fatal(err)107 t.Fatal(err)
106 }108 }
107- if spec.BudgetLimit != 500_000 || spec.BudgetSource != "model_context" {109+ if len(spec.Tools) != 1 || spec.Tools[0] != "read" {
108- t.Fatalf("child model budget = %#v", spec)110+ t.Fatalf("child spec tools = %#v, want resolved tool list", spec)
111+ }
112+ // The budget fields are gone from the spec struct; the serialized
113+ // form must carry no budget bytes either.
114+ encoded, err := json.Marshal(spec)
115+ if err != nil {
116+ t.Fatal(err)
117+ }
118+ if strings.Contains(string(encoded), "budget") {
119+ t.Fatalf("child spec carries budget = %s", encoded)
109 }120 }
110 }121 }
111 122
D packages/agent/subagents/budget.go +0 -126
@@ -1,126 +0,0 @@
1-package subagents
2-
3-import (
4- "errors"
5- "math"
6- "sync"
7-
8- "github.com/bnema/zut/packages/provider"
9-)
10-
11-const cachedInputBudgetWeight = 0.25
12-
13-// BudgetState is the host-visible state of a resident child's cumulative
14-// rollout budget.
15-type BudgetState string
16-
17-const (
18- BudgetNormal BudgetState = "normal"
19- BudgetExceeded BudgetState = "exceeded"
20-)
21-
22-var ErrBudgetExceeded = errors.New("resident subagent rollout budget exceeded")
23-
24-// BudgetSnapshot is the bounded public projection of a child's cumulative
25-// rollout budget. Used is weighted usage, not the active context size.
26-type BudgetSnapshot struct {
27- Used int64 `json:"used_tokens"`
28- Limit int64 `json:"limit_tokens"`
29- Percent int `json:"percent"`
30- State BudgetState `json:"state"`
31-}
32-
33-// ContextBudgetLimit derives the deterministic rollout limit for a child from
34-// its resolved model metadata.
35-func ContextBudgetLimit(contextWindow int) (int64, error) {
36- if contextWindow <= 0 {
37- return 0, errors.New("cannot derive a subagent budget without a model context window")
38- }
39- return int64(contextWindow), nil
40-}
41-
42-// WeightedBudgetUsage charges uncached and cache-write input fully, cache-read
43-// input at a reduced weight, and output once (provider OutputTokens already
44-// includes reasoning tokens where the provider reports them).
45-func WeightedBudgetUsage(usage provider.Usage) int64 {
46- weighted := float64(max(usage.InputTokens, 0)) +
47- float64(max(usage.CacheWriteTokens, 0)) +
48- float64(max(usage.OutputTokens, 0)) +
49- float64(max(usage.CacheReadTokens, 0))*cachedInputBudgetWeight
50- return int64(math.Ceil(weighted))
51-}
52-
53-func BudgetSnapshotFor(usage provider.Usage, limit int64) BudgetSnapshot {
54- if limit <= 0 {
55- return BudgetSnapshot{}
56- }
57- return budgetSnapshotSince(usage, limit, 0)
58-}
59-
60-func budgetSnapshotSince(usage provider.Usage, limit, baseline int64) BudgetSnapshot {
61- if limit <= 0 {
62- return BudgetSnapshot{}
63- }
64- used := max(WeightedBudgetUsage(usage)-baseline, 0)
65- percent := int(math.Floor(float64(used) * 100 / float64(limit)))
66- return BudgetSnapshot{Used: used, Limit: limit, Percent: percent, State: budgetState(used, limit)}
67-}
68-
69-func budgetState(used, limit int64) BudgetState {
70- if limit <= 0 {
71- return ""
72- }
73- if used >= limit {
74- return BudgetExceeded
75- }
76- return BudgetNormal
77-}
78-
79-// RolloutBudget accounts one resident child's cumulative provider usage.
80-type RolloutBudget struct {
81- mu sync.Mutex
82- limit int64
83- baseline int64
84- usage provider.Usage
85- snapshot BudgetSnapshot
86-}
87-
88-func NewRolloutBudget(limit int64, usage provider.Usage) *RolloutBudget {
89- budget := &RolloutBudget{limit: limit, usage: usage}
90- budget.snapshot = BudgetSnapshotFor(usage, limit)
91- return budget
92-}
93-
94-func (b *RolloutBudget) Observe(usage provider.Usage) BudgetSnapshot {
95- if b == nil {
96- return BudgetSnapshot{}
97- }
98- b.mu.Lock()
99- b.usage = usage
100- b.snapshot = budgetSnapshotSince(usage, b.limit, b.baseline)
101- snapshot := b.snapshot
102- b.mu.Unlock()
103- return snapshot
104-}
105-
106-// SetBaseline applies an explicitly accepted recovery allowance without
107-// discarding cumulative cost or conversation history.
108-func (b *RolloutBudget) SetBaseline(baseline int64) {
109- if b == nil {
110- return
111- }
112- b.mu.Lock()
113- b.baseline = baseline
114- b.snapshot = budgetSnapshotSince(b.usage, b.limit, baseline)
115- b.mu.Unlock()
116-}
117-
118-func (b *RolloutBudget) Snapshot() BudgetSnapshot {
119- if b == nil {
120- return BudgetSnapshot{}
121- }
122- b.mu.Lock()
123- snapshot := b.snapshot
124- b.mu.Unlock()
125- return snapshot
126-}
@@ -1,126 +0,0 @@
1-package subagents
2-
3-import (
4- "errors"
5- "math"
6- "sync"
7-
8- "github.com/bnema/zut/packages/provider"
9-)
10-
11-const cachedInputBudgetWeight = 0.25
12-
13-// BudgetState is the host-visible state of a resident child's cumulative
14-// rollout budget.
15-type BudgetState string
16-
17-const (
18- BudgetNormal BudgetState = "normal"
19- BudgetExceeded BudgetState = "exceeded"
20-)
21-
22-var ErrBudgetExceeded = errors.New("resident subagent rollout budget exceeded")
23-
24-// BudgetSnapshot is the bounded public projection of a child's cumulative
25-// rollout budget. Used is weighted usage, not the active context size.
26-type BudgetSnapshot struct {
27- Used int64 `json:"used_tokens"`
28- Limit int64 `json:"limit_tokens"`
29- Percent int `json:"percent"`
30- State BudgetState `json:"state"`
31-}
32-
33-// ContextBudgetLimit derives the deterministic rollout limit for a child from
34-// its resolved model metadata.
35-func ContextBudgetLimit(contextWindow int) (int64, error) {
36- if contextWindow <= 0 {
37- return 0, errors.New("cannot derive a subagent budget without a model context window")
38- }
39- return int64(contextWindow), nil
40-}
41-
42-// WeightedBudgetUsage charges uncached and cache-write input fully, cache-read
43-// input at a reduced weight, and output once (provider OutputTokens already
44-// includes reasoning tokens where the provider reports them).
45-func WeightedBudgetUsage(usage provider.Usage) int64 {
46- weighted := float64(max(usage.InputTokens, 0)) +
47- float64(max(usage.CacheWriteTokens, 0)) +
48- float64(max(usage.OutputTokens, 0)) +
49- float64(max(usage.CacheReadTokens, 0))*cachedInputBudgetWeight
50- return int64(math.Ceil(weighted))
51-}
52-
53-func BudgetSnapshotFor(usage provider.Usage, limit int64) BudgetSnapshot {
54- if limit <= 0 {
55- return BudgetSnapshot{}
56- }
57- return budgetSnapshotSince(usage, limit, 0)
58-}
59-
60-func budgetSnapshotSince(usage provider.Usage, limit, baseline int64) BudgetSnapshot {
61- if limit <= 0 {
62- return BudgetSnapshot{}
63- }
64- used := max(WeightedBudgetUsage(usage)-baseline, 0)
65- percent := int(math.Floor(float64(used) * 100 / float64(limit)))
66- return BudgetSnapshot{Used: used, Limit: limit, Percent: percent, State: budgetState(used, limit)}
67-}
68-
69-func budgetState(used, limit int64) BudgetState {
70- if limit <= 0 {
71- return ""
72- }
73- if used >= limit {
74- return BudgetExceeded
75- }
76- return BudgetNormal
77-}
78-
79-// RolloutBudget accounts one resident child's cumulative provider usage.
80-type RolloutBudget struct {
81- mu sync.Mutex
82- limit int64
83- baseline int64
84- usage provider.Usage
85- snapshot BudgetSnapshot
86-}
87-
88-func NewRolloutBudget(limit int64, usage provider.Usage) *RolloutBudget {
89- budget := &RolloutBudget{limit: limit, usage: usage}
90- budget.snapshot = BudgetSnapshotFor(usage, limit)
91- return budget
92-}
93-
94-func (b *RolloutBudget) Observe(usage provider.Usage) BudgetSnapshot {
95- if b == nil {
96- return BudgetSnapshot{}
97- }
98- b.mu.Lock()
99- b.usage = usage
100- b.snapshot = budgetSnapshotSince(usage, b.limit, b.baseline)
101- snapshot := b.snapshot
102- b.mu.Unlock()
103- return snapshot
104-}
105-
106-// SetBaseline applies an explicitly accepted recovery allowance without
107-// discarding cumulative cost or conversation history.
108-func (b *RolloutBudget) SetBaseline(baseline int64) {
109- if b == nil {
110- return
111- }
112- b.mu.Lock()
113- b.baseline = baseline
114- b.snapshot = budgetSnapshotSince(b.usage, b.limit, baseline)
115- b.mu.Unlock()
116-}
117-
118-func (b *RolloutBudget) Snapshot() BudgetSnapshot {
119- if b == nil {
120- return BudgetSnapshot{}
121- }
122- b.mu.Lock()
123- snapshot := b.snapshot
124- b.mu.Unlock()
125- return snapshot
126-}
D packages/agent/subagents/budget_test.go +0 -56
@@ -1,56 +0,0 @@
1-package subagents
2-
3-import (
4- "testing"
5-
6- "github.com/bnema/zut/packages/provider"
7-)
8-
9-func TestContextBudgetLimitUsesFullModelContext(t *testing.T) {
10- limit, err := ContextBudgetLimit(500_000)
11- if err != nil {
12- t.Fatal(err)
13- }
14- if limit != 500_000 {
15- t.Fatalf("ContextBudgetLimit = %d, want 500000", limit)
16- }
17- if _, err := ContextBudgetLimit(0); err == nil {
18- t.Fatal("ContextBudgetLimit(0) succeeded")
19- }
20-}
21-
22-func TestValidateResidentBudgetRequiresModelContext(t *testing.T) {
23- if err := ValidateResidentBudget(ResidentChildSpec{BudgetLimit: 500_000, BudgetSource: "model_context"}); err != nil {
24- t.Fatal(err)
25- }
26- for _, spec := range []ResidentChildSpec{
27- {},
28- {BudgetLimit: 500_000},
29- {BudgetLimit: 375_000, BudgetSource: "default_ratio"},
30- } {
31- if err := ValidateResidentBudget(spec); err == nil {
32- t.Fatalf("ValidateResidentBudget(%#v) succeeded", spec)
33- }
34- }
35-}
36-
37-func TestWeightedBudgetUsageDiscountsCacheReads(t *testing.T) {
38- usage := provider.Usage{InputTokens: 100, CacheReadTokens: 100, CacheWriteTokens: 20, OutputTokens: 30, ReasoningTokens: 10, ReasoningTokensKnown: true}
39- if got := WeightedBudgetUsage(usage); got != 175 {
40- t.Fatalf("WeightedBudgetUsage = %d, want 175", got)
41- }
42-}
43-
44-func TestRolloutBudgetOnlyExhaustsAtLimit(t *testing.T) {
45- budget := NewRolloutBudget(1_000, provider.Usage{})
46- for _, used := range []int{500, 700, 850, 900, 999} {
47- budget.Observe(provider.Usage{InputTokens: used})
48- if got := budget.Snapshot().State; got != BudgetNormal {
49- t.Fatalf("usage %d state = %q, want %q", used, got, BudgetNormal)
50- }
51- }
52- budget.Observe(provider.Usage{InputTokens: 1_000})
53- if got := budget.Snapshot().State; got != BudgetExceeded {
54- t.Fatalf("exhausted state = %q, want %q", got, BudgetExceeded)
55- }
56-}
@@ -1,56 +0,0 @@
1-package subagents
2-
3-import (
4- "testing"
5-
6- "github.com/bnema/zut/packages/provider"
7-)
8-
9-func TestContextBudgetLimitUsesFullModelContext(t *testing.T) {
10- limit, err := ContextBudgetLimit(500_000)
11- if err != nil {
12- t.Fatal(err)
13- }
14- if limit != 500_000 {
15- t.Fatalf("ContextBudgetLimit = %d, want 500000", limit)
16- }
17- if _, err := ContextBudgetLimit(0); err == nil {
18- t.Fatal("ContextBudgetLimit(0) succeeded")
19- }
20-}
21-
22-func TestValidateResidentBudgetRequiresModelContext(t *testing.T) {
23- if err := ValidateResidentBudget(ResidentChildSpec{BudgetLimit: 500_000, BudgetSource: "model_context"}); err != nil {
24- t.Fatal(err)
25- }
26- for _, spec := range []ResidentChildSpec{
27- {},
28- {BudgetLimit: 500_000},
29- {BudgetLimit: 375_000, BudgetSource: "default_ratio"},
30- } {
31- if err := ValidateResidentBudget(spec); err == nil {
32- t.Fatalf("ValidateResidentBudget(%#v) succeeded", spec)
33- }
34- }
35-}
36-
37-func TestWeightedBudgetUsageDiscountsCacheReads(t *testing.T) {
38- usage := provider.Usage{InputTokens: 100, CacheReadTokens: 100, CacheWriteTokens: 20, OutputTokens: 30, ReasoningTokens: 10, ReasoningTokensKnown: true}
39- if got := WeightedBudgetUsage(usage); got != 175 {
40- t.Fatalf("WeightedBudgetUsage = %d, want 175", got)
41- }
42-}
43-
44-func TestRolloutBudgetOnlyExhaustsAtLimit(t *testing.T) {
45- budget := NewRolloutBudget(1_000, provider.Usage{})
46- for _, used := range []int{500, 700, 850, 900, 999} {
47- budget.Observe(provider.Usage{InputTokens: used})
48- if got := budget.Snapshot().State; got != BudgetNormal {
49- t.Fatalf("usage %d state = %q, want %q", used, got, BudgetNormal)
50- }
51- }
52- budget.Observe(provider.Usage{InputTokens: 1_000})
53- if got := budget.Snapshot().State; got != BudgetExceeded {
54- t.Fatalf("exhausted state = %q, want %q", got, BudgetExceeded)
55- }
56-}
M packages/agent/subagents/completion.go +0 -2
@@ -26,8 +26,6 @@ func (c ResidentCompletion) Completion() Completion {
2626 result.Status, result.Error = string(ResidentFailed), c.Err.Error()
2727 if errors.Is(c.Err, context.Canceled) {
2828 result.Status = string(ResidentInterrupted)
29- } else if errors.Is(c.Err, ErrBudgetExceeded) {
30- result.Status = string(ResidentBudgetExhausted)
3129 }
3230 }
3331 return result
@@ -26,8 +26,6 @@ func (c ResidentCompletion) Completion() Completion {
26 result.Status, result.Error = string(ResidentFailed), c.Err.Error()26 result.Status, result.Error = string(ResidentFailed), c.Err.Error()
27 if errors.Is(c.Err, context.Canceled) {27 if errors.Is(c.Err, context.Canceled) {
28 result.Status = string(ResidentInterrupted)28 result.Status = string(ResidentInterrupted)
29- } else if errors.Is(c.Err, ErrBudgetExceeded) {
30- result.Status = string(ResidentBudgetExhausted)
31 }29 }
32 }30 }
33 return result31 return result
M packages/agent/subagents/completion_test.go +2 -2
@@ -15,8 +15,8 @@ func TestResidentCompletionProjection(t *testing.T) {
1515 {nil, "completed"},
1616 {errors.New("failure"), "failed"},
1717 {context.Canceled, "interrupted"},
18- {errors.Join(ErrBudgetExceeded, errors.New("capture failed")), "budget_exhausted"},
19- {errors.Join(context.Canceled, ErrBudgetExceeded), "interrupted"},
18+ {errors.New("capture failed"), "failed"},
19+ {errors.Join(context.Canceled, errors.New("capture failed")), "interrupted"},
2020 } {
2121 t.Run(tc.status, func(t *testing.T) {
2222 got := (ResidentCompletion{ChildID: "child", TurnID: "turn", Task: "task", Summary: "saved progress", Err: tc.err}).Completion()
@@ -15,8 +15,8 @@ func TestResidentCompletionProjection(t *testing.T) {
15 {nil, "completed"},15 {nil, "completed"},
16 {errors.New("failure"), "failed"},16 {errors.New("failure"), "failed"},
17 {context.Canceled, "interrupted"},17 {context.Canceled, "interrupted"},
18- {errors.Join(ErrBudgetExceeded, errors.New("capture failed")), "budget_exhausted"},18+ {errors.New("capture failed"), "failed"},
19- {errors.Join(context.Canceled, ErrBudgetExceeded), "interrupted"},19+ {errors.Join(context.Canceled, errors.New("capture failed")), "interrupted"},
20 } {20 } {
21 t.Run(tc.status, func(t *testing.T) {21 t.Run(tc.status, func(t *testing.T) {
22 got := (ResidentCompletion{ChildID: "child", TurnID: "turn", Task: "task", Summary: "saved progress", Err: tc.err}).Completion()22 got := (ResidentCompletion{ChildID: "child", TurnID: "turn", Task: "task", Summary: "saved progress", Err: tc.err}).Completion()
M packages/agent/subagents/profiles.go +1 -1
@@ -161,7 +161,7 @@ func SystemPromptAddendum(profiles []*Profile) string {
161161 }
162162 var sb strings.Builder
163163 sb.WriteString("[subagents_list]\n")
164- sb.WriteString("Named subagents available to subagent_spawn. Choose the profile whose description best matches the independent task and pass its name as the tool's agent field. The selected profile's instructions, model, thinking level, tool limits, fast-mode preference, and budget override are applied to the child.\n")
164+ sb.WriteString("Named subagents available to subagent_spawn. Choose the profile whose description best matches the independent task and pass its name as the tool's agent field. The selected profile's instructions, model, thinking level, tool limits, and fast-mode preference are applied to the child.\n")
165165 for _, profile := range profiles {
166166 if profile == nil {
167167 continue
@@ -161,7 +161,7 @@ func SystemPromptAddendum(profiles []*Profile) string {
161 }161 }
162 var sb strings.Builder162 var sb strings.Builder
163 sb.WriteString("[subagents_list]\n")163 sb.WriteString("[subagents_list]\n")
164- sb.WriteString("Named subagents available to subagent_spawn. Choose the profile whose description best matches the independent task and pass its name as the tool's agent field. The selected profile's instructions, model, thinking level, tool limits, fast-mode preference, and budget override are applied to the child.\n")164+ sb.WriteString("Named subagents available to subagent_spawn. Choose the profile whose description best matches the independent task and pass its name as the tool's agent field. The selected profile's instructions, model, thinking level, tool limits, and fast-mode preference are applied to the child.\n")
165 for _, profile := range profiles {165 for _, profile := range profiles {
166 if profile == nil {166 if profile == nil {
167 continue167 continue
M packages/agent/subagents/resident_artifact_recovery_test.go +3 -2
@@ -1,6 +1,7 @@
11 package subagents
22
33 import (
4+ "errors"
45 "os"
56 "path/filepath"
67 "reflect"
@@ -34,7 +35,7 @@ func TestReconcileResidentJournalRestoresCapturedArtifacts(t *testing.T) {
3435 } else if err := writeResidentPatch(journal.Dir(), []byte("stale patch from an earlier turn\n")); err != nil {
3536 t.Fatal(err)
3637 }
37- if err := journal.RecordTurnFinishedWithCapture(spec, spec.InitialTurnID, ErrBudgetExceeded, capture); err != nil {
38+ if err := journal.RecordTurnFinishedWithCapture(spec, spec.InitialTurnID, errors.New("turn failed"), capture); err != nil {
3839 t.Fatal(err)
3940 }
4041 original, err := journal.Result()
@@ -70,7 +71,7 @@ func TestReconcileResidentJournalRestoresCapturedArtifacts(t *testing.T) {
7071 if err != nil {
7172 t.Fatal(err)
7273 }
73- if result.State != ResidentBudgetExhausted || result.TurnID != spec.InitialTurnID || result.ErrorCode != residentErrorBudgetExhausted || result.Handoff == "" || result.PatchRef != original.PatchRef || !reflect.DeepEqual(result.ChangedFiles, original.ChangedFiles) {
74+ if result.State != ResidentFailed || result.TurnID != spec.InitialTurnID || result.ErrorCode != residentErrorTurnFailed || result.Handoff != "" || result.PatchRef != original.PatchRef || !reflect.DeepEqual(result.ChangedFiles, original.ChangedFiles) {
7475 t.Fatalf("reconstructed result = %#v, original = %#v", result, original)
7576 }
7677 }
@@ -1,6 +1,7 @@
1 package subagents1 package subagents
2 2
3 import (3 import (
4+ "errors"
4 "os"5 "os"
5 "path/filepath"6 "path/filepath"
6 "reflect"7 "reflect"
@@ -34,7 +35,7 @@ func TestReconcileResidentJournalRestoresCapturedArtifacts(t *testing.T) {
34 } else if err := writeResidentPatch(journal.Dir(), []byte("stale patch from an earlier turn\n")); err != nil {35 } else if err := writeResidentPatch(journal.Dir(), []byte("stale patch from an earlier turn\n")); err != nil {
35 t.Fatal(err)36 t.Fatal(err)
36 }37 }
37- if err := journal.RecordTurnFinishedWithCapture(spec, spec.InitialTurnID, ErrBudgetExceeded, capture); err != nil {38+ if err := journal.RecordTurnFinishedWithCapture(spec, spec.InitialTurnID, errors.New("turn failed"), capture); err != nil {
38 t.Fatal(err)39 t.Fatal(err)
39 }40 }
40 original, err := journal.Result()41 original, err := journal.Result()
@@ -70,7 +71,7 @@ func TestReconcileResidentJournalRestoresCapturedArtifacts(t *testing.T) {
70 if err != nil {71 if err != nil {
71 t.Fatal(err)72 t.Fatal(err)
72 }73 }
73- if result.State != ResidentBudgetExhausted || result.TurnID != spec.InitialTurnID || result.ErrorCode != residentErrorBudgetExhausted || result.Handoff == "" || result.PatchRef != original.PatchRef || !reflect.DeepEqual(result.ChangedFiles, original.ChangedFiles) {74+ if result.State != ResidentFailed || result.TurnID != spec.InitialTurnID || result.ErrorCode != residentErrorTurnFailed || result.Handoff != "" || result.PatchRef != original.PatchRef || !reflect.DeepEqual(result.ChangedFiles, original.ChangedFiles) {
74 t.Fatalf("reconstructed result = %#v, original = %#v", result, original)75 t.Fatalf("reconstructed result = %#v, original = %#v", result, original)
75 }76 }
76 }77 }
M packages/agent/subagents/resident_cancellation_test.go +3 -3
@@ -8,7 +8,7 @@ import (
88 )
99
1010 func TestResidentCancellationPreservesInterruptedState(t *testing.T) {
11- for _, turnErr := range []error{context.Canceled, errors.Join(context.Canceled, ErrBudgetExceeded)} {
11+ for _, turnErr := range []error{context.Canceled, errors.Join(context.Canceled, errors.New("worker failed"))} {
1212 t.Run(turnErr.Error(), func(t *testing.T) {
1313 root := t.TempDir()
1414 manager := NewResidentManager(root, func(ResidentChildSpec, *ResidentJournal) (ResidentTurnRunner, error) {
@@ -24,13 +24,13 @@ func TestResidentCancellationPreservesInterruptedState(t *testing.T) {
2424 t.Fatal(err)
2525 }
2626 cancel() // Accepted children outlive the caller's spawn context.
27- completion := awaitBudgetCompletion(t, completions)
27+ completion := awaitResidentCompletion(t, completions)
2828 snapshot, ok := manager.SnapshotFor(spec.ID)
2929 if !ok || snapshot.State != ResidentInterrupted || completion.Completion().Status != string(ResidentInterrupted) {
3030 t.Fatalf("snapshot = %#v, completion = %#v", snapshot, completion)
3131 }
3232 result, err := manager.Result(spec.ID)
33- if err != nil || result.State != ResidentInterrupted || result.Handoff != "" || result.ErrorCode == residentErrorBudgetExhausted {
33+ if err != nil || result.State != ResidentInterrupted || result.Handoff != "" || result.ErrorCode == residentV2ErrorBudgetExhausted {
3434 t.Fatalf("result = %#v, error = %v", result, err)
3535 }
3636 if len(manager.UnmetRequired()) != 1 {
@@ -8,7 +8,7 @@ import (
8 )8 )
9 9
10 func TestResidentCancellationPreservesInterruptedState(t *testing.T) {10 func TestResidentCancellationPreservesInterruptedState(t *testing.T) {
11- for _, turnErr := range []error{context.Canceled, errors.Join(context.Canceled, ErrBudgetExceeded)} {11+ for _, turnErr := range []error{context.Canceled, errors.Join(context.Canceled, errors.New("worker failed"))} {
12 t.Run(turnErr.Error(), func(t *testing.T) {12 t.Run(turnErr.Error(), func(t *testing.T) {
13 root := t.TempDir()13 root := t.TempDir()
14 manager := NewResidentManager(root, func(ResidentChildSpec, *ResidentJournal) (ResidentTurnRunner, error) {14 manager := NewResidentManager(root, func(ResidentChildSpec, *ResidentJournal) (ResidentTurnRunner, error) {
@@ -24,13 +24,13 @@ func TestResidentCancellationPreservesInterruptedState(t *testing.T) {
24 t.Fatal(err)24 t.Fatal(err)
25 }25 }
26 cancel() // Accepted children outlive the caller's spawn context.26 cancel() // Accepted children outlive the caller's spawn context.
27- completion := awaitBudgetCompletion(t, completions)27+ completion := awaitResidentCompletion(t, completions)
28 snapshot, ok := manager.SnapshotFor(spec.ID)28 snapshot, ok := manager.SnapshotFor(spec.ID)
29 if !ok || snapshot.State != ResidentInterrupted || completion.Completion().Status != string(ResidentInterrupted) {29 if !ok || snapshot.State != ResidentInterrupted || completion.Completion().Status != string(ResidentInterrupted) {
30 t.Fatalf("snapshot = %#v, completion = %#v", snapshot, completion)30 t.Fatalf("snapshot = %#v, completion = %#v", snapshot, completion)
31 }31 }
32 result, err := manager.Result(spec.ID)32 result, err := manager.Result(spec.ID)
33- if err != nil || result.State != ResidentInterrupted || result.Handoff != "" || result.ErrorCode == residentErrorBudgetExhausted {33+ if err != nil || result.State != ResidentInterrupted || result.Handoff != "" || result.ErrorCode == residentV2ErrorBudgetExhausted {
34 t.Fatalf("result = %#v, error = %v", result, err)34 t.Fatalf("result = %#v, error = %v", result, err)
35 }35 }
36 if len(manager.UnmetRequired()) != 1 {36 if len(manager.UnmetRequired()) != 1 {
M packages/agent/subagents/resident_child.go +1 -4
@@ -412,7 +412,7 @@ func (c *ResidentChild) run() {
412412 }
413413 terminalErr := result.err
414414 var capture *WorkspaceCapture
415- if (terminalErr == nil || errors.Is(terminalErr, ErrBudgetExceeded)) && c.workspace != nil && c.workspace.Mode() == WorkspaceWorktree {
415+ if (terminalErr == nil || !errors.Is(terminalErr, context.Canceled)) && c.workspace != nil && c.workspace.Mode() == WorkspaceWorktree {
416416 captured, err := c.workspace.Capture(c.ctx)
417417 if err != nil {
418418 terminalErr = errors.Join(terminalErr, fmt.Errorf("capture resident worktree: %w", err))
@@ -423,9 +423,6 @@ func (c *ResidentChild) run() {
423423 state := ResidentIdle
424424 if terminalErr != nil {
425425 state = ResidentFailed
426- if errors.Is(terminalErr, ErrBudgetExceeded) {
427- state = ResidentBudgetExhausted
428- }
429426 }
430427 summary := ""
431428 persistenceFailed := false
@@ -412,7 +412,7 @@ func (c *ResidentChild) run() {
412 }412 }
413 terminalErr := result.err413 terminalErr := result.err
414 var capture *WorkspaceCapture414 var capture *WorkspaceCapture
415- if (terminalErr == nil || errors.Is(terminalErr, ErrBudgetExceeded)) && c.workspace != nil && c.workspace.Mode() == WorkspaceWorktree {415+ if (terminalErr == nil || !errors.Is(terminalErr, context.Canceled)) && c.workspace != nil && c.workspace.Mode() == WorkspaceWorktree {
416 captured, err := c.workspace.Capture(c.ctx)416 captured, err := c.workspace.Capture(c.ctx)
417 if err != nil {417 if err != nil {
418 terminalErr = errors.Join(terminalErr, fmt.Errorf("capture resident worktree: %w", err))418 terminalErr = errors.Join(terminalErr, fmt.Errorf("capture resident worktree: %w", err))
@@ -423,9 +423,6 @@ func (c *ResidentChild) run() {
423 state := ResidentIdle423 state := ResidentIdle
424 if terminalErr != nil {424 if terminalErr != nil {
425 state = ResidentFailed425 state = ResidentFailed
426- if errors.Is(terminalErr, ErrBudgetExceeded) {
427- state = ResidentBudgetExhausted
428- }
429 }426 }
430 summary := ""427 summary := ""
431 persistenceFailed := false428 persistenceFailed := false
R packages/agent/subagents/resident_budget_recovery_test.go → packages/agent/subagents/resident_failure_recovery_test.go +17 -19 renamed, 75% similar
@@ -15,7 +15,7 @@ import (
1515 "github.com/bnema/zut/packages/provider"
1616 )
1717
18-func TestResidentBudgetExhaustionHandoffAndRecovery(t *testing.T) {
18+func TestResidentFailureRecoveryKeepsRequiredUnmetUntilResume(t *testing.T) {
1919 for _, mode := range []string{"read-only", "shared", "worktree"} {
2020 for _, restart := range []bool{false, true} {
2121 t.Run(fmt.Sprintf("%s/restart=%t", mode, restart), func(t *testing.T) {
@@ -23,16 +23,14 @@ func TestResidentBudgetExhaustionHandoffAndRecovery(t *testing.T) {
2323 if mode == "worktree" {
2424 workspace = initTestRepo(t)
2525 }
26- spec := ResidentChildSpec{ID: "budget-recovery", SessionID: "session", InitialTurnID: "initial", Provider: "openai", Model: "test", Required: true, BudgetLimit: 100, BudgetSource: "model_context", RepositoryRoot: workspace, WorkspaceMode: WorkspaceShared}
26+ spec := ResidentChildSpec{ID: "failure-recovery", SessionID: "session", InitialTurnID: "initial", Provider: "openai", Model: "test", Required: true, RepositoryRoot: workspace, WorkspaceMode: WorkspaceShared}
2727 if mode == "worktree" {
2828 spec.WorkspaceMode, spec.WorkspaceCapture = WorkspaceWorktree, CapturePatch
2929 }
30+ turnErr := errors.New("turn failed")
3031 factory := func(spec ResidentChildSpec, journal *ResidentJournal) (ResidentTurnRunner, error) {
3132 return func(_ context.Context, prompt string) error {
3233 if prompt == "continue remaining work" {
33- if journal.BudgetBaseline() != 100 {
34- return fmt.Errorf("recovery baseline = %d", journal.BudgetBaseline())
35- }
3634 messages, err := ReadResidentTranscriptMessages(journal.Dir())
3735 if err != nil || len(messages) < 3 {
3836 return fmt.Errorf("missing restored progress: %d messages, %v", len(messages), err)
@@ -67,7 +65,7 @@ func TestResidentBudgetExhaustionHandoffAndRecovery(t *testing.T) {
6765 return err
6866 }
6967 }
70- return ErrBudgetExceeded
68+ return turnErr
7169 }, nil
7270 }
7371 manager := NewResidentManager(root, factory)
@@ -77,23 +75,23 @@ func TestResidentBudgetExhaustionHandoffAndRecovery(t *testing.T) {
7775 if _, err := manager.Spawn(t.Context(), spec, "inspect and verify README"); err != nil {
7876 t.Fatal(err)
7977 }
80- completion := awaitBudgetCompletion(t, completions)
81- if !errors.Is(completion.Err, ErrBudgetExceeded) || !strings.Contains(completion.Summary, "verification unfinished") {
78+ completion := awaitResidentCompletion(t, completions)
79+ if !errors.Is(completion.Err, turnErr) {
8280 t.Fatalf("terminal notification = %#v", completion)
8381 }
84- assertBudgetPartial := func() ResidentResult {
82+ assertFailedPartial := func() ResidentResult {
8583 t.Helper()
8684 snapshot, ok := manager.SnapshotFor(spec.ID)
87- if !ok || snapshot.State != ResidentBudgetExhausted || snapshot.Budget.State != BudgetExceeded || len(manager.UnmetRequired()) != 1 {
85+ if !ok || snapshot.State != ResidentFailed || len(manager.UnmetRequired()) != 1 {
8886 t.Fatalf("partial status = %#v; unmet = %#v", snapshot, manager.UnmetRequired())
8987 }
9088 result, err := manager.Result(spec.ID)
91- if err != nil || result.ErrorCode != "budget_exhausted" || !strings.Contains(result.Handoff, "README.md") || len(result.Handoff) > residentHandoffBytes {
89+ if err != nil || result.ErrorCode != "turn_failed" || result.Handoff != "" {
9290 t.Fatalf("saved partial result = %#v, %v", result, err)
9391 }
9492 return result
9593 }
96- result := assertBudgetPartial()
94+ result := assertFailedPartial()
9795 if mode == "worktree" {
9896 if result.PatchRef != PatchRef(spec.ID) || len(result.ChangedFiles) != 1 {
9997 t.Fatalf("missing partial patch: %#v", result)
@@ -112,40 +110,40 @@ func TestResidentBudgetExhaustionHandoffAndRecovery(t *testing.T) {
112110 if errs := manager.Reconcile(); len(errs) != 0 {
113111 t.Fatal(errs)
114112 }
115- assertBudgetPartial()
113+ assertFailedPartial()
116114 }
117115 if err := manager.Resume(t.Context(), spec.ID, "continue remaining work"); err != nil {
118116 t.Fatal(err)
119117 }
120- if completion := awaitBudgetCompletion(t, completions); completion.Err != nil {
118+ if completion := awaitResidentCompletion(t, completions); completion.Err != nil {
121119 t.Fatal(completion.Err)
122120 }
123121 if unmet := manager.UnmetRequired(); len(unmet) != 0 {
124122 t.Fatalf("successful recovery remains unmet: %#v", unmet)
125123 }
126124 snapshot, _ := manager.SnapshotFor(spec.ID)
127- if snapshot.Budget.Used != 5 || snapshot.Budget.Limit != 100 || snapshot.Usage.InputTokens != 105 {
125+ if snapshot.Usage.InputTokens != 105 {
128126 t.Fatalf("recovery lost cumulative accounting: %#v", snapshot)
129127 }
130128 if err := manager.Close(t.Context()); err != nil {
131129 t.Fatal(err)
132130 }
133131 metadata, err := ReconcileResidentJournal(filepath.Join(root, spec.ID))
134- if err != nil || metadata.BudgetBaseline != 100 || metadata.Usage.InputTokens != 105 {
135- t.Fatalf("recovery baseline did not survive reconciliation: %#v, %v", metadata, err)
132+ if err != nil || metadata.Usage.InputTokens != 105 {
133+ t.Fatalf("recovery usage did not survive reconciliation: %#v, %v", metadata, err)
136134 }
137135 })
138136 }
139137 }
140138 }
141139
142-func awaitBudgetCompletion(t *testing.T, completions <-chan ResidentCompletion) ResidentCompletion {
140+func awaitResidentCompletion(t *testing.T, completions <-chan ResidentCompletion) ResidentCompletion {
143141 t.Helper()
144142 select {
145143 case completion := <-completions:
146144 return completion
147145 case <-time.After(5 * time.Second):
148- t.Fatal("no terminal budget notification")
146+ t.Fatal("no terminal notification")
149147 return ResidentCompletion{}
150148 }
151149 }
@@ -15,7 +15,7 @@ import (
15 "github.com/bnema/zut/packages/provider"15 "github.com/bnema/zut/packages/provider"
16 )16 )
17 17
18-func TestResidentBudgetExhaustionHandoffAndRecovery(t *testing.T) {18+func TestResidentFailureRecoveryKeepsRequiredUnmetUntilResume(t *testing.T) {
19 for _, mode := range []string{"read-only", "shared", "worktree"} {19 for _, mode := range []string{"read-only", "shared", "worktree"} {
20 for _, restart := range []bool{false, true} {20 for _, restart := range []bool{false, true} {
21 t.Run(fmt.Sprintf("%s/restart=%t", mode, restart), func(t *testing.T) {21 t.Run(fmt.Sprintf("%s/restart=%t", mode, restart), func(t *testing.T) {
@@ -23,16 +23,14 @@ func TestResidentBudgetExhaustionHandoffAndRecovery(t *testing.T) {
23 if mode == "worktree" {23 if mode == "worktree" {
24 workspace = initTestRepo(t)24 workspace = initTestRepo(t)
25 }25 }
26- spec := ResidentChildSpec{ID: "budget-recovery", SessionID: "session", InitialTurnID: "initial", Provider: "openai", Model: "test", Required: true, BudgetLimit: 100, BudgetSource: "model_context", RepositoryRoot: workspace, WorkspaceMode: WorkspaceShared}26+ spec := ResidentChildSpec{ID: "failure-recovery", SessionID: "session", InitialTurnID: "initial", Provider: "openai", Model: "test", Required: true, RepositoryRoot: workspace, WorkspaceMode: WorkspaceShared}
27 if mode == "worktree" {27 if mode == "worktree" {
28 spec.WorkspaceMode, spec.WorkspaceCapture = WorkspaceWorktree, CapturePatch28 spec.WorkspaceMode, spec.WorkspaceCapture = WorkspaceWorktree, CapturePatch
29 }29 }
30+ turnErr := errors.New("turn failed")
30 factory := func(spec ResidentChildSpec, journal *ResidentJournal) (ResidentTurnRunner, error) {31 factory := func(spec ResidentChildSpec, journal *ResidentJournal) (ResidentTurnRunner, error) {
31 return func(_ context.Context, prompt string) error {32 return func(_ context.Context, prompt string) error {
32 if prompt == "continue remaining work" {33 if prompt == "continue remaining work" {
33- if journal.BudgetBaseline() != 100 {
34- return fmt.Errorf("recovery baseline = %d", journal.BudgetBaseline())
35- }
36 messages, err := ReadResidentTranscriptMessages(journal.Dir())34 messages, err := ReadResidentTranscriptMessages(journal.Dir())
37 if err != nil || len(messages) < 3 {35 if err != nil || len(messages) < 3 {
38 return fmt.Errorf("missing restored progress: %d messages, %v", len(messages), err)36 return fmt.Errorf("missing restored progress: %d messages, %v", len(messages), err)
@@ -67,7 +65,7 @@ func TestResidentBudgetExhaustionHandoffAndRecovery(t *testing.T) {
67 return err65 return err
68 }66 }
69 }67 }
70- return ErrBudgetExceeded68+ return turnErr
71 }, nil69 }, nil
72 }70 }
73 manager := NewResidentManager(root, factory)71 manager := NewResidentManager(root, factory)
@@ -77,23 +75,23 @@ func TestResidentBudgetExhaustionHandoffAndRecovery(t *testing.T) {
77 if _, err := manager.Spawn(t.Context(), spec, "inspect and verify README"); err != nil {75 if _, err := manager.Spawn(t.Context(), spec, "inspect and verify README"); err != nil {
78 t.Fatal(err)76 t.Fatal(err)
79 }77 }
80- completion := awaitBudgetCompletion(t, completions)78+ completion := awaitResidentCompletion(t, completions)
81- if !errors.Is(completion.Err, ErrBudgetExceeded) || !strings.Contains(completion.Summary, "verification unfinished") {79+ if !errors.Is(completion.Err, turnErr) {
82 t.Fatalf("terminal notification = %#v", completion)80 t.Fatalf("terminal notification = %#v", completion)
83 }81 }
84- assertBudgetPartial := func() ResidentResult {82+ assertFailedPartial := func() ResidentResult {
85 t.Helper()83 t.Helper()
86 snapshot, ok := manager.SnapshotFor(spec.ID)84 snapshot, ok := manager.SnapshotFor(spec.ID)
87- if !ok || snapshot.State != ResidentBudgetExhausted || snapshot.Budget.State != BudgetExceeded || len(manager.UnmetRequired()) != 1 {85+ if !ok || snapshot.State != ResidentFailed || len(manager.UnmetRequired()) != 1 {
88 t.Fatalf("partial status = %#v; unmet = %#v", snapshot, manager.UnmetRequired())86 t.Fatalf("partial status = %#v; unmet = %#v", snapshot, manager.UnmetRequired())
89 }87 }
90 result, err := manager.Result(spec.ID)88 result, err := manager.Result(spec.ID)
91- if err != nil || result.ErrorCode != "budget_exhausted" || !strings.Contains(result.Handoff, "README.md") || len(result.Handoff) > residentHandoffBytes {89+ if err != nil || result.ErrorCode != "turn_failed" || result.Handoff != "" {
92 t.Fatalf("saved partial result = %#v, %v", result, err)90 t.Fatalf("saved partial result = %#v, %v", result, err)
93 }91 }
94 return result92 return result
95 }93 }
96- result := assertBudgetPartial()94+ result := assertFailedPartial()
97 if mode == "worktree" {95 if mode == "worktree" {
98 if result.PatchRef != PatchRef(spec.ID) || len(result.ChangedFiles) != 1 {96 if result.PatchRef != PatchRef(spec.ID) || len(result.ChangedFiles) != 1 {
99 t.Fatalf("missing partial patch: %#v", result)97 t.Fatalf("missing partial patch: %#v", result)
@@ -112,40 +110,40 @@ func TestResidentBudgetExhaustionHandoffAndRecovery(t *testing.T) {
112 if errs := manager.Reconcile(); len(errs) != 0 {110 if errs := manager.Reconcile(); len(errs) != 0 {
113 t.Fatal(errs)111 t.Fatal(errs)
114 }112 }
115- assertBudgetPartial()113+ assertFailedPartial()
116 }114 }
117 if err := manager.Resume(t.Context(), spec.ID, "continue remaining work"); err != nil {115 if err := manager.Resume(t.Context(), spec.ID, "continue remaining work"); err != nil {
118 t.Fatal(err)116 t.Fatal(err)
119 }117 }
120- if completion := awaitBudgetCompletion(t, completions); completion.Err != nil {118+ if completion := awaitResidentCompletion(t, completions); completion.Err != nil {
121 t.Fatal(completion.Err)119 t.Fatal(completion.Err)
122 }120 }
123 if unmet := manager.UnmetRequired(); len(unmet) != 0 {121 if unmet := manager.UnmetRequired(); len(unmet) != 0 {
124 t.Fatalf("successful recovery remains unmet: %#v", unmet)122 t.Fatalf("successful recovery remains unmet: %#v", unmet)
125 }123 }
126 snapshot, _ := manager.SnapshotFor(spec.ID)124 snapshot, _ := manager.SnapshotFor(spec.ID)
127- if snapshot.Budget.Used != 5 || snapshot.Budget.Limit != 100 || snapshot.Usage.InputTokens != 105 {125+ if snapshot.Usage.InputTokens != 105 {
128 t.Fatalf("recovery lost cumulative accounting: %#v", snapshot)126 t.Fatalf("recovery lost cumulative accounting: %#v", snapshot)
129 }127 }
130 if err := manager.Close(t.Context()); err != nil {128 if err := manager.Close(t.Context()); err != nil {
131 t.Fatal(err)129 t.Fatal(err)
132 }130 }
133 metadata, err := ReconcileResidentJournal(filepath.Join(root, spec.ID))131 metadata, err := ReconcileResidentJournal(filepath.Join(root, spec.ID))
134- if err != nil || metadata.BudgetBaseline != 100 || metadata.Usage.InputTokens != 105 {132+ if err != nil || metadata.Usage.InputTokens != 105 {
135- t.Fatalf("recovery baseline did not survive reconciliation: %#v, %v", metadata, err)133+ t.Fatalf("recovery usage did not survive reconciliation: %#v, %v", metadata, err)
136 }134 }
137 })135 })
138 }136 }
139 }137 }
140 }138 }
141 139
142-func awaitBudgetCompletion(t *testing.T, completions <-chan ResidentCompletion) ResidentCompletion {140+func awaitResidentCompletion(t *testing.T, completions <-chan ResidentCompletion) ResidentCompletion {
143 t.Helper()141 t.Helper()
144 select {142 select {
145 case completion := <-completions:143 case completion := <-completions:
146 return completion144 return completion
147 case <-time.After(5 * time.Second):145 case <-time.After(5 * time.Second):
148- t.Fatal("no terminal budget notification")146+ t.Fatal("no terminal notification")
149 return ResidentCompletion{}147 return ResidentCompletion{}
150 }148 }
151 }149 }
D packages/agent/subagents/resident_handoff.go +0 -67
@@ -1,67 +0,0 @@
1-package subagents
2-
3-import (
4- "encoding/json"
5- "fmt"
6- "strings"
7- "unicode/utf8"
8-
9- "github.com/bnema/zut/packages/core"
10-)
11-
12-const residentHandoffBytes = 16 << 10
13-
14-// residentBudgetHandoff is an evidence-only fallback, not a model-generated
15-// claim of completion. The durable transcript retains full tool arguments and
16-// results, including side effects that cannot be inferred from a patch.
17-func residentBudgetHandoff(dir string, spec ResidentChildSpec) string {
18- var b strings.Builder
19- fmt.Fprintf(&b, "Partial result: budget exhausted; work is NOT complete.\nHistory: %s\nResult: %s\nWorkspace: %s\n", HistoryRef(spec.ID), ResultRef(spec.ID), spec.Workspace)
20- b.WriteString("Recovery: inspect the saved history and artifacts before repeating side effects, then use subagent_resume with the remaining task. Explicit resume grants a fresh allowance of the same size; required work remains unmet until success.\n")
21- b.WriteString("The following are bounded recent observations, not verified findings or an exhaustive list of side effects. Unfinished steps must be reconciled against the accepted task. Raw tool arguments and output are omitted because they may contain secrets; inspect them in the child history.\n")
22- items, err := ReadResidentHistory(dir, 16)
23- if err != nil {
24- b.WriteString("Recent history unavailable; inspect the durable child session before retrying.\n")
25- return b.String()
26- }
27- // Do not mix in earlier turns when the latest prompt is in this page.
28- for i := len(items) - 1; i >= 0; i-- {
29- if items[i].Type == residentRecordUser {
30- items = items[i:]
31- break
32- }
33- }
34- for _, item := range items {
35- switch item.Type {
36- case residentRecordUser, residentRecordAssistant:
37- message, err := core.DecodeMessageJSON(item.Message)
38- if err == nil {
39- fmt.Fprintf(&b, "%s: %s\n", item.Type, handoffExcerpt(residentAssistantSummary(message), 1024))
40- }
41- case residentRecordToolCall:
42- fmt.Fprintf(&b, "tool %s (%s): called; arguments retained in history\n", item.ToolName, item.ToolID)
43- case residentRecordToolResult:
44- var result struct {
45- IsError bool
46- }
47- if err := json.Unmarshal(item.ToolResult, &result); err == nil {
48- fmt.Fprintf(&b, "tool result (%s): is_error=%t; output retained in history\n", item.ToolID, result.IsError)
49- }
50- }
51- if b.Len() >= residentHandoffBytes {
52- break
53- }
54- }
55- return handoffExcerpt(b.String(), residentHandoffBytes)
56-}
57-
58-func handoffExcerpt(text string, limit int) string {
59- if len(text) <= limit {
60- return text
61- }
62- end := limit - len("…")
63- for end > 0 && !utf8.RuneStart(text[end]) {
64- end--
65- }
66- return text[:end] + "…"
67-}
@@ -1,67 +0,0 @@
1-package subagents
2-
3-import (
4- "encoding/json"
5- "fmt"
6- "strings"
7- "unicode/utf8"
8-
9- "github.com/bnema/zut/packages/core"
10-)
11-
12-const residentHandoffBytes = 16 << 10
13-
14-// residentBudgetHandoff is an evidence-only fallback, not a model-generated
15-// claim of completion. The durable transcript retains full tool arguments and
16-// results, including side effects that cannot be inferred from a patch.
17-func residentBudgetHandoff(dir string, spec ResidentChildSpec) string {
18- var b strings.Builder
19- fmt.Fprintf(&b, "Partial result: budget exhausted; work is NOT complete.\nHistory: %s\nResult: %s\nWorkspace: %s\n", HistoryRef(spec.ID), ResultRef(spec.ID), spec.Workspace)
20- b.WriteString("Recovery: inspect the saved history and artifacts before repeating side effects, then use subagent_resume with the remaining task. Explicit resume grants a fresh allowance of the same size; required work remains unmet until success.\n")
21- b.WriteString("The following are bounded recent observations, not verified findings or an exhaustive list of side effects. Unfinished steps must be reconciled against the accepted task. Raw tool arguments and output are omitted because they may contain secrets; inspect them in the child history.\n")
22- items, err := ReadResidentHistory(dir, 16)
23- if err != nil {
24- b.WriteString("Recent history unavailable; inspect the durable child session before retrying.\n")
25- return b.String()
26- }
27- // Do not mix in earlier turns when the latest prompt is in this page.
28- for i := len(items) - 1; i >= 0; i-- {
29- if items[i].Type == residentRecordUser {
30- items = items[i:]
31- break
32- }
33- }
34- for _, item := range items {
35- switch item.Type {
36- case residentRecordUser, residentRecordAssistant:
37- message, err := core.DecodeMessageJSON(item.Message)
38- if err == nil {
39- fmt.Fprintf(&b, "%s: %s\n", item.Type, handoffExcerpt(residentAssistantSummary(message), 1024))
40- }
41- case residentRecordToolCall:
42- fmt.Fprintf(&b, "tool %s (%s): called; arguments retained in history\n", item.ToolName, item.ToolID)
43- case residentRecordToolResult:
44- var result struct {
45- IsError bool
46- }
47- if err := json.Unmarshal(item.ToolResult, &result); err == nil {
48- fmt.Fprintf(&b, "tool result (%s): is_error=%t; output retained in history\n", item.ToolID, result.IsError)
49- }
50- }
51- if b.Len() >= residentHandoffBytes {
52- break
53- }
54- }
55- return handoffExcerpt(b.String(), residentHandoffBytes)
56-}
57-
58-func handoffExcerpt(text string, limit int) string {
59- if len(text) <= limit {
60- return text
61- }
62- end := limit - len("…")
63- for end > 0 && !utf8.RuneStart(text[end]) {
64- end--
65- }
66- return text[:end] + "…"
67-}
M packages/agent/subagents/resident_handoff_test.go +34 -15
@@ -2,6 +2,8 @@ package subagents
22
33 import (
44 "encoding/json"
5+ "errors"
6+ "path/filepath"
57 "strings"
68 "testing"
79
@@ -9,7 +11,7 @@ import (
911 "github.com/bnema/zut/packages/provider"
1012 )
1113
12-func TestResidentHandoffOmitsRawToolSecrets(t *testing.T) {
14+func TestResidentHandoffStaysEmptyForNewResults(t *testing.T) {
1315 journal, err := OpenResidentJournal(t.TempDir(), "handoff-secrets")
1416 if err != nil {
1517 t.Fatal(err)
@@ -27,37 +29,54 @@ func TestResidentHandoffOmitsRawToolSecrets(t *testing.T) {
2729 core.EvAssistantMessage{Message: provider.Message{Role: provider.RoleAssistant, Content: []provider.Content{provider.TextBlock{Text: "Service verification is incomplete."}, provider.ToolCallBlock{ID: "call", Name: "bash", Arguments: args}}}},
2830 core.EvToolCall{ID: "call", Name: "bash", Args: args},
2931 core.EvToolResult{ID: "call", Result: core.ToolResult{IsError: true, Content: []provider.Content{provider.TextBlock{Text: "response token=synthetic-output-secret"}}}},
32+ core.EvAssistantMessage{Message: provider.Message{Role: provider.RoleAssistant, Content: []provider.Content{provider.TextBlock{Text: "Verification failed: service down."}}}},
3033 } {
3134 if err := journal.RecordAgentEvent(event); err != nil {
3235 t.Fatal(err)
3336 }
3437 }
35- if err := journal.RecordTurnFinished(spec, spec.InitialTurnID, ErrBudgetExceeded); err != nil {
38+ turnErr := errors.New("turn failed")
39+ if err := journal.RecordTurnFinished(spec, spec.InitialTurnID, turnErr); err != nil {
3640 t.Fatal(err)
3741 }
3842 result, err := journal.Result()
3943 if err != nil {
4044 t.Fatal(err)
4145 }
42- update := FormatCompletionUpdate([]Completion{(ResidentCompletion{ChildID: spec.ID, TurnID: spec.InitialTurnID, Err: ErrBudgetExceeded, Summary: result.Handoff}).Completion()}, "")
43- for _, text := range []string{result.Handoff, update} {
44- for _, secret := range []string{"synthetic-argument-secret", "synthetic-output-secret", "Authorization", "curl"} {
45- if strings.Contains(text, secret) {
46- t.Fatalf("handoff disclosed raw tool payload %q", secret)
47- }
48- }
49- for _, want := range []string{"Service verification is incomplete.", "tool bash (call)", "is_error=true", HistoryRef(spec.ID)} {
50- if !strings.Contains(text, want) {
51- t.Fatalf("handoff missing safe metadata %q: %s", want, text)
52- }
46+ // Handoff is deprecated: new results stay empty, so no raw tool
47+ // payload can leak through the terminal projection. The completion
48+ // update carries the production summary instead. Archived v2
49+ // handoffs remain readable.
50+ if result.Handoff != "" {
51+ t.Fatalf("new result carries handoff %q, want empty", result.Handoff)
52+ }
53+ if result.Summary != "Verification failed: service down." {
54+ t.Fatalf("new result summary = %q", result.Summary)
55+ }
56+ update := FormatCompletionUpdate([]Completion{(ResidentCompletion{ChildID: spec.ID, TurnID: spec.InitialTurnID, Err: turnErr, Summary: result.Summary}).Completion()}, "")
57+ if !strings.Contains(update, "Verification failed: service down.") {
58+ t.Fatalf("completion update missing summary: %q", update)
59+ }
60+ for _, secret := range []string{"synthetic-argument-secret", "synthetic-output-secret", "Authorization", "curl"} {
61+ if strings.Contains(update, secret) {
62+ t.Fatalf("completion update disclosed raw tool payload %q", secret)
5363 }
5464 }
5565 items, err := ReadResidentHistory(journal.Dir(), 16)
5666 if err != nil {
5767 t.Fatal(err)
5868 }
59- raw, err := json.Marshal(items)
69+ if len(items) == 0 || items[len(items)-1].Type != residentRecordAssistant {
70+ t.Fatalf("history tail = %#v, want latest assistant message", items)
71+ }
72+ // The bounded history page starts at the latest prompt; the full
73+ // secrets remain in the authoritative transcript records.
74+ records, err := ReadResidentJournal(filepath.Join(journal.Dir(), residentTranscriptName))
75+ if err != nil {
76+ t.Fatal(err)
77+ }
78+ raw, err := json.Marshal(records)
6079 if err != nil || !strings.Contains(string(raw), "synthetic-argument-secret") || !strings.Contains(string(raw), "synthetic-output-secret") {
61- t.Fatal("omitting automatic payloads must not destroy durable recovery history")
80+ t.Fatal("empty handoff must not destroy durable recovery history")
6281 }
6382 }
@@ -2,6 +2,8 @@ package subagents
2 2
3 import (3 import (
4 "encoding/json"4 "encoding/json"
5+ "errors"
6+ "path/filepath"
5 "strings"7 "strings"
6 "testing"8 "testing"
7 9
@@ -9,7 +11,7 @@ import (
9 "github.com/bnema/zut/packages/provider"11 "github.com/bnema/zut/packages/provider"
10 )12 )
11 13
12-func TestResidentHandoffOmitsRawToolSecrets(t *testing.T) {14+func TestResidentHandoffStaysEmptyForNewResults(t *testing.T) {
13 journal, err := OpenResidentJournal(t.TempDir(), "handoff-secrets")15 journal, err := OpenResidentJournal(t.TempDir(), "handoff-secrets")
14 if err != nil {16 if err != nil {
15 t.Fatal(err)17 t.Fatal(err)
@@ -27,37 +29,54 @@ func TestResidentHandoffOmitsRawToolSecrets(t *testing.T) {
27 core.EvAssistantMessage{Message: provider.Message{Role: provider.RoleAssistant, Content: []provider.Content{provider.TextBlock{Text: "Service verification is incomplete."}, provider.ToolCallBlock{ID: "call", Name: "bash", Arguments: args}}}},29 core.EvAssistantMessage{Message: provider.Message{Role: provider.RoleAssistant, Content: []provider.Content{provider.TextBlock{Text: "Service verification is incomplete."}, provider.ToolCallBlock{ID: "call", Name: "bash", Arguments: args}}}},
28 core.EvToolCall{ID: "call", Name: "bash", Args: args},30 core.EvToolCall{ID: "call", Name: "bash", Args: args},
29 core.EvToolResult{ID: "call", Result: core.ToolResult{IsError: true, Content: []provider.Content{provider.TextBlock{Text: "response token=synthetic-output-secret"}}}},31 core.EvToolResult{ID: "call", Result: core.ToolResult{IsError: true, Content: []provider.Content{provider.TextBlock{Text: "response token=synthetic-output-secret"}}}},
32+ core.EvAssistantMessage{Message: provider.Message{Role: provider.RoleAssistant, Content: []provider.Content{provider.TextBlock{Text: "Verification failed: service down."}}}},
30 } {33 } {
31 if err := journal.RecordAgentEvent(event); err != nil {34 if err := journal.RecordAgentEvent(event); err != nil {
32 t.Fatal(err)35 t.Fatal(err)
33 }36 }
34 }37 }
35- if err := journal.RecordTurnFinished(spec, spec.InitialTurnID, ErrBudgetExceeded); err != nil {38+ turnErr := errors.New("turn failed")
39+ if err := journal.RecordTurnFinished(spec, spec.InitialTurnID, turnErr); err != nil {
36 t.Fatal(err)40 t.Fatal(err)
37 }41 }
38 result, err := journal.Result()42 result, err := journal.Result()
39 if err != nil {43 if err != nil {
40 t.Fatal(err)44 t.Fatal(err)
41 }45 }
42- update := FormatCompletionUpdate([]Completion{(ResidentCompletion{ChildID: spec.ID, TurnID: spec.InitialTurnID, Err: ErrBudgetExceeded, Summary: result.Handoff}).Completion()}, "")46+ // Handoff is deprecated: new results stay empty, so no raw tool
43- for _, text := range []string{result.Handoff, update} {47+ // payload can leak through the terminal projection. The completion
44- for _, secret := range []string{"synthetic-argument-secret", "synthetic-output-secret", "Authorization", "curl"} {48+ // update carries the production summary instead. Archived v2
45- if strings.Contains(text, secret) {49+ // handoffs remain readable.
46- t.Fatalf("handoff disclosed raw tool payload %q", secret)50+ if result.Handoff != "" {
47- }51+ t.Fatalf("new result carries handoff %q, want empty", result.Handoff)
48- }52+ }
49- for _, want := range []string{"Service verification is incomplete.", "tool bash (call)", "is_error=true", HistoryRef(spec.ID)} {53+ if result.Summary != "Verification failed: service down." {
50- if !strings.Contains(text, want) {54+ t.Fatalf("new result summary = %q", result.Summary)
51- t.Fatalf("handoff missing safe metadata %q: %s", want, text)55+ }
52- }56+ update := FormatCompletionUpdate([]Completion{(ResidentCompletion{ChildID: spec.ID, TurnID: spec.InitialTurnID, Err: turnErr, Summary: result.Summary}).Completion()}, "")
57+ if !strings.Contains(update, "Verification failed: service down.") {
58+ t.Fatalf("completion update missing summary: %q", update)
59+ }
60+ for _, secret := range []string{"synthetic-argument-secret", "synthetic-output-secret", "Authorization", "curl"} {
61+ if strings.Contains(update, secret) {
62+ t.Fatalf("completion update disclosed raw tool payload %q", secret)
53 }63 }
54 }64 }
55 items, err := ReadResidentHistory(journal.Dir(), 16)65 items, err := ReadResidentHistory(journal.Dir(), 16)
56 if err != nil {66 if err != nil {
57 t.Fatal(err)67 t.Fatal(err)
58 }68 }
59- raw, err := json.Marshal(items)69+ if len(items) == 0 || items[len(items)-1].Type != residentRecordAssistant {
70+ t.Fatalf("history tail = %#v, want latest assistant message", items)
71+ }
72+ // The bounded history page starts at the latest prompt; the full
73+ // secrets remain in the authoritative transcript records.
74+ records, err := ReadResidentJournal(filepath.Join(journal.Dir(), residentTranscriptName))
75+ if err != nil {
76+ t.Fatal(err)
77+ }
78+ raw, err := json.Marshal(records)
60 if err != nil || !strings.Contains(string(raw), "synthetic-argument-secret") || !strings.Contains(string(raw), "synthetic-output-secret") {79 if err != nil || !strings.Contains(string(raw), "synthetic-argument-secret") || !strings.Contains(string(raw), "synthetic-output-secret") {
61- t.Fatal("omitting automatic payloads must not destroy durable recovery history")80+ t.Fatal("empty handoff must not destroy durable recovery history")
62 }81 }
63 }82 }
M packages/agent/subagents/resident_journal.go +110 -107
@@ -19,7 +19,7 @@ import (
1919 "github.com/bnema/zut/packages/provider"
2020 )
2121
22-var ErrIncompatibleResidentBudget = errors.New("resident journal: incompatible rollout budget; start a new subagent")
22+var ErrIncompatibleResidentJournal = errors.New("resident journal: incompatible version; start a new subagent")
2323
2424 const (
2525 residentMetadataName = "metadata.json"
@@ -37,7 +37,8 @@ const (
3737 residentRecordToolCall = "tool.call"
3838 residentRecordToolResult = "tool.result"
3939 residentRecordUsage = "usage"
40- residentJournalVersion = 2
40+ residentJournalVersion = 3
41+ residentJournalVersionV2 = 2
4142 residentInterruptedText = "tool interrupted by resident host restart"
4243 residentMaxRecordBytes = 2 << 20
4344 residentResultSummaryBytes = 256 << 10
@@ -54,25 +55,26 @@ func PatchRef(id string) string { return AgentRef(id) + "/patch" }
5455 type ResidentState string
5556
5657 const (
57- ResidentQueued ResidentState = "queued"
58- ResidentRunning ResidentState = "running"
59- ResidentIdle ResidentState = "idle"
60- ResidentCompleted ResidentState = "completed"
61- ResidentFailed ResidentState = "failed"
62- ResidentStopped ResidentState = "stopped"
63- ResidentInterrupted ResidentState = "interrupted"
64- ResidentBudgetExhausted ResidentState = "budget_exhausted"
58+ ResidentQueued ResidentState = "queued"
59+ ResidentRunning ResidentState = "running"
60+ ResidentIdle ResidentState = "idle"
61+ ResidentCompleted ResidentState = "completed"
62+ ResidentFailed ResidentState = "failed"
63+ ResidentStopped ResidentState = "stopped"
64+ ResidentInterrupted ResidentState = "interrupted"
6565 )
6666
6767 // Journal outcomes and result error codes are persisted compatibility values.
68+// The residentV2 constants describe the removed rollout-budget stop; they
69+// are read-only translation keys for v2 archives, never written.
6870 const (
69- residentOutcomeCompleted = string(ResidentCompleted)
70- residentOutcomeFailed = string(ResidentFailed)
71- residentOutcomeInterrupted = string(ResidentInterrupted)
72- residentOutcomeBudgetExhausted = string(ResidentBudgetExhausted)
73- residentOutcomeCompletedBudgetExhausted = "completed_budget_exhausted"
74- residentErrorTurnFailed = "turn_failed"
75- residentErrorBudgetExhausted = string(ResidentBudgetExhausted)
71+ residentOutcomeCompleted = string(ResidentCompleted)
72+ residentOutcomeFailed = string(ResidentFailed)
73+ residentOutcomeInterrupted = string(ResidentInterrupted)
74+ residentV2OutcomeBudgetExhausted = "budget_exhausted"
75+ residentV2OutcomeCompletedBudgetExhausted = "completed_budget_exhausted"
76+ residentErrorTurnFailed = "turn_failed"
77+ residentV2ErrorBudgetExhausted = "budget_exhausted"
7678 )
7779
7880 // ResidentChildSpec is the non-secret configuration required to reconstruct
@@ -106,32 +108,28 @@ type ResidentChildSpec struct {
106108 InheritSkills *bool `json:"inherit_skills,omitempty"`
107109 Permissions []string `json:"permissions,omitempty"`
108110 Required bool `json:"required,omitempty"`
109- BudgetLimit int64 `json:"budget_limit,omitempty"`
110- BudgetRatio float64 `json:"budget_ratio,omitempty"`
111- BudgetSource string `json:"budget_source,omitempty"`
112111 }
113112
114113 type residentRecord struct {
115- Version int `json:"version"`
116- Type string `json:"type"`
117- Time time.Time `json:"time"`
118- Spec *ResidentChildSpec `json:"spec,omitempty"`
119- TurnID string `json:"turn_id,omitempty"`
120- Prompt string `json:"prompt,omitempty"`
121- Outcome string `json:"outcome,omitempty"`
122- PatchRef string `json:"patch_ref,omitempty"`
123- ChangedFiles []string `json:"changed_files,omitempty"`
124- BudgetBaseline int64 `json:"budget_baseline,omitempty"`
125- Message json.RawMessage `json:"message,omitempty"`
126- ToolID string `json:"tool_id,omitempty"`
127- ToolName string `json:"tool_name,omitempty"`
128- ToolArgs json.RawMessage `json:"tool_args,omitempty"`
129- ToolResult json.RawMessage `json:"tool_result,omitempty"`
130- Usage *provider.Usage `json:"usage,omitempty"`
131- ContextUsed int `json:"context_used,omitempty"`
132- ContextMax int `json:"context_max,omitempty"`
133- Subscription bool `json:"subscription,omitempty"`
134- raw json.RawMessage `json:"-"`
114+ Version int `json:"version"`
115+ Type string `json:"type"`
116+ Time time.Time `json:"time"`
117+ Spec *ResidentChildSpec `json:"spec,omitempty"`
118+ TurnID string `json:"turn_id,omitempty"`
119+ Prompt string `json:"prompt,omitempty"`
120+ Outcome string `json:"outcome,omitempty"`
121+ PatchRef string `json:"patch_ref,omitempty"`
122+ ChangedFiles []string `json:"changed_files,omitempty"`
123+ Message json.RawMessage `json:"message,omitempty"`
124+ ToolID string `json:"tool_id,omitempty"`
125+ ToolName string `json:"tool_name,omitempty"`
126+ ToolArgs json.RawMessage `json:"tool_args,omitempty"`
127+ ToolResult json.RawMessage `json:"tool_result,omitempty"`
128+ Usage *provider.Usage `json:"usage,omitempty"`
129+ ContextUsed int `json:"context_used,omitempty"`
130+ ContextMax int `json:"context_max,omitempty"`
131+ Subscription bool `json:"subscription,omitempty"`
132+ raw json.RawMessage `json:"-"`
135133 }
136134
137135 // RecordAgentEvent persists finalized provider-neutral transcript events. It
@@ -245,7 +243,6 @@ type ResidentMetadata struct {
245243 State ResidentState `json:"state"`
246244 UpdatedAt time.Time `json:"updated_at"`
247245 Usage provider.Usage `json:"usage,omitempty"`
248- BudgetBaseline int64 `json:"budget_baseline,omitempty"`
249246 ContextUsed int `json:"context_used,omitempty"`
250247 ContextMax int `json:"context_max,omitempty"`
251248 Subscription bool `json:"subscription,omitempty"`
@@ -255,16 +252,19 @@ type ResidentMetadata struct {
255252 // remains authoritative for detailed content; this file allows status and
256253 // resource readers to locate a terminal outcome without reading it all.
257254 type ResidentResult struct {
258- Version int `json:"version"`
259- ID string `json:"id"`
260- TurnID string `json:"turn_id"`
261- State ResidentState `json:"state"`
262- Summary string `json:"summary,omitempty"`
263- Handoff string `json:"handoff,omitempty"`
264- ErrorCode string `json:"error_code,omitempty"`
265- PatchRef string `json:"patch_ref,omitempty"`
266- ChangedFiles []string `json:"changed_files,omitempty"`
267- CreatedAt time.Time `json:"created_at"`
255+ Version int `json:"version"`
256+ ID string `json:"id"`
257+ TurnID string `json:"turn_id"`
258+ State ResidentState `json:"state"`
259+ Summary string `json:"summary,omitempty"`
260+ // Handoff is deprecated: it carried the auto-generated partial handoff
261+ // of the removed rollout-budget stop. It stays empty for new results
262+ // and remains readable only for archived v2 results.
263+ Handoff string `json:"handoff,omitempty"`
264+ ErrorCode string `json:"error_code,omitempty"`
265+ PatchRef string `json:"patch_ref,omitempty"`
266+ ChangedFiles []string `json:"changed_files,omitempty"`
267+ CreatedAt time.Time `json:"created_at"`
268268 }
269269
270270 // ResidentJournal serializes durable child records. It is intentionally a
@@ -277,7 +277,6 @@ type ResidentJournal struct {
277277 latestSummary string
278278 usageMu sync.RWMutex
279279 usage provider.Usage
280- budgetBaseline int64
281280 usageRecordPersisted bool
282281 usageMetadataPersisted bool
283282 contextUsed int
@@ -291,11 +290,10 @@ type ResidentJournal struct {
291290 // ResidentUsageSnapshot is the bounded usage projection shared by durable and
292291 // live resident state.
293292 type ResidentUsageSnapshot struct {
294- Usage provider.Usage
295- ContextUsed int
296- ContextMax int
297- Subscription bool
298- BudgetBaseline int64
293+ Usage provider.Usage
294+ ContextUsed int
295+ ContextMax int
296+ Subscription bool
299297 }
300298
301299 // ConfigureUsage records resolved model metadata and restores the latest
@@ -307,12 +305,11 @@ func (j *ResidentJournal) ConfigureUsage(contextMax int, subscription bool) Resi
307305 j.usageMu.Lock()
308306 if metadata, err := ReadResidentMetadata(filepath.Join(j.dir, residentMetadataName)); err == nil {
309307 j.usage = metadata.Usage
310- j.budgetBaseline = metadata.BudgetBaseline
311308 j.contextUsed = metadata.ContextUsed
312309 }
313310 j.contextMax = contextMax
314311 j.subscription = subscription
315- snapshot := ResidentUsageSnapshot{Usage: j.usage, ContextUsed: j.contextUsed, ContextMax: j.contextMax, Subscription: j.subscription, BudgetBaseline: j.budgetBaseline}
312+ snapshot := ResidentUsageSnapshot{Usage: j.usage, ContextUsed: j.contextUsed, ContextMax: j.contextMax, Subscription: j.subscription}
316313 j.usageMu.Unlock()
317314 return snapshot
318315 }
@@ -323,12 +320,7 @@ func (j *ResidentJournal) usageSnapshot() ResidentUsageSnapshot {
323320 }
324321 j.usageMu.RLock()
325322 defer j.usageMu.RUnlock()
326- return ResidentUsageSnapshot{Usage: j.usage, ContextUsed: j.contextUsed, ContextMax: j.contextMax, Subscription: j.subscription, BudgetBaseline: j.budgetBaseline}
327-}
328-
329-// BudgetBaseline is the durable weighted usage at the latest explicit recovery.
330-func (j *ResidentJournal) BudgetBaseline() int64 {
331- return j.usageSnapshot().BudgetBaseline
323+ return ResidentUsageSnapshot{Usage: j.usage, ContextUsed: j.contextUsed, ContextMax: j.contextMax, Subscription: j.subscription}
332324 }
333325
334326 func (j *ResidentJournal) usageRecordPersistence(record residentRecord) (durable, metadataPersisted bool) {
@@ -412,14 +404,6 @@ func (j *ResidentJournal) Dir() string {
412404 return j.dir
413405 }
414406
415-// ValidateResidentBudget ensures a new child has a model-context budget.
416-func ValidateResidentBudget(spec ResidentChildSpec) error {
417- if spec.BudgetLimit <= 0 || spec.BudgetSource != "model_context" {
418- return ErrIncompatibleResidentBudget
419- }
420- return nil
421-}
422-
423407 // Accept establishes the authoritative spawn commit point. The transcript is
424408 // synced before metadata is projected so a crash cannot fabricate acceptance.
425409 func (j *ResidentJournal) Accept(spec ResidentChildSpec, prompt string) error {
@@ -465,21 +449,9 @@ func (j *ResidentJournal) AcceptFollowUp(spec ResidentChildSpec, turnID, prompt
465449 return errors.New("resident journal: closed")
466450 }
467451 now := time.Now().UTC()
468- usage := j.usageSnapshot()
469- baseline := usage.BudgetBaseline
470- metadata, err := ReadResidentMetadata(filepath.Join(j.dir, residentMetadataName))
471- if err != nil {
472- return err
473- }
474- if metadata.State != ResidentRunning && metadata.State != ResidentQueued && budgetSnapshotSince(usage.Usage, spec.BudgetLimit, baseline).State == BudgetExceeded {
475- baseline = WeightedBudgetUsage(usage.Usage)
476- }
477- if err := j.appendSync(residentRecord{Version: residentJournalVersion, Type: residentRecordTurnAccepted, Time: now, TurnID: turnID, Prompt: prompt, BudgetBaseline: baseline}); err != nil {
452+ if err := j.appendSync(residentRecord{Version: residentJournalVersion, Type: residentRecordTurnAccepted, Time: now, TurnID: turnID, Prompt: prompt}); err != nil {
478453 return err
479454 }
480- j.usageMu.Lock()
481- j.budgetBaseline = baseline
482- j.usageMu.Unlock()
483455 return writeResidentMetadata(j.dir, j.metadata(spec, ResidentQueued, now))
484456 }
485457
@@ -515,19 +487,11 @@ func (j *ResidentJournal) finishTurn(spec ResidentChildSpec, turnID string, turn
515487 if errors.Is(turnErr, context.Canceled) {
516488 state, outcome = ResidentInterrupted, residentOutcomeInterrupted
517489 recordType = residentRecordInterrupted
518- } else if errors.Is(turnErr, ErrBudgetExceeded) {
519- state, outcome = ResidentBudgetExhausted, residentOutcomeBudgetExhausted
520- } else if turnErr == nil && budgetSnapshotSince(j.usageSnapshot().Usage, spec.BudgetLimit, j.BudgetBaseline()).State == BudgetExceeded {
521- state, outcome = ResidentCompleted, residentOutcomeCompletedBudgetExhausted
522490 }
523491 result := ResidentResult{Version: residentJournalVersion, ID: spec.ID, TurnID: turnID, State: state, Summary: j.latestAssistantSummary(), CreatedAt: time.Now().UTC()}
524492 if turnErr != nil {
525493 result.ErrorCode = residentErrorTurnFailed
526494 }
527- if state == ResidentBudgetExhausted {
528- result.ErrorCode = residentErrorBudgetExhausted
529- result.Handoff = residentBudgetHandoff(j.dir, spec)
530- }
531495 if capture != nil {
532496 result.ChangedFiles = append([]string(nil), capture.ChangedFiles...)
533497 if len(capture.Patch) > 0 {
@@ -625,7 +589,7 @@ func (j *ResidentJournal) metadata(spec ResidentChildSpec, state ResidentState,
625589 return ResidentMetadata{
626590 Version: residentJournalVersion, ID: spec.ID, SessionID: spec.SessionID,
627591 RootCacheID: spec.RootCacheID, ParentSessionID: spec.ParentSessionID, State: state, UpdatedAt: updatedAt,
628- Usage: usage.Usage, ContextUsed: usage.ContextUsed, ContextMax: usage.ContextMax, Subscription: usage.Subscription, BudgetBaseline: usage.BudgetBaseline,
592+ Usage: usage.Usage, ContextUsed: usage.ContextUsed, ContextMax: usage.ContextMax, Subscription: usage.Subscription,
629593 }
630594 }
631595
@@ -737,9 +701,18 @@ func ReadResidentResult(path string) (ResidentResult, error) {
737701 if err := json.Unmarshal(data, &result); err != nil {
738702 return ResidentResult{}, err
739703 }
740- if result.Version != residentJournalVersion || result.ID == "" || result.TurnID == "" {
704+ if result.Version != residentJournalVersion && result.Version != residentJournalVersionV2 {
705+ return ResidentResult{}, errors.New("resident result: invalid projection")
706+ }
707+ if result.ID == "" || result.TurnID == "" {
741708 return ResidentResult{}, errors.New("resident result: invalid projection")
742709 }
710+ if result.Version == residentJournalVersionV2 && result.State == ResidentState(residentV2OutcomeBudgetExhausted) {
711+ // v2 translation: a budget stop was a failure to finish the accepted
712+ // turn, never a success. The archived handoff and error code stay
713+ // readable as historical provenance.
714+ result.State = ResidentFailed
715+ }
743716 return result, nil
744717 }
745718
@@ -807,8 +780,8 @@ func reconcileOwnedResidentJournal(journal *ResidentJournal) (ResidentMetadata,
807780 if len(records) == 0 || records[0].Type != residentRecordAccepted || records[0].Spec == nil {
808781 return ResidentMetadata{}, errors.New("resident journal: missing accepted child record")
809782 }
810- if records[0].Version != residentJournalVersion {
811- return ResidentMetadata{}, ErrIncompatibleResidentBudget
783+ if records[0].Version != residentJournalVersion && records[0].Version != residentJournalVersionV2 {
784+ return ResidentMetadata{}, ErrIncompatibleResidentJournal
812785 }
813786 if repaired, ok := repairLegacyFalseRecovery(records); ok {
814787 if err := journal.rewriteTranscript(repaired); err != nil {
@@ -820,6 +793,13 @@ func reconcileOwnedResidentJournal(journal *ResidentJournal) (ResidentMetadata,
820793 if spec.ID == "" || spec.SessionID == "" {
821794 return ResidentMetadata{}, errors.New("resident journal: invalid accepted child record")
822795 }
796+ // v2 journals carry the removed rollout-budget outcomes and baselines;
797+ // they are read with explicit translation below. Translation rebuilds
798+ // the metadata/result projections; the transcript itself is never
799+ // rewritten by translation (only the legacy false-recovery repair
800+ // rewrites it, with a backup). Unknown versions are rejected above so
801+ // no archive becomes false success.
802+ translateV2BudgetOutcome := records[0].Version == residentJournalVersionV2
823803 state := ResidentQueued
824804 lastStateAt := records[0].Time
825805 var lastFinished residentRecord
@@ -838,13 +818,18 @@ func reconcileOwnedResidentJournal(journal *ResidentJournal) (ResidentMetadata,
838818 if index > 0 && record.Type == residentRecordAccepted {
839819 return ResidentMetadata{}, errors.New("resident journal: duplicate accepted child record")
840820 }
821+ // Resumed v2 journals legitimately mix versions: the v2 prefix
822+ // stays on disk while new records stamp v3. Any other version is
823+ // corruption, never a silent success.
824+ if record.Version != residentJournalVersion && record.Version != residentJournalVersionV2 {
825+ return ResidentMetadata{}, ErrIncompatibleResidentJournal
826+ }
841827 switch record.Type {
842828 case residentRecordTurnAccepted:
843829 if record.TurnID == "" || seenTurns[record.TurnID] != "" {
844830 return ResidentMetadata{}, errors.New("resident journal: invalid accepted turn record")
845831 }
846832 seenTurns[record.TurnID] = residentRecordTurnAccepted
847- usage.BudgetBaseline = record.BudgetBaseline
848833 lastStateAt = record.Time
849834 case residentRecordTurnStarted:
850835 if record.TurnID == "" || (seenTurns[record.TurnID] != residentRecordTurnAccepted && record.TurnID != spec.InitialTurnID) {
@@ -863,11 +848,28 @@ func reconcileOwnedResidentJournal(journal *ResidentJournal) (ResidentMetadata,
863848 switch record.Outcome {
864849 case residentOutcomeFailed:
865850 state = ResidentFailed
866- case residentOutcomeBudgetExhausted:
867- state = ResidentBudgetExhausted
868- case residentOutcomeCompletedBudgetExhausted:
851+ case residentV2OutcomeBudgetExhausted:
852+ if translateV2BudgetOutcome {
853+ // v2 outcome: the cumulative rollout budget no longer
854+ // exists. A budget stop was a failure to finish the
855+ // accepted turn, so reconcile it as failed — never
856+ // silently as success. Required work stays unmet until
857+ // an explicit successful follow-up.
858+ state = ResidentFailed
859+ } else {
860+ return ResidentMetadata{}, errors.New("resident journal: invalid turn outcome")
861+ }
862+ case residentV2OutcomeCompletedBudgetExhausted:
863+ if !translateV2BudgetOutcome {
864+ return ResidentMetadata{}, errors.New("resident journal: invalid turn outcome")
865+ }
866+ // The turn completed before the budget check fired. It is
867+ // a genuine success.
869868 state = ResidentCompleted
870869 default:
870+ if record.Outcome != residentOutcomeCompleted {
871+ return ResidentMetadata{}, errors.New("resident journal: invalid turn outcome")
872+ }
871873 state = ResidentIdle
872874 }
873875 lastStateAt = record.Time
@@ -914,7 +916,7 @@ func reconcileOwnedResidentJournal(journal *ResidentJournal) (ResidentMetadata,
914916 if record.Usage == nil {
915917 return ResidentMetadata{}, errors.New("resident journal: invalid usage record")
916918 }
917- usage = ResidentUsageSnapshot{Usage: *record.Usage, ContextUsed: record.ContextUsed, ContextMax: record.ContextMax, Subscription: record.Subscription, BudgetBaseline: usage.BudgetBaseline}
919+ usage = ResidentUsageSnapshot{Usage: *record.Usage, ContextUsed: record.ContextUsed, ContextMax: record.ContextMax, Subscription: record.Subscription}
918920 }
919921 }
920922 if lastStateAt.IsZero() {
@@ -923,7 +925,7 @@ func reconcileOwnedResidentJournal(journal *ResidentJournal) (ResidentMetadata,
923925 metadata := ResidentMetadata{
924926 Version: residentJournalVersion, ID: spec.ID, SessionID: spec.SessionID,
925927 RootCacheID: spec.RootCacheID, ParentSessionID: spec.ParentSessionID, State: state, UpdatedAt: lastStateAt,
926- Usage: usage.Usage, ContextUsed: usage.ContextUsed, ContextMax: usage.ContextMax, Subscription: usage.Subscription, BudgetBaseline: usage.BudgetBaseline,
928+ Usage: usage.Usage, ContextUsed: usage.ContextUsed, ContextMax: usage.ContextMax, Subscription: usage.Subscription,
927929 }
928930 needsInterruption := state == ResidentQueued || state == ResidentRunning
929931 needsToolRepair := len(toolCalls) != len(toolResults)
@@ -1170,9 +1172,10 @@ func rebuildResidentResult(dir string, spec ResidentChildSpec, metadata Resident
11701172 switch finished.Outcome {
11711173 case residentOutcomeFailed:
11721174 result.ErrorCode = residentErrorTurnFailed
1173- case residentOutcomeBudgetExhausted:
1174- result.ErrorCode = residentErrorBudgetExhausted
1175- result.Handoff = residentBudgetHandoff(dir, spec)
1175+ case residentV2OutcomeBudgetExhausted, residentV2OutcomeCompletedBudgetExhausted:
1176+ // Historical v2 outcome. The error code stays for provenance; no
1177+ // handoff is generated (deprecated, empty for new results).
1178+ result.ErrorCode = residentV2ErrorBudgetExhausted
11761179 }
11771180 return writeResidentResult(dir, result)
11781181 }
@@ -19,7 +19,7 @@ import (
19 "github.com/bnema/zut/packages/provider"19 "github.com/bnema/zut/packages/provider"
20 )20 )
21 21
22-var ErrIncompatibleResidentBudget = errors.New("resident journal: incompatible rollout budget; start a new subagent")22+var ErrIncompatibleResidentJournal = errors.New("resident journal: incompatible version; start a new subagent")
23 23
24 const (24 const (
25 residentMetadataName = "metadata.json"25 residentMetadataName = "metadata.json"
@@ -37,7 +37,8 @@ const (
37 residentRecordToolCall = "tool.call"37 residentRecordToolCall = "tool.call"
38 residentRecordToolResult = "tool.result"38 residentRecordToolResult = "tool.result"
39 residentRecordUsage = "usage"39 residentRecordUsage = "usage"
40- residentJournalVersion = 240+ residentJournalVersion = 3
41+ residentJournalVersionV2 = 2
41 residentInterruptedText = "tool interrupted by resident host restart"42 residentInterruptedText = "tool interrupted by resident host restart"
42 residentMaxRecordBytes = 2 << 2043 residentMaxRecordBytes = 2 << 20
43 residentResultSummaryBytes = 256 << 1044 residentResultSummaryBytes = 256 << 10
@@ -54,25 +55,26 @@ func PatchRef(id string) string { return AgentRef(id) + "/patch" }
54 type ResidentState string55 type ResidentState string
55 56
56 const (57 const (
57- ResidentQueued ResidentState = "queued"58+ ResidentQueued ResidentState = "queued"
58- ResidentRunning ResidentState = "running"59+ ResidentRunning ResidentState = "running"
59- ResidentIdle ResidentState = "idle"60+ ResidentIdle ResidentState = "idle"
60- ResidentCompleted ResidentState = "completed"61+ ResidentCompleted ResidentState = "completed"
61- ResidentFailed ResidentState = "failed"62+ ResidentFailed ResidentState = "failed"
62- ResidentStopped ResidentState = "stopped"63+ ResidentStopped ResidentState = "stopped"
63- ResidentInterrupted ResidentState = "interrupted"64+ ResidentInterrupted ResidentState = "interrupted"
64- ResidentBudgetExhausted ResidentState = "budget_exhausted"
65 )65 )
66 66
67 // Journal outcomes and result error codes are persisted compatibility values.67 // Journal outcomes and result error codes are persisted compatibility values.
68+// The residentV2 constants describe the removed rollout-budget stop; they
69+// are read-only translation keys for v2 archives, never written.
68 const (70 const (
69- residentOutcomeCompleted = string(ResidentCompleted)71+ residentOutcomeCompleted = string(ResidentCompleted)
70- residentOutcomeFailed = string(ResidentFailed)72+ residentOutcomeFailed = string(ResidentFailed)
71- residentOutcomeInterrupted = string(ResidentInterrupted)73+ residentOutcomeInterrupted = string(ResidentInterrupted)
72- residentOutcomeBudgetExhausted = string(ResidentBudgetExhausted)74+ residentV2OutcomeBudgetExhausted = "budget_exhausted"
73- residentOutcomeCompletedBudgetExhausted = "completed_budget_exhausted"75+ residentV2OutcomeCompletedBudgetExhausted = "completed_budget_exhausted"
74- residentErrorTurnFailed = "turn_failed"76+ residentErrorTurnFailed = "turn_failed"
75- residentErrorBudgetExhausted = string(ResidentBudgetExhausted)77+ residentV2ErrorBudgetExhausted = "budget_exhausted"
76 )78 )
77 79
78 // ResidentChildSpec is the non-secret configuration required to reconstruct80 // ResidentChildSpec is the non-secret configuration required to reconstruct
@@ -106,32 +108,28 @@ type ResidentChildSpec struct {
106 InheritSkills *bool `json:"inherit_skills,omitempty"`108 InheritSkills *bool `json:"inherit_skills,omitempty"`
107 Permissions []string `json:"permissions,omitempty"`109 Permissions []string `json:"permissions,omitempty"`
108 Required bool `json:"required,omitempty"`110 Required bool `json:"required,omitempty"`
109- BudgetLimit int64 `json:"budget_limit,omitempty"`
110- BudgetRatio float64 `json:"budget_ratio,omitempty"`
111- BudgetSource string `json:"budget_source,omitempty"`
112 }111 }
113 112
114 type residentRecord struct {113 type residentRecord struct {
115- Version int `json:"version"`114+ Version int `json:"version"`
116- Type string `json:"type"`115+ Type string `json:"type"`
117- Time time.Time `json:"time"`116+ Time time.Time `json:"time"`
118- Spec *ResidentChildSpec `json:"spec,omitempty"`117+ Spec *ResidentChildSpec `json:"spec,omitempty"`
119- TurnID string `json:"turn_id,omitempty"`118+ TurnID string `json:"turn_id,omitempty"`
120- Prompt string `json:"prompt,omitempty"`119+ Prompt string `json:"prompt,omitempty"`
121- Outcome string `json:"outcome,omitempty"`120+ Outcome string `json:"outcome,omitempty"`
122- PatchRef string `json:"patch_ref,omitempty"`121+ PatchRef string `json:"patch_ref,omitempty"`
123- ChangedFiles []string `json:"changed_files,omitempty"`122+ ChangedFiles []string `json:"changed_files,omitempty"`
124- BudgetBaseline int64 `json:"budget_baseline,omitempty"`123+ Message json.RawMessage `json:"message,omitempty"`
125- Message json.RawMessage `json:"message,omitempty"`124+ ToolID string `json:"tool_id,omitempty"`
126- ToolID string `json:"tool_id,omitempty"`125+ ToolName string `json:"tool_name,omitempty"`
127- ToolName string `json:"tool_name,omitempty"`126+ ToolArgs json.RawMessage `json:"tool_args,omitempty"`
128- ToolArgs json.RawMessage `json:"tool_args,omitempty"`127+ ToolResult json.RawMessage `json:"tool_result,omitempty"`
129- ToolResult json.RawMessage `json:"tool_result,omitempty"`128+ Usage *provider.Usage `json:"usage,omitempty"`
130- Usage *provider.Usage `json:"usage,omitempty"`129+ ContextUsed int `json:"context_used,omitempty"`
131- ContextUsed int `json:"context_used,omitempty"`130+ ContextMax int `json:"context_max,omitempty"`
132- ContextMax int `json:"context_max,omitempty"`131+ Subscription bool `json:"subscription,omitempty"`
133- Subscription bool `json:"subscription,omitempty"`132+ raw json.RawMessage `json:"-"`
134- raw json.RawMessage `json:"-"`
135 }133 }
136 134
137 // RecordAgentEvent persists finalized provider-neutral transcript events. It135 // RecordAgentEvent persists finalized provider-neutral transcript events. It
@@ -245,7 +243,6 @@ type ResidentMetadata struct {
245 State ResidentState `json:"state"`243 State ResidentState `json:"state"`
246 UpdatedAt time.Time `json:"updated_at"`244 UpdatedAt time.Time `json:"updated_at"`
247 Usage provider.Usage `json:"usage,omitempty"`245 Usage provider.Usage `json:"usage,omitempty"`
248- BudgetBaseline int64 `json:"budget_baseline,omitempty"`
249 ContextUsed int `json:"context_used,omitempty"`246 ContextUsed int `json:"context_used,omitempty"`
250 ContextMax int `json:"context_max,omitempty"`247 ContextMax int `json:"context_max,omitempty"`
251 Subscription bool `json:"subscription,omitempty"`248 Subscription bool `json:"subscription,omitempty"`
@@ -255,16 +252,19 @@ type ResidentMetadata struct {
255 // remains authoritative for detailed content; this file allows status and252 // remains authoritative for detailed content; this file allows status and
256 // resource readers to locate a terminal outcome without reading it all.253 // resource readers to locate a terminal outcome without reading it all.
257 type ResidentResult struct {254 type ResidentResult struct {
258- Version int `json:"version"`255+ Version int `json:"version"`
259- ID string `json:"id"`256+ ID string `json:"id"`
260- TurnID string `json:"turn_id"`257+ TurnID string `json:"turn_id"`
261- State ResidentState `json:"state"`258+ State ResidentState `json:"state"`
262- Summary string `json:"summary,omitempty"`259+ Summary string `json:"summary,omitempty"`
263- Handoff string `json:"handoff,omitempty"`260+ // Handoff is deprecated: it carried the auto-generated partial handoff
264- ErrorCode string `json:"error_code,omitempty"`261+ // of the removed rollout-budget stop. It stays empty for new results
265- PatchRef string `json:"patch_ref,omitempty"`262+ // and remains readable only for archived v2 results.
266- ChangedFiles []string `json:"changed_files,omitempty"`263+ Handoff string `json:"handoff,omitempty"`
267- CreatedAt time.Time `json:"created_at"`264+ ErrorCode string `json:"error_code,omitempty"`
265+ PatchRef string `json:"patch_ref,omitempty"`
266+ ChangedFiles []string `json:"changed_files,omitempty"`
267+ CreatedAt time.Time `json:"created_at"`
268 }268 }
269 269
270 // ResidentJournal serializes durable child records. It is intentionally a270 // ResidentJournal serializes durable child records. It is intentionally a
@@ -277,7 +277,6 @@ type ResidentJournal struct {
277 latestSummary string277 latestSummary string
278 usageMu sync.RWMutex278 usageMu sync.RWMutex
279 usage provider.Usage279 usage provider.Usage
280- budgetBaseline int64
281 usageRecordPersisted bool280 usageRecordPersisted bool
282 usageMetadataPersisted bool281 usageMetadataPersisted bool
283 contextUsed int282 contextUsed int
@@ -291,11 +290,10 @@ type ResidentJournal struct {
291 // ResidentUsageSnapshot is the bounded usage projection shared by durable and290 // ResidentUsageSnapshot is the bounded usage projection shared by durable and
292 // live resident state.291 // live resident state.
293 type ResidentUsageSnapshot struct {292 type ResidentUsageSnapshot struct {
294- Usage provider.Usage293+ Usage provider.Usage
295- ContextUsed int294+ ContextUsed int
296- ContextMax int295+ ContextMax int
297- Subscription bool296+ Subscription bool
298- BudgetBaseline int64
299 }297 }
300 298
301 // ConfigureUsage records resolved model metadata and restores the latest299 // ConfigureUsage records resolved model metadata and restores the latest
@@ -307,12 +305,11 @@ func (j *ResidentJournal) ConfigureUsage(contextMax int, subscription bool) Resi
307 j.usageMu.Lock()305 j.usageMu.Lock()
308 if metadata, err := ReadResidentMetadata(filepath.Join(j.dir, residentMetadataName)); err == nil {306 if metadata, err := ReadResidentMetadata(filepath.Join(j.dir, residentMetadataName)); err == nil {
309 j.usage = metadata.Usage307 j.usage = metadata.Usage
310- j.budgetBaseline = metadata.BudgetBaseline
311 j.contextUsed = metadata.ContextUsed308 j.contextUsed = metadata.ContextUsed
312 }309 }
313 j.contextMax = contextMax310 j.contextMax = contextMax
314 j.subscription = subscription311 j.subscription = subscription
315- snapshot := ResidentUsageSnapshot{Usage: j.usage, ContextUsed: j.contextUsed, ContextMax: j.contextMax, Subscription: j.subscription, BudgetBaseline: j.budgetBaseline}312+ snapshot := ResidentUsageSnapshot{Usage: j.usage, ContextUsed: j.contextUsed, ContextMax: j.contextMax, Subscription: j.subscription}
316 j.usageMu.Unlock()313 j.usageMu.Unlock()
317 return snapshot314 return snapshot
318 }315 }
@@ -323,12 +320,7 @@ func (j *ResidentJournal) usageSnapshot() ResidentUsageSnapshot {
323 }320 }
324 j.usageMu.RLock()321 j.usageMu.RLock()
325 defer j.usageMu.RUnlock()322 defer j.usageMu.RUnlock()
326- return ResidentUsageSnapshot{Usage: j.usage, ContextUsed: j.contextUsed, ContextMax: j.contextMax, Subscription: j.subscription, BudgetBaseline: j.budgetBaseline}323+ return ResidentUsageSnapshot{Usage: j.usage, ContextUsed: j.contextUsed, ContextMax: j.contextMax, Subscription: j.subscription}
327-}
328-
329-// BudgetBaseline is the durable weighted usage at the latest explicit recovery.
330-func (j *ResidentJournal) BudgetBaseline() int64 {
331- return j.usageSnapshot().BudgetBaseline
332 }324 }
333 325
334 func (j *ResidentJournal) usageRecordPersistence(record residentRecord) (durable, metadataPersisted bool) {326 func (j *ResidentJournal) usageRecordPersistence(record residentRecord) (durable, metadataPersisted bool) {
@@ -412,14 +404,6 @@ func (j *ResidentJournal) Dir() string {
412 return j.dir404 return j.dir
413 }405 }
414 406
415-// ValidateResidentBudget ensures a new child has a model-context budget.
416-func ValidateResidentBudget(spec ResidentChildSpec) error {
417- if spec.BudgetLimit <= 0 || spec.BudgetSource != "model_context" {
418- return ErrIncompatibleResidentBudget
419- }
420- return nil
421-}
422-
423 // Accept establishes the authoritative spawn commit point. The transcript is407 // Accept establishes the authoritative spawn commit point. The transcript is
424 // synced before metadata is projected so a crash cannot fabricate acceptance.408 // synced before metadata is projected so a crash cannot fabricate acceptance.
425 func (j *ResidentJournal) Accept(spec ResidentChildSpec, prompt string) error {409 func (j *ResidentJournal) Accept(spec ResidentChildSpec, prompt string) error {
@@ -465,21 +449,9 @@ func (j *ResidentJournal) AcceptFollowUp(spec ResidentChildSpec, turnID, prompt
465 return errors.New("resident journal: closed")449 return errors.New("resident journal: closed")
466 }450 }
467 now := time.Now().UTC()451 now := time.Now().UTC()
468- usage := j.usageSnapshot()452+ if err := j.appendSync(residentRecord{Version: residentJournalVersion, Type: residentRecordTurnAccepted, Time: now, TurnID: turnID, Prompt: prompt}); err != nil {
469- baseline := usage.BudgetBaseline
470- metadata, err := ReadResidentMetadata(filepath.Join(j.dir, residentMetadataName))
471- if err != nil {
472- return err
473- }
474- if metadata.State != ResidentRunning && metadata.State != ResidentQueued && budgetSnapshotSince(usage.Usage, spec.BudgetLimit, baseline).State == BudgetExceeded {
475- baseline = WeightedBudgetUsage(usage.Usage)
476- }
477- if err := j.appendSync(residentRecord{Version: residentJournalVersion, Type: residentRecordTurnAccepted, Time: now, TurnID: turnID, Prompt: prompt, BudgetBaseline: baseline}); err != nil {
478 return err453 return err
479 }454 }
480- j.usageMu.Lock()
481- j.budgetBaseline = baseline
482- j.usageMu.Unlock()
483 return writeResidentMetadata(j.dir, j.metadata(spec, ResidentQueued, now))455 return writeResidentMetadata(j.dir, j.metadata(spec, ResidentQueued, now))
484 }456 }
485 457
@@ -515,19 +487,11 @@ func (j *ResidentJournal) finishTurn(spec ResidentChildSpec, turnID string, turn
515 if errors.Is(turnErr, context.Canceled) {487 if errors.Is(turnErr, context.Canceled) {
516 state, outcome = ResidentInterrupted, residentOutcomeInterrupted488 state, outcome = ResidentInterrupted, residentOutcomeInterrupted
517 recordType = residentRecordInterrupted489 recordType = residentRecordInterrupted
518- } else if errors.Is(turnErr, ErrBudgetExceeded) {
519- state, outcome = ResidentBudgetExhausted, residentOutcomeBudgetExhausted
520- } else if turnErr == nil && budgetSnapshotSince(j.usageSnapshot().Usage, spec.BudgetLimit, j.BudgetBaseline()).State == BudgetExceeded {
521- state, outcome = ResidentCompleted, residentOutcomeCompletedBudgetExhausted
522 }490 }
523 result := ResidentResult{Version: residentJournalVersion, ID: spec.ID, TurnID: turnID, State: state, Summary: j.latestAssistantSummary(), CreatedAt: time.Now().UTC()}491 result := ResidentResult{Version: residentJournalVersion, ID: spec.ID, TurnID: turnID, State: state, Summary: j.latestAssistantSummary(), CreatedAt: time.Now().UTC()}
524 if turnErr != nil {492 if turnErr != nil {
525 result.ErrorCode = residentErrorTurnFailed493 result.ErrorCode = residentErrorTurnFailed
526 }494 }
527- if state == ResidentBudgetExhausted {
528- result.ErrorCode = residentErrorBudgetExhausted
529- result.Handoff = residentBudgetHandoff(j.dir, spec)
530- }
531 if capture != nil {495 if capture != nil {
532 result.ChangedFiles = append([]string(nil), capture.ChangedFiles...)496 result.ChangedFiles = append([]string(nil), capture.ChangedFiles...)
533 if len(capture.Patch) > 0 {497 if len(capture.Patch) > 0 {
@@ -625,7 +589,7 @@ func (j *ResidentJournal) metadata(spec ResidentChildSpec, state ResidentState,
625 return ResidentMetadata{589 return ResidentMetadata{
626 Version: residentJournalVersion, ID: spec.ID, SessionID: spec.SessionID,590 Version: residentJournalVersion, ID: spec.ID, SessionID: spec.SessionID,
627 RootCacheID: spec.RootCacheID, ParentSessionID: spec.ParentSessionID, State: state, UpdatedAt: updatedAt,591 RootCacheID: spec.RootCacheID, ParentSessionID: spec.ParentSessionID, State: state, UpdatedAt: updatedAt,
628- Usage: usage.Usage, ContextUsed: usage.ContextUsed, ContextMax: usage.ContextMax, Subscription: usage.Subscription, BudgetBaseline: usage.BudgetBaseline,592+ Usage: usage.Usage, ContextUsed: usage.ContextUsed, ContextMax: usage.ContextMax, Subscription: usage.Subscription,
629 }593 }
630 }594 }
631 595
@@ -737,9 +701,18 @@ func ReadResidentResult(path string) (ResidentResult, error) {
737 if err := json.Unmarshal(data, &result); err != nil {701 if err := json.Unmarshal(data, &result); err != nil {
738 return ResidentResult{}, err702 return ResidentResult{}, err
739 }703 }
740- if result.Version != residentJournalVersion || result.ID == "" || result.TurnID == "" {704+ if result.Version != residentJournalVersion && result.Version != residentJournalVersionV2 {
705+ return ResidentResult{}, errors.New("resident result: invalid projection")
706+ }
707+ if result.ID == "" || result.TurnID == "" {
741 return ResidentResult{}, errors.New("resident result: invalid projection")708 return ResidentResult{}, errors.New("resident result: invalid projection")
742 }709 }
710+ if result.Version == residentJournalVersionV2 && result.State == ResidentState(residentV2OutcomeBudgetExhausted) {
711+ // v2 translation: a budget stop was a failure to finish the accepted
712+ // turn, never a success. The archived handoff and error code stay
713+ // readable as historical provenance.
714+ result.State = ResidentFailed
715+ }
743 return result, nil716 return result, nil
744 }717 }
745 718
@@ -807,8 +780,8 @@ func reconcileOwnedResidentJournal(journal *ResidentJournal) (ResidentMetadata,
807 if len(records) == 0 || records[0].Type != residentRecordAccepted || records[0].Spec == nil {780 if len(records) == 0 || records[0].Type != residentRecordAccepted || records[0].Spec == nil {
808 return ResidentMetadata{}, errors.New("resident journal: missing accepted child record")781 return ResidentMetadata{}, errors.New("resident journal: missing accepted child record")
809 }782 }
810- if records[0].Version != residentJournalVersion {783+ if records[0].Version != residentJournalVersion && records[0].Version != residentJournalVersionV2 {
811- return ResidentMetadata{}, ErrIncompatibleResidentBudget784+ return ResidentMetadata{}, ErrIncompatibleResidentJournal
812 }785 }
813 if repaired, ok := repairLegacyFalseRecovery(records); ok {786 if repaired, ok := repairLegacyFalseRecovery(records); ok {
814 if err := journal.rewriteTranscript(repaired); err != nil {787 if err := journal.rewriteTranscript(repaired); err != nil {
@@ -820,6 +793,13 @@ func reconcileOwnedResidentJournal(journal *ResidentJournal) (ResidentMetadata,
820 if spec.ID == "" || spec.SessionID == "" {793 if spec.ID == "" || spec.SessionID == "" {
821 return ResidentMetadata{}, errors.New("resident journal: invalid accepted child record")794 return ResidentMetadata{}, errors.New("resident journal: invalid accepted child record")
822 }795 }
796+ // v2 journals carry the removed rollout-budget outcomes and baselines;
797+ // they are read with explicit translation below. Translation rebuilds
798+ // the metadata/result projections; the transcript itself is never
799+ // rewritten by translation (only the legacy false-recovery repair
800+ // rewrites it, with a backup). Unknown versions are rejected above so
801+ // no archive becomes false success.
802+ translateV2BudgetOutcome := records[0].Version == residentJournalVersionV2
823 state := ResidentQueued803 state := ResidentQueued
824 lastStateAt := records[0].Time804 lastStateAt := records[0].Time
825 var lastFinished residentRecord805 var lastFinished residentRecord
@@ -838,13 +818,18 @@ func reconcileOwnedResidentJournal(journal *ResidentJournal) (ResidentMetadata,
838 if index > 0 && record.Type == residentRecordAccepted {818 if index > 0 && record.Type == residentRecordAccepted {
839 return ResidentMetadata{}, errors.New("resident journal: duplicate accepted child record")819 return ResidentMetadata{}, errors.New("resident journal: duplicate accepted child record")
840 }820 }
821+ // Resumed v2 journals legitimately mix versions: the v2 prefix
822+ // stays on disk while new records stamp v3. Any other version is
823+ // corruption, never a silent success.
824+ if record.Version != residentJournalVersion && record.Version != residentJournalVersionV2 {
825+ return ResidentMetadata{}, ErrIncompatibleResidentJournal
826+ }
841 switch record.Type {827 switch record.Type {
842 case residentRecordTurnAccepted:828 case residentRecordTurnAccepted:
843 if record.TurnID == "" || seenTurns[record.TurnID] != "" {829 if record.TurnID == "" || seenTurns[record.TurnID] != "" {
844 return ResidentMetadata{}, errors.New("resident journal: invalid accepted turn record")830 return ResidentMetadata{}, errors.New("resident journal: invalid accepted turn record")
845 }831 }
846 seenTurns[record.TurnID] = residentRecordTurnAccepted832 seenTurns[record.TurnID] = residentRecordTurnAccepted
847- usage.BudgetBaseline = record.BudgetBaseline
848 lastStateAt = record.Time833 lastStateAt = record.Time
849 case residentRecordTurnStarted:834 case residentRecordTurnStarted:
850 if record.TurnID == "" || (seenTurns[record.TurnID] != residentRecordTurnAccepted && record.TurnID != spec.InitialTurnID) {835 if record.TurnID == "" || (seenTurns[record.TurnID] != residentRecordTurnAccepted && record.TurnID != spec.InitialTurnID) {
@@ -863,11 +848,28 @@ func reconcileOwnedResidentJournal(journal *ResidentJournal) (ResidentMetadata,
863 switch record.Outcome {848 switch record.Outcome {
864 case residentOutcomeFailed:849 case residentOutcomeFailed:
865 state = ResidentFailed850 state = ResidentFailed
866- case residentOutcomeBudgetExhausted:851+ case residentV2OutcomeBudgetExhausted:
867- state = ResidentBudgetExhausted852+ if translateV2BudgetOutcome {
868- case residentOutcomeCompletedBudgetExhausted:853+ // v2 outcome: the cumulative rollout budget no longer
854+ // exists. A budget stop was a failure to finish the
855+ // accepted turn, so reconcile it as failed — never
856+ // silently as success. Required work stays unmet until
857+ // an explicit successful follow-up.
858+ state = ResidentFailed
859+ } else {
860+ return ResidentMetadata{}, errors.New("resident journal: invalid turn outcome")
861+ }
862+ case residentV2OutcomeCompletedBudgetExhausted:
863+ if !translateV2BudgetOutcome {
864+ return ResidentMetadata{}, errors.New("resident journal: invalid turn outcome")
865+ }
866+ // The turn completed before the budget check fired. It is
867+ // a genuine success.
869 state = ResidentCompleted868 state = ResidentCompleted
870 default:869 default:
870+ if record.Outcome != residentOutcomeCompleted {
871+ return ResidentMetadata{}, errors.New("resident journal: invalid turn outcome")
872+ }
871 state = ResidentIdle873 state = ResidentIdle
872 }874 }
873 lastStateAt = record.Time875 lastStateAt = record.Time
@@ -914,7 +916,7 @@ func reconcileOwnedResidentJournal(journal *ResidentJournal) (ResidentMetadata,
914 if record.Usage == nil {916 if record.Usage == nil {
915 return ResidentMetadata{}, errors.New("resident journal: invalid usage record")917 return ResidentMetadata{}, errors.New("resident journal: invalid usage record")
916 }918 }
917- usage = ResidentUsageSnapshot{Usage: *record.Usage, ContextUsed: record.ContextUsed, ContextMax: record.ContextMax, Subscription: record.Subscription, BudgetBaseline: usage.BudgetBaseline}919+ usage = ResidentUsageSnapshot{Usage: *record.Usage, ContextUsed: record.ContextUsed, ContextMax: record.ContextMax, Subscription: record.Subscription}
918 }920 }
919 }921 }
920 if lastStateAt.IsZero() {922 if lastStateAt.IsZero() {
@@ -923,7 +925,7 @@ func reconcileOwnedResidentJournal(journal *ResidentJournal) (ResidentMetadata,
923 metadata := ResidentMetadata{925 metadata := ResidentMetadata{
924 Version: residentJournalVersion, ID: spec.ID, SessionID: spec.SessionID,926 Version: residentJournalVersion, ID: spec.ID, SessionID: spec.SessionID,
925 RootCacheID: spec.RootCacheID, ParentSessionID: spec.ParentSessionID, State: state, UpdatedAt: lastStateAt,927 RootCacheID: spec.RootCacheID, ParentSessionID: spec.ParentSessionID, State: state, UpdatedAt: lastStateAt,
926- Usage: usage.Usage, ContextUsed: usage.ContextUsed, ContextMax: usage.ContextMax, Subscription: usage.Subscription, BudgetBaseline: usage.BudgetBaseline,928+ Usage: usage.Usage, ContextUsed: usage.ContextUsed, ContextMax: usage.ContextMax, Subscription: usage.Subscription,
927 }929 }
928 needsInterruption := state == ResidentQueued || state == ResidentRunning930 needsInterruption := state == ResidentQueued || state == ResidentRunning
929 needsToolRepair := len(toolCalls) != len(toolResults)931 needsToolRepair := len(toolCalls) != len(toolResults)
@@ -1170,9 +1172,10 @@ func rebuildResidentResult(dir string, spec ResidentChildSpec, metadata Resident
1170 switch finished.Outcome {1172 switch finished.Outcome {
1171 case residentOutcomeFailed:1173 case residentOutcomeFailed:
1172 result.ErrorCode = residentErrorTurnFailed1174 result.ErrorCode = residentErrorTurnFailed
1173- case residentOutcomeBudgetExhausted:1175+ case residentV2OutcomeBudgetExhausted, residentV2OutcomeCompletedBudgetExhausted:
1174- result.ErrorCode = residentErrorBudgetExhausted1176+ // Historical v2 outcome. The error code stays for provenance; no
1175- result.Handoff = residentBudgetHandoff(dir, spec)1177+ // handoff is generated (deprecated, empty for new results).
1178+ result.ErrorCode = residentV2ErrorBudgetExhausted
1176 }1179 }
1177 return writeResidentResult(dir, result)1180 return writeResidentResult(dir, result)
1178 }1181 }
M packages/agent/subagents/resident_journal_test.go +298 -0
@@ -1,7 +1,9 @@
11 package subagents
22
33 import (
4+ "context"
45 "encoding/json"
6+ "errors"
57 "os"
68 "path/filepath"
79 "runtime"
@@ -681,6 +683,302 @@ func TestReconcileResidentJournalRepairsLegacyFalseRecovery(t *testing.T) {
681683 }
682684 }
683685
686+func TestReconcileResidentJournalTranslatesV2BudgetHistory(t *testing.T) {
687+ for _, tc := range []struct {
688+ name string
689+ outcome string
690+ want ResidentState
691+ }{
692+ {name: "exhausted stop is failure", outcome: residentV2OutcomeBudgetExhausted, want: ResidentFailed},
693+ {name: "completed before check is success", outcome: residentV2OutcomeCompletedBudgetExhausted, want: ResidentCompleted},
694+ } {
695+ t.Run(tc.name, func(t *testing.T) {
696+ root := t.TempDir()
697+ journal, err := OpenResidentJournal(root, "v2-child")
698+ if err != nil {
699+ t.Fatal(err)
700+ }
701+ spec := ResidentChildSpec{ID: "v2-child", SessionID: "child-session", InitialTurnID: "turn-1", Provider: "openai", Model: "test", Required: true}
702+ if err := journal.Accept(spec, "task"); err != nil {
703+ t.Fatal(err)
704+ }
705+ // A historical recovery follow-up: the v2 host accepted it
706+ // with a budget baseline on the turn.accepted record, then
707+ // ran and finished it with the historical outcome.
708+ if err := journal.AcceptFollowUp(spec, "turn-2", "continue"); err != nil {
709+ t.Fatal(err)
710+ }
711+ if err := journal.RecordTurnStarted(spec, "turn-2"); err != nil {
712+ t.Fatal(err)
713+ }
714+ if err := journal.appendSync(residentRecord{Version: residentJournalVersion, Type: residentRecordTurnFinished, Time: time.Now().UTC(), TurnID: "turn-2", Outcome: tc.outcome}); err != nil {
715+ t.Fatal(err)
716+ }
717+ if err := journal.Close(); err != nil {
718+ t.Fatal(err)
719+ }
720+ downgradeJournalVersion(t, filepath.Join(root, spec.ID, residentTranscriptName))
721+ transcript, err := os.ReadFile(filepath.Join(root, spec.ID, residentTranscriptName))
722+ if err != nil {
723+ t.Fatal(err)
724+ }
725+ factory := func(ResidentChildSpec, *ResidentJournal) (ResidentTurnRunner, error) {
726+ return func(context.Context, string) error { return nil }, nil
727+ }
728+ manager := NewResidentManager(root, factory)
729+ t.Cleanup(func() { _ = manager.Close(context.Background()) })
730+ completions := make(chan ResidentCompletion, 1)
731+ manager.SetCompletionObserver(func(c ResidentCompletion) { completions <- c })
732+ if errs := manager.Reconcile(); len(errs) != 0 {
733+ t.Fatal(errs)
734+ }
735+ snapshot, ok := manager.SnapshotFor(spec.ID)
736+ if !ok || snapshot.State != tc.want {
737+ t.Fatalf("reconciled snapshot = %#v, want state %q", snapshot, tc.want)
738+ }
739+ wantUnmet := 0
740+ if tc.want == ResidentFailed {
741+ wantUnmet = 1
742+ }
743+ if unmet := manager.UnmetRequired(); len(unmet) != wantUnmet {
744+ t.Fatalf("unmet required = %#v, want %d", unmet, wantUnmet)
745+ }
746+ // Translation rebuilds projections but never rewrites the
747+ // authoritative transcript.
748+ after, err := os.ReadFile(filepath.Join(root, spec.ID, residentTranscriptName))
749+ if err != nil || string(after) != string(transcript) {
750+ t.Fatalf("reconciliation modified transcript: %v", err)
751+ }
752+ // An explicit follow-up resolves the history: success
753+ // satisfies required work for the exhausted case too.
754+ if err := manager.Resume(t.Context(), spec.ID, "finish it"); err != nil {
755+ t.Fatal(err)
756+ }
757+ select {
758+ case completion := <-completions:
759+ if completion.Err != nil {
760+ t.Fatal(completion.Err)
761+ }
762+ case <-time.After(5 * time.Second):
763+ t.Fatal("no terminal notification")
764+ }
765+ if unmet := manager.UnmetRequired(); len(unmet) != 0 {
766+ t.Fatalf("successful resume remains unmet: %#v", unmet)
767+ }
768+ // A second reconciliation replays nothing.
769+ if errs := manager.Reconcile(); len(errs) != 0 {
770+ t.Fatal(errs)
771+ }
772+ snapshot, ok = manager.SnapshotFor(spec.ID)
773+ if !ok || snapshot.State != ResidentIdle {
774+ t.Fatalf("post-resume snapshot = %#v, want idle", snapshot)
775+ }
776+ })
777+ }
778+}
779+
780+func TestReconcileResidentJournalRejectsUnknownTurnOutcome(t *testing.T) {
781+ root := t.TempDir()
782+ journal, err := OpenResidentJournal(root, "bogus-child")
783+ if err != nil {
784+ t.Fatal(err)
785+ }
786+ spec := ResidentChildSpec{ID: "bogus-child", SessionID: "child-session", InitialTurnID: "turn-1", Provider: "openai", Model: "test"}
787+ if err := journal.Accept(spec, "task"); err != nil {
788+ t.Fatal(err)
789+ }
790+ if err := journal.RecordTurnStarted(spec, spec.InitialTurnID); err != nil {
791+ t.Fatal(err)
792+ }
793+ if err := journal.appendSync(residentRecord{Version: residentJournalVersion, Type: residentRecordTurnFinished, Time: time.Now().UTC(), TurnID: spec.InitialTurnID, Outcome: "bogus"}); err != nil {
794+ t.Fatal(err)
795+ }
796+ if err := journal.Close(); err != nil {
797+ t.Fatal(err)
798+ }
799+ if _, err := ReconcileResidentJournal(filepath.Join(root, spec.ID)); err == nil {
800+ t.Fatal("unknown turn outcome reconciled without error")
801+ }
802+}
803+
804+func TestReadResidentResultTranslatesArchivedV2BudgetExhaustion(t *testing.T) {
805+ root := t.TempDir()
806+ journal, err := OpenResidentJournal(root, "v2-result")
807+ if err != nil {
808+ t.Fatal(err)
809+ }
810+ spec := ResidentChildSpec{ID: "v2-result", SessionID: "child-session", InitialTurnID: "turn-1", Provider: "openai", Model: "test"}
811+ if err := journal.Accept(spec, "task"); err != nil {
812+ t.Fatal(err)
813+ }
814+ if err := journal.RecordTurnStarted(spec, spec.InitialTurnID); err != nil {
815+ t.Fatal(err)
816+ }
817+ if err := journal.appendSync(residentRecord{Version: residentJournalVersion, Type: residentRecordTurnFinished, Time: time.Now().UTC(), TurnID: spec.InitialTurnID, Outcome: residentV2OutcomeBudgetExhausted}); err != nil {
818+ t.Fatal(err)
819+ }
820+ if err := journal.Close(); err != nil {
821+ t.Fatal(err)
822+ }
823+ downgradeJournalVersion(t, filepath.Join(root, spec.ID, residentTranscriptName))
824+ // An archived v2 result.json carries the historical state, handoff,
825+ // and error code. The reader translates the state to failure while
826+ // keeping the archived bytes readable as provenance.
827+ archived, err := json.Marshal(ResidentResult{Version: residentJournalVersionV2, ID: spec.ID, TurnID: spec.InitialTurnID, State: ResidentState(residentV2OutcomeBudgetExhausted), Summary: "partial", Handoff: "archived handoff", ErrorCode: residentV2ErrorBudgetExhausted})
828+ if err != nil {
829+ t.Fatal(err)
830+ }
831+ if err := os.WriteFile(filepath.Join(root, spec.ID, residentResultName), archived, 0o600); err != nil {
832+ t.Fatal(err)
833+ }
834+ result, err := ReadResidentResult(filepath.Join(root, spec.ID, residentResultName))
835+ if err != nil {
836+ t.Fatal(err)
837+ }
838+ if result.State != ResidentFailed || result.Handoff != "archived handoff" || result.ErrorCode != residentV2ErrorBudgetExhausted {
839+ t.Fatalf("translated result = %#v", result)
840+ }
841+}
842+
843+func TestReconcileResidentJournalRejectsUnknownVersion(t *testing.T) {
844+ root := t.TempDir()
845+ journal, err := OpenResidentJournal(root, "future-child")
846+ if err != nil {
847+ t.Fatal(err)
848+ }
849+ spec := ResidentChildSpec{ID: "future-child", SessionID: "child-session", Provider: "openai", Model: "test"}
850+ if err := journal.Accept(spec, "task"); err != nil {
851+ t.Fatal(err)
852+ }
853+ if err := journal.Close(); err != nil {
854+ t.Fatal(err)
855+ }
856+ transcript := filepath.Join(root, spec.ID, residentTranscriptName)
857+ data, err := os.ReadFile(transcript)
858+ if err != nil {
859+ t.Fatal(err)
860+ }
861+ future := strings.Replace(string(data), `"version":3`, `"version":99`, 1)
862+ if future == string(data) {
863+ t.Fatal("accepted record has no journal version")
864+ }
865+ if err := os.WriteFile(transcript, []byte(future), 0o600); err != nil {
866+ t.Fatal(err)
867+ }
868+ if _, err := ReconcileResidentJournal(filepath.Join(root, spec.ID)); err == nil {
869+ t.Fatal("unknown journal version reconciled without error")
870+ }
871+}
872+
873+func TestReconcileResidentJournalRejectsUnknownLaterRecordVersion(t *testing.T) {
874+ root := t.TempDir()
875+ journal, err := OpenResidentJournal(root, "tampered-child")
876+ if err != nil {
877+ t.Fatal(err)
878+ }
879+ spec := ResidentChildSpec{ID: "tampered-child", SessionID: "child-session", InitialTurnID: "turn-1", Provider: "openai", Model: "test"}
880+ if err := journal.Accept(spec, "task"); err != nil {
881+ t.Fatal(err)
882+ }
883+ if err := journal.RecordTurnStarted(spec, spec.InitialTurnID); err != nil {
884+ t.Fatal(err)
885+ }
886+ if err := journal.RecordTurnFinished(spec, spec.InitialTurnID, nil); err != nil {
887+ t.Fatal(err)
888+ }
889+ if err := journal.Close(); err != nil {
890+ t.Fatal(err)
891+ }
892+ // Tamper only a later record: the accepted prefix stays v3, so only
893+ // per-record validation can catch this corruption.
894+ transcript := filepath.Join(root, spec.ID, residentTranscriptName)
895+ data, err := os.ReadFile(transcript)
896+ if err != nil {
897+ t.Fatal(err)
898+ }
899+ lines := strings.Split(strings.TrimSuffix(string(data), "\n"), "\n")
900+ if len(lines) != 3 {
901+ t.Fatalf("transcript lines = %d, want 3", len(lines))
902+ }
903+ lines[2] = strings.Replace(lines[2], `"version":3`, `"version":99`, 1)
904+ if !strings.Contains(lines[2], `"version":99`) {
905+ t.Fatal("finished record has no journal version")
906+ }
907+ if err := os.WriteFile(transcript, []byte(strings.Join(lines, "\n")+"\n"), 0o600); err != nil {
908+ t.Fatal(err)
909+ }
910+ if _, err := ReconcileResidentJournal(filepath.Join(root, spec.ID)); !errors.Is(err, ErrIncompatibleResidentJournal) {
911+ t.Fatalf("tampered record reconciled: %v, want incompatible version", err)
912+ }
913+}
914+
915+func TestReconcileResidentJournalAcceptsMixedV2V3ResumedJournal(t *testing.T) {
916+ root := t.TempDir()
917+ journal, err := OpenResidentJournal(root, "mixed-child")
918+ if err != nil {
919+ t.Fatal(err)
920+ }
921+ spec := ResidentChildSpec{ID: "mixed-child", SessionID: "child-session", InitialTurnID: "turn-1", Provider: "openai", Model: "test"}
922+ if err := journal.Accept(spec, "task"); err != nil {
923+ t.Fatal(err)
924+ }
925+ if err := journal.RecordTurnStarted(spec, spec.InitialTurnID); err != nil {
926+ t.Fatal(err)
927+ }
928+ if err := journal.RecordTurnFinished(spec, spec.InitialTurnID, nil); err != nil {
929+ t.Fatal(err)
930+ }
931+ if err := journal.Close(); err != nil {
932+ t.Fatal(err)
933+ }
934+ // A resumed v2 journal keeps its v2 prefix on disk while the new host
935+ // appends v3 records. Downgrade only the accepted record to model it.
936+ transcript := filepath.Join(root, spec.ID, residentTranscriptName)
937+ data, err := os.ReadFile(transcript)
938+ if err != nil {
939+ t.Fatal(err)
940+ }
941+ mixed := strings.Replace(string(data), `"version":3`, `"version":2`, 1)
942+ if mixed == string(data) {
943+ t.Fatal("accepted record has no journal version")
944+ }
945+ if err := os.WriteFile(transcript, []byte(mixed), 0o600); err != nil {
946+ t.Fatal(err)
947+ }
948+ metadata, err := ReconcileResidentJournal(filepath.Join(root, spec.ID))
949+ if err != nil {
950+ t.Fatal(err)
951+ }
952+ if metadata.State != ResidentIdle {
953+ t.Fatalf("mixed-version state = %q, want idle", metadata.State)
954+ }
955+}
956+
957+// downgradeJournalVersion rewrites a freshly written v3 transcript into a
958+// faithful v2 archive by lowering every record version and restoring the
959+// historical budget bytes (baseline and spec fields) that v3 no longer
960+// writes. The v3 reader must ignore those bytes structurally.
961+func downgradeJournalVersion(t *testing.T, transcript string) {
962+ t.Helper()
963+ data, err := os.ReadFile(transcript)
964+ if err != nil {
965+ t.Fatal(err)
966+ }
967+ downgraded := strings.ReplaceAll(string(data), `"version":3`, `"version":2`)
968+ if downgraded == string(data) {
969+ t.Fatal("transcript has no journal version")
970+ }
971+ // Historical v2 bytes: a budget baseline on the follow-up turn.accepted
972+ // record (written by the v2 recovery acceptance) and budget fields on
973+ // the accepted spec. Neither exists in the v3 structs, so the reader
974+ // must drop them without error.
975+ downgraded = strings.Replace(downgraded, `"type":"turn.accepted"`, `"type":"turn.accepted","budget_baseline":100`, 1)
976+ downgraded = strings.Replace(downgraded, `"provider":"openai"`, `"provider":"openai","budget_limit":100,"budget_source":"model_context"`, 1)
977+ if err := os.WriteFile(transcript, []byte(downgraded), 0o600); err != nil {
978+ t.Fatal(err)
979+ }
980+}
981+
684982 func TestRepairLegacyFalseRecoveryRefusesAmbiguousBlock(t *testing.T) {
685983 synthetic := json.RawMessage(`{"Content":[{"text":"tool interrupted by resident host restart"}],"IsError":true}`)
686984 real := json.RawMessage(`{"Content":[{"text":"ok"}],"IsError":false}`)
@@ -1,7 +1,9 @@
1 package subagents1 package subagents
2 2
3 import (3 import (
4+ "context"
4 "encoding/json"5 "encoding/json"
6+ "errors"
5 "os"7 "os"
6 "path/filepath"8 "path/filepath"
7 "runtime"9 "runtime"
@@ -681,6 +683,302 @@ func TestReconcileResidentJournalRepairsLegacyFalseRecovery(t *testing.T) {
681 }683 }
682 }684 }
683 685
686+func TestReconcileResidentJournalTranslatesV2BudgetHistory(t *testing.T) {
687+ for _, tc := range []struct {
688+ name string
689+ outcome string
690+ want ResidentState
691+ }{
692+ {name: "exhausted stop is failure", outcome: residentV2OutcomeBudgetExhausted, want: ResidentFailed},
693+ {name: "completed before check is success", outcome: residentV2OutcomeCompletedBudgetExhausted, want: ResidentCompleted},
694+ } {
695+ t.Run(tc.name, func(t *testing.T) {
696+ root := t.TempDir()
697+ journal, err := OpenResidentJournal(root, "v2-child")
698+ if err != nil {
699+ t.Fatal(err)
700+ }
701+ spec := ResidentChildSpec{ID: "v2-child", SessionID: "child-session", InitialTurnID: "turn-1", Provider: "openai", Model: "test", Required: true}
702+ if err := journal.Accept(spec, "task"); err != nil {
703+ t.Fatal(err)
704+ }
705+ // A historical recovery follow-up: the v2 host accepted it
706+ // with a budget baseline on the turn.accepted record, then
707+ // ran and finished it with the historical outcome.
708+ if err := journal.AcceptFollowUp(spec, "turn-2", "continue"); err != nil {
709+ t.Fatal(err)
710+ }
711+ if err := journal.RecordTurnStarted(spec, "turn-2"); err != nil {
712+ t.Fatal(err)
713+ }
714+ if err := journal.appendSync(residentRecord{Version: residentJournalVersion, Type: residentRecordTurnFinished, Time: time.Now().UTC(), TurnID: "turn-2", Outcome: tc.outcome}); err != nil {
715+ t.Fatal(err)
716+ }
717+ if err := journal.Close(); err != nil {
718+ t.Fatal(err)
719+ }
720+ downgradeJournalVersion(t, filepath.Join(root, spec.ID, residentTranscriptName))
721+ transcript, err := os.ReadFile(filepath.Join(root, spec.ID, residentTranscriptName))
722+ if err != nil {
723+ t.Fatal(err)
724+ }
725+ factory := func(ResidentChildSpec, *ResidentJournal) (ResidentTurnRunner, error) {
726+ return func(context.Context, string) error { return nil }, nil
727+ }
728+ manager := NewResidentManager(root, factory)
729+ t.Cleanup(func() { _ = manager.Close(context.Background()) })
730+ completions := make(chan ResidentCompletion, 1)
731+ manager.SetCompletionObserver(func(c ResidentCompletion) { completions <- c })
732+ if errs := manager.Reconcile(); len(errs) != 0 {
733+ t.Fatal(errs)
734+ }
735+ snapshot, ok := manager.SnapshotFor(spec.ID)
736+ if !ok || snapshot.State != tc.want {
737+ t.Fatalf("reconciled snapshot = %#v, want state %q", snapshot, tc.want)
738+ }
739+ wantUnmet := 0
740+ if tc.want == ResidentFailed {
741+ wantUnmet = 1
742+ }
743+ if unmet := manager.UnmetRequired(); len(unmet) != wantUnmet {
744+ t.Fatalf("unmet required = %#v, want %d", unmet, wantUnmet)
745+ }
746+ // Translation rebuilds projections but never rewrites the
747+ // authoritative transcript.
748+ after, err := os.ReadFile(filepath.Join(root, spec.ID, residentTranscriptName))
749+ if err != nil || string(after) != string(transcript) {
750+ t.Fatalf("reconciliation modified transcript: %v", err)
751+ }
752+ // An explicit follow-up resolves the history: success
753+ // satisfies required work for the exhausted case too.
754+ if err := manager.Resume(t.Context(), spec.ID, "finish it"); err != nil {
755+ t.Fatal(err)
756+ }
757+ select {
758+ case completion := <-completions:
759+ if completion.Err != nil {
760+ t.Fatal(completion.Err)
761+ }
762+ case <-time.After(5 * time.Second):
763+ t.Fatal("no terminal notification")
764+ }
765+ if unmet := manager.UnmetRequired(); len(unmet) != 0 {
766+ t.Fatalf("successful resume remains unmet: %#v", unmet)
767+ }
768+ // A second reconciliation replays nothing.
769+ if errs := manager.Reconcile(); len(errs) != 0 {
770+ t.Fatal(errs)
771+ }
772+ snapshot, ok = manager.SnapshotFor(spec.ID)
773+ if !ok || snapshot.State != ResidentIdle {
774+ t.Fatalf("post-resume snapshot = %#v, want idle", snapshot)
775+ }
776+ })
777+ }
778+}
779+
780+func TestReconcileResidentJournalRejectsUnknownTurnOutcome(t *testing.T) {
781+ root := t.TempDir()
782+ journal, err := OpenResidentJournal(root, "bogus-child")
783+ if err != nil {
784+ t.Fatal(err)
785+ }
786+ spec := ResidentChildSpec{ID: "bogus-child", SessionID: "child-session", InitialTurnID: "turn-1", Provider: "openai", Model: "test"}
787+ if err := journal.Accept(spec, "task"); err != nil {
788+ t.Fatal(err)
789+ }
790+ if err := journal.RecordTurnStarted(spec, spec.InitialTurnID); err != nil {
791+ t.Fatal(err)
792+ }
793+ if err := journal.appendSync(residentRecord{Version: residentJournalVersion, Type: residentRecordTurnFinished, Time: time.Now().UTC(), TurnID: spec.InitialTurnID, Outcome: "bogus"}); err != nil {
794+ t.Fatal(err)
795+ }
796+ if err := journal.Close(); err != nil {
797+ t.Fatal(err)
798+ }
799+ if _, err := ReconcileResidentJournal(filepath.Join(root, spec.ID)); err == nil {
800+ t.Fatal("unknown turn outcome reconciled without error")
801+ }
802+}
803+
804+func TestReadResidentResultTranslatesArchivedV2BudgetExhaustion(t *testing.T) {
805+ root := t.TempDir()
806+ journal, err := OpenResidentJournal(root, "v2-result")
807+ if err != nil {
808+ t.Fatal(err)
809+ }
810+ spec := ResidentChildSpec{ID: "v2-result", SessionID: "child-session", InitialTurnID: "turn-1", Provider: "openai", Model: "test"}
811+ if err := journal.Accept(spec, "task"); err != nil {
812+ t.Fatal(err)
813+ }
814+ if err := journal.RecordTurnStarted(spec, spec.InitialTurnID); err != nil {
815+ t.Fatal(err)
816+ }
817+ if err := journal.appendSync(residentRecord{Version: residentJournalVersion, Type: residentRecordTurnFinished, Time: time.Now().UTC(), TurnID: spec.InitialTurnID, Outcome: residentV2OutcomeBudgetExhausted}); err != nil {
818+ t.Fatal(err)
819+ }
820+ if err := journal.Close(); err != nil {
821+ t.Fatal(err)
822+ }
823+ downgradeJournalVersion(t, filepath.Join(root, spec.ID, residentTranscriptName))
824+ // An archived v2 result.json carries the historical state, handoff,
825+ // and error code. The reader translates the state to failure while
826+ // keeping the archived bytes readable as provenance.
827+ archived, err := json.Marshal(ResidentResult{Version: residentJournalVersionV2, ID: spec.ID, TurnID: spec.InitialTurnID, State: ResidentState(residentV2OutcomeBudgetExhausted), Summary: "partial", Handoff: "archived handoff", ErrorCode: residentV2ErrorBudgetExhausted})
828+ if err != nil {
829+ t.Fatal(err)
830+ }
831+ if err := os.WriteFile(filepath.Join(root, spec.ID, residentResultName), archived, 0o600); err != nil {
832+ t.Fatal(err)
833+ }
834+ result, err := ReadResidentResult(filepath.Join(root, spec.ID, residentResultName))
835+ if err != nil {
836+ t.Fatal(err)
837+ }
838+ if result.State != ResidentFailed || result.Handoff != "archived handoff" || result.ErrorCode != residentV2ErrorBudgetExhausted {
839+ t.Fatalf("translated result = %#v", result)
840+ }
841+}
842+
843+func TestReconcileResidentJournalRejectsUnknownVersion(t *testing.T) {
844+ root := t.TempDir()
845+ journal, err := OpenResidentJournal(root, "future-child")
846+ if err != nil {
847+ t.Fatal(err)
848+ }
849+ spec := ResidentChildSpec{ID: "future-child", SessionID: "child-session", Provider: "openai", Model: "test"}
850+ if err := journal.Accept(spec, "task"); err != nil {
851+ t.Fatal(err)
852+ }
853+ if err := journal.Close(); err != nil {
854+ t.Fatal(err)
855+ }
856+ transcript := filepath.Join(root, spec.ID, residentTranscriptName)
857+ data, err := os.ReadFile(transcript)
858+ if err != nil {
859+ t.Fatal(err)
860+ }
861+ future := strings.Replace(string(data), `"version":3`, `"version":99`, 1)
862+ if future == string(data) {
863+ t.Fatal("accepted record has no journal version")
864+ }
865+ if err := os.WriteFile(transcript, []byte(future), 0o600); err != nil {
866+ t.Fatal(err)
867+ }
868+ if _, err := ReconcileResidentJournal(filepath.Join(root, spec.ID)); err == nil {
869+ t.Fatal("unknown journal version reconciled without error")
870+ }
871+}
872+
873+func TestReconcileResidentJournalRejectsUnknownLaterRecordVersion(t *testing.T) {
874+ root := t.TempDir()
875+ journal, err := OpenResidentJournal(root, "tampered-child")
876+ if err != nil {
877+ t.Fatal(err)
878+ }
879+ spec := ResidentChildSpec{ID: "tampered-child", SessionID: "child-session", InitialTurnID: "turn-1", Provider: "openai", Model: "test"}
880+ if err := journal.Accept(spec, "task"); err != nil {
881+ t.Fatal(err)
882+ }
883+ if err := journal.RecordTurnStarted(spec, spec.InitialTurnID); err != nil {
884+ t.Fatal(err)
885+ }
886+ if err := journal.RecordTurnFinished(spec, spec.InitialTurnID, nil); err != nil {
887+ t.Fatal(err)
888+ }
889+ if err := journal.Close(); err != nil {
890+ t.Fatal(err)
891+ }
892+ // Tamper only a later record: the accepted prefix stays v3, so only
893+ // per-record validation can catch this corruption.
894+ transcript := filepath.Join(root, spec.ID, residentTranscriptName)
895+ data, err := os.ReadFile(transcript)
896+ if err != nil {
897+ t.Fatal(err)
898+ }
899+ lines := strings.Split(strings.TrimSuffix(string(data), "\n"), "\n")
900+ if len(lines) != 3 {
901+ t.Fatalf("transcript lines = %d, want 3", len(lines))
902+ }
903+ lines[2] = strings.Replace(lines[2], `"version":3`, `"version":99`, 1)
904+ if !strings.Contains(lines[2], `"version":99`) {
905+ t.Fatal("finished record has no journal version")
906+ }
907+ if err := os.WriteFile(transcript, []byte(strings.Join(lines, "\n")+"\n"), 0o600); err != nil {
908+ t.Fatal(err)
909+ }
910+ if _, err := ReconcileResidentJournal(filepath.Join(root, spec.ID)); !errors.Is(err, ErrIncompatibleResidentJournal) {
911+ t.Fatalf("tampered record reconciled: %v, want incompatible version", err)
912+ }
913+}
914+
915+func TestReconcileResidentJournalAcceptsMixedV2V3ResumedJournal(t *testing.T) {
916+ root := t.TempDir()
917+ journal, err := OpenResidentJournal(root, "mixed-child")
918+ if err != nil {
919+ t.Fatal(err)
920+ }
921+ spec := ResidentChildSpec{ID: "mixed-child", SessionID: "child-session", InitialTurnID: "turn-1", Provider: "openai", Model: "test"}
922+ if err := journal.Accept(spec, "task"); err != nil {
923+ t.Fatal(err)
924+ }
925+ if err := journal.RecordTurnStarted(spec, spec.InitialTurnID); err != nil {
926+ t.Fatal(err)
927+ }
928+ if err := journal.RecordTurnFinished(spec, spec.InitialTurnID, nil); err != nil {
929+ t.Fatal(err)
930+ }
931+ if err := journal.Close(); err != nil {
932+ t.Fatal(err)
933+ }
934+ // A resumed v2 journal keeps its v2 prefix on disk while the new host
935+ // appends v3 records. Downgrade only the accepted record to model it.
936+ transcript := filepath.Join(root, spec.ID, residentTranscriptName)
937+ data, err := os.ReadFile(transcript)
938+ if err != nil {
939+ t.Fatal(err)
940+ }
941+ mixed := strings.Replace(string(data), `"version":3`, `"version":2`, 1)
942+ if mixed == string(data) {
943+ t.Fatal("accepted record has no journal version")
944+ }
945+ if err := os.WriteFile(transcript, []byte(mixed), 0o600); err != nil {
946+ t.Fatal(err)
947+ }
948+ metadata, err := ReconcileResidentJournal(filepath.Join(root, spec.ID))
949+ if err != nil {
950+ t.Fatal(err)
951+ }
952+ if metadata.State != ResidentIdle {
953+ t.Fatalf("mixed-version state = %q, want idle", metadata.State)
954+ }
955+}
956+
957+// downgradeJournalVersion rewrites a freshly written v3 transcript into a
958+// faithful v2 archive by lowering every record version and restoring the
959+// historical budget bytes (baseline and spec fields) that v3 no longer
960+// writes. The v3 reader must ignore those bytes structurally.
961+func downgradeJournalVersion(t *testing.T, transcript string) {
962+ t.Helper()
963+ data, err := os.ReadFile(transcript)
964+ if err != nil {
965+ t.Fatal(err)
966+ }
967+ downgraded := strings.ReplaceAll(string(data), `"version":3`, `"version":2`)
968+ if downgraded == string(data) {
969+ t.Fatal("transcript has no journal version")
970+ }
971+ // Historical v2 bytes: a budget baseline on the follow-up turn.accepted
972+ // record (written by the v2 recovery acceptance) and budget fields on
973+ // the accepted spec. Neither exists in the v3 structs, so the reader
974+ // must drop them without error.
975+ downgraded = strings.Replace(downgraded, `"type":"turn.accepted"`, `"type":"turn.accepted","budget_baseline":100`, 1)
976+ downgraded = strings.Replace(downgraded, `"provider":"openai"`, `"provider":"openai","budget_limit":100,"budget_source":"model_context"`, 1)
977+ if err := os.WriteFile(transcript, []byte(downgraded), 0o600); err != nil {
978+ t.Fatal(err)
979+ }
980+}
981+
684 func TestRepairLegacyFalseRecoveryRefusesAmbiguousBlock(t *testing.T) {982 func TestRepairLegacyFalseRecoveryRefusesAmbiguousBlock(t *testing.T) {
685 synthetic := json.RawMessage(`{"Content":[{"text":"tool interrupted by resident host restart"}],"IsError":true}`)983 synthetic := json.RawMessage(`{"Content":[{"text":"tool interrupted by resident host restart"}],"IsError":true}`)
686 real := json.RawMessage(`{"Content":[{"text":"ok"}],"IsError":false}`)984 real := json.RawMessage(`{"Content":[{"text":"ok"}],"IsError":false}`)
M packages/agent/subagents/resident_manager.go +25 -19
@@ -221,8 +221,6 @@ type ResidentSnapshot struct {
221221 ContextUsed int
222222 ContextMax int
223223 Subscription bool
224- Budget BudgetSnapshot
225- BudgetSource string
226224 }
227225
228226 func residentSnapshot(child *ResidentChild) ResidentSnapshot {
@@ -230,18 +228,12 @@ func residentSnapshot(child *ResidentChild) ResidentSnapshot {
230228 return ResidentSnapshot{}
231229 }
232230 live := child.Live()
233- budget, budgetSource := residentBudgetSnapshot(live.Usage, child.spec.BudgetLimit, child.spec.BudgetSource, child.journal.BudgetBaseline())
234231 return ResidentSnapshot{ID: child.spec.ID, State: child.State(), Profile: child.spec.Profile,
235232 Provider: child.spec.Provider, Model: child.spec.Model,
236233 WorkspaceMode: child.spec.WorkspaceMode, Required: child.spec.Required,
237234 UpdatedAt: child.StateUpdatedAt(), TurnStartedAt: child.TurnStartedAt(),
238235 ActivityUpdatedAt: child.ActivityUpdatedAt(), WaitingForModel: live.WaitingForModel,
239- Usage: live.Usage, ContextUsed: live.ContextUsed, ContextMax: live.ContextMax, Subscription: live.Subscription,
240- Budget: budget, BudgetSource: budgetSource}
241-}
242-
243-func residentBudgetSnapshot(usage provider.Usage, limit int64, source string, baseline int64) (BudgetSnapshot, string) {
244- return budgetSnapshotSince(usage, limit, baseline), source
236+ Usage: live.Usage, ContextUsed: live.ContextUsed, ContextMax: live.ContextMax, Subscription: live.Subscription}
245237 }
246238
247239 func NewResidentManager(root string, factory ResidentFactory) *ResidentManager {
@@ -478,9 +470,6 @@ func (m *ResidentManager) Resume(ctx context.Context, childID, prompt string) er
478470 m.pending[childID] = struct{}{}
479471 }
480472 m.mu.Unlock()
481- if child != nil && residentSnapshot(child).Budget.State == BudgetExceeded && (child.State() == ResidentRunning || child.State() == ResidentQueued) {
482- return errors.New("resident manager: wait for the budget-exhausted turn's terminal notification before resuming")
483- }
484473 if child == nil && recovered {
485474 defer func() { m.mu.Lock(); delete(m.pending, childID); m.mu.Unlock() }()
486475 journal, err := OpenResidentJournal(m.root, childID)
@@ -809,7 +798,7 @@ func (m *ResidentManager) SnapshotFor(childID string) (ResidentSnapshot, bool) {
809798
810799 // Reconcile discovers journals left by an earlier host, repairs their bounded
811800 // projections, and marks queued/running work interrupted. It never creates a
812-// live child or replays a prompt. Incompatible budget journals are left untouched
801+// live child or replays a prompt. Incompatible journals are left untouched
813802 // and excluded from discovery. Other per-child errors are returned for callers
814803 // to surface without hiding valid sibling journals.
815804 func (m *ResidentManager) Reconcile() []error {
@@ -851,10 +840,11 @@ func (m *ResidentManager) Reconcile() []error {
851840 }
852841 childDir := filepath.Join(m.root, childID)
853842 metadata, spec, reconcileErr := reconcileResidentJournalWithSpec(childDir)
854- // Old budget journals cannot be resumed. Leave them on disk without
855- // reporting the same incompatibility at every host startup. Match the
856- // exact sentinel so a joined journal-close error is still reported.
857- if reconcileErr == ErrIncompatibleResidentBudget {
843+ // Unknown journal versions cannot be resumed. Leave them on disk
844+ // without reporting the same incompatibility at every host startup.
845+ // Match the exact sentinel so a joined journal-close error is still
846+ // reported.
847+ if reconcileErr == ErrIncompatibleResidentJournal {
858848 continue
859849 }
860850 if errors.Is(reconcileErr, ErrResidentLeaseBusy) {
@@ -863,12 +853,30 @@ func (m *ResidentManager) Reconcile() []error {
863853 errs = append(errs, fmt.Errorf("resident child %s: read foreign metadata: %w", childID, readErr))
864854 continue
865855 }
856+ if readErr == nil {
857+ if foreign.Version != residentJournalVersion && foreign.Version != residentJournalVersionV2 {
858+ errs = append(errs, fmt.Errorf("resident child %s: %w", childID, ErrIncompatibleResidentJournal))
859+ continue
860+ }
861+ // A v3 projection can never carry the removed budget
862+ // state: the writer only persists live states. Treat it
863+ // as corruption rather than resurfacing the zombie state.
864+ if foreign.Version != residentJournalVersionV2 && foreign.State == ResidentState(residentV2OutcomeBudgetExhausted) {
865+ errs = append(errs, fmt.Errorf("resident child %s: invalid terminal state", childID))
866+ continue
867+ }
868+ }
866869 state, updatedAt := foreign.State, foreign.UpdatedAt
867870 if readErr != nil || state == "" {
868871 // Ownership begins before child.accepted is durable. Do not read
869872 // that live transcript merely to improve this transient snapshot.
870873 state, updatedAt = ResidentQueued, time.Now().UTC()
871874 }
875+ if foreign.Version == residentJournalVersionV2 && state == ResidentState(residentV2OutcomeBudgetExhausted) {
876+ // Historical budget stop from an older host: display it as
877+ // failed, never as a distinct terminal state.
878+ state = ResidentFailed
879+ }
872880 m.mu.Lock()
873881 m.recovered[childID] = ResidentSnapshot{
874882 ID: childID, State: state, UpdatedAt: updatedAt, OwnedElsewhere: true,
@@ -881,14 +889,12 @@ func (m *ResidentManager) Reconcile() []error {
881889 errs = append(errs, fmt.Errorf("resident child %s: %w", childID, reconcileErr))
882890 continue
883891 }
884- budget, budgetSource := residentBudgetSnapshot(metadata.Usage, spec.BudgetLimit, spec.BudgetSource, metadata.BudgetBaseline)
885892 m.mu.Lock()
886893 if _, live := m.children[childID]; !live {
887894 m.recovered[childID] = ResidentSnapshot{
888895 ID: childID, State: metadata.State, Profile: spec.Profile, Provider: spec.Provider, Model: spec.Model,
889896 WorkspaceMode: spec.WorkspaceMode, Required: spec.Required, UpdatedAt: metadata.UpdatedAt,
890897 Usage: metadata.Usage, ContextUsed: metadata.ContextUsed, ContextMax: metadata.ContextMax, Subscription: metadata.Subscription,
891- Budget: budget, BudgetSource: budgetSource,
892898 }
893899 m.recoveredSpec[childID] = spec
894900 }
@@ -221,8 +221,6 @@ type ResidentSnapshot struct {
221 ContextUsed int221 ContextUsed int
222 ContextMax int222 ContextMax int
223 Subscription bool223 Subscription bool
224- Budget BudgetSnapshot
225- BudgetSource string
226 }224 }
227 225
228 func residentSnapshot(child *ResidentChild) ResidentSnapshot {226 func residentSnapshot(child *ResidentChild) ResidentSnapshot {
@@ -230,18 +228,12 @@ func residentSnapshot(child *ResidentChild) ResidentSnapshot {
230 return ResidentSnapshot{}228 return ResidentSnapshot{}
231 }229 }
232 live := child.Live()230 live := child.Live()
233- budget, budgetSource := residentBudgetSnapshot(live.Usage, child.spec.BudgetLimit, child.spec.BudgetSource, child.journal.BudgetBaseline())
234 return ResidentSnapshot{ID: child.spec.ID, State: child.State(), Profile: child.spec.Profile,231 return ResidentSnapshot{ID: child.spec.ID, State: child.State(), Profile: child.spec.Profile,
235 Provider: child.spec.Provider, Model: child.spec.Model,232 Provider: child.spec.Provider, Model: child.spec.Model,
236 WorkspaceMode: child.spec.WorkspaceMode, Required: child.spec.Required,233 WorkspaceMode: child.spec.WorkspaceMode, Required: child.spec.Required,
237 UpdatedAt: child.StateUpdatedAt(), TurnStartedAt: child.TurnStartedAt(),234 UpdatedAt: child.StateUpdatedAt(), TurnStartedAt: child.TurnStartedAt(),
238 ActivityUpdatedAt: child.ActivityUpdatedAt(), WaitingForModel: live.WaitingForModel,235 ActivityUpdatedAt: child.ActivityUpdatedAt(), WaitingForModel: live.WaitingForModel,
239- Usage: live.Usage, ContextUsed: live.ContextUsed, ContextMax: live.ContextMax, Subscription: live.Subscription,236+ Usage: live.Usage, ContextUsed: live.ContextUsed, ContextMax: live.ContextMax, Subscription: live.Subscription}
240- Budget: budget, BudgetSource: budgetSource}
241-}
242-
243-func residentBudgetSnapshot(usage provider.Usage, limit int64, source string, baseline int64) (BudgetSnapshot, string) {
244- return budgetSnapshotSince(usage, limit, baseline), source
245 }237 }
246 238
247 func NewResidentManager(root string, factory ResidentFactory) *ResidentManager {239 func NewResidentManager(root string, factory ResidentFactory) *ResidentManager {
@@ -478,9 +470,6 @@ func (m *ResidentManager) Resume(ctx context.Context, childID, prompt string) er
478 m.pending[childID] = struct{}{}470 m.pending[childID] = struct{}{}
479 }471 }
480 m.mu.Unlock()472 m.mu.Unlock()
481- if child != nil && residentSnapshot(child).Budget.State == BudgetExceeded && (child.State() == ResidentRunning || child.State() == ResidentQueued) {
482- return errors.New("resident manager: wait for the budget-exhausted turn's terminal notification before resuming")
483- }
484 if child == nil && recovered {473 if child == nil && recovered {
485 defer func() { m.mu.Lock(); delete(m.pending, childID); m.mu.Unlock() }()474 defer func() { m.mu.Lock(); delete(m.pending, childID); m.mu.Unlock() }()
486 journal, err := OpenResidentJournal(m.root, childID)475 journal, err := OpenResidentJournal(m.root, childID)
@@ -809,7 +798,7 @@ func (m *ResidentManager) SnapshotFor(childID string) (ResidentSnapshot, bool) {
809 798
810 // Reconcile discovers journals left by an earlier host, repairs their bounded799 // Reconcile discovers journals left by an earlier host, repairs their bounded
811 // projections, and marks queued/running work interrupted. It never creates a800 // projections, and marks queued/running work interrupted. It never creates a
812-// live child or replays a prompt. Incompatible budget journals are left untouched801+// live child or replays a prompt. Incompatible journals are left untouched
813 // and excluded from discovery. Other per-child errors are returned for callers802 // and excluded from discovery. Other per-child errors are returned for callers
814 // to surface without hiding valid sibling journals.803 // to surface without hiding valid sibling journals.
815 func (m *ResidentManager) Reconcile() []error {804 func (m *ResidentManager) Reconcile() []error {
@@ -851,10 +840,11 @@ func (m *ResidentManager) Reconcile() []error {
851 }840 }
852 childDir := filepath.Join(m.root, childID)841 childDir := filepath.Join(m.root, childID)
853 metadata, spec, reconcileErr := reconcileResidentJournalWithSpec(childDir)842 metadata, spec, reconcileErr := reconcileResidentJournalWithSpec(childDir)
854- // Old budget journals cannot be resumed. Leave them on disk without843+ // Unknown journal versions cannot be resumed. Leave them on disk
855- // reporting the same incompatibility at every host startup. Match the844+ // without reporting the same incompatibility at every host startup.
856- // exact sentinel so a joined journal-close error is still reported.845+ // Match the exact sentinel so a joined journal-close error is still
857- if reconcileErr == ErrIncompatibleResidentBudget {846+ // reported.
847+ if reconcileErr == ErrIncompatibleResidentJournal {
858 continue848 continue
859 }849 }
860 if errors.Is(reconcileErr, ErrResidentLeaseBusy) {850 if errors.Is(reconcileErr, ErrResidentLeaseBusy) {
@@ -863,12 +853,30 @@ func (m *ResidentManager) Reconcile() []error {
863 errs = append(errs, fmt.Errorf("resident child %s: read foreign metadata: %w", childID, readErr))853 errs = append(errs, fmt.Errorf("resident child %s: read foreign metadata: %w", childID, readErr))
864 continue854 continue
865 }855 }
856+ if readErr == nil {
857+ if foreign.Version != residentJournalVersion && foreign.Version != residentJournalVersionV2 {
858+ errs = append(errs, fmt.Errorf("resident child %s: %w", childID, ErrIncompatibleResidentJournal))
859+ continue
860+ }
861+ // A v3 projection can never carry the removed budget
862+ // state: the writer only persists live states. Treat it
863+ // as corruption rather than resurfacing the zombie state.
864+ if foreign.Version != residentJournalVersionV2 && foreign.State == ResidentState(residentV2OutcomeBudgetExhausted) {
865+ errs = append(errs, fmt.Errorf("resident child %s: invalid terminal state", childID))
866+ continue
867+ }
868+ }
866 state, updatedAt := foreign.State, foreign.UpdatedAt869 state, updatedAt := foreign.State, foreign.UpdatedAt
867 if readErr != nil || state == "" {870 if readErr != nil || state == "" {
868 // Ownership begins before child.accepted is durable. Do not read871 // Ownership begins before child.accepted is durable. Do not read
869 // that live transcript merely to improve this transient snapshot.872 // that live transcript merely to improve this transient snapshot.
870 state, updatedAt = ResidentQueued, time.Now().UTC()873 state, updatedAt = ResidentQueued, time.Now().UTC()
871 }874 }
875+ if foreign.Version == residentJournalVersionV2 && state == ResidentState(residentV2OutcomeBudgetExhausted) {
876+ // Historical budget stop from an older host: display it as
877+ // failed, never as a distinct terminal state.
878+ state = ResidentFailed
879+ }
872 m.mu.Lock()880 m.mu.Lock()
873 m.recovered[childID] = ResidentSnapshot{881 m.recovered[childID] = ResidentSnapshot{
874 ID: childID, State: state, UpdatedAt: updatedAt, OwnedElsewhere: true,882 ID: childID, State: state, UpdatedAt: updatedAt, OwnedElsewhere: true,
@@ -881,14 +889,12 @@ func (m *ResidentManager) Reconcile() []error {
881 errs = append(errs, fmt.Errorf("resident child %s: %w", childID, reconcileErr))889 errs = append(errs, fmt.Errorf("resident child %s: %w", childID, reconcileErr))
882 continue890 continue
883 }891 }
884- budget, budgetSource := residentBudgetSnapshot(metadata.Usage, spec.BudgetLimit, spec.BudgetSource, metadata.BudgetBaseline)
885 m.mu.Lock()892 m.mu.Lock()
886 if _, live := m.children[childID]; !live {893 if _, live := m.children[childID]; !live {
887 m.recovered[childID] = ResidentSnapshot{894 m.recovered[childID] = ResidentSnapshot{
888 ID: childID, State: metadata.State, Profile: spec.Profile, Provider: spec.Provider, Model: spec.Model,895 ID: childID, State: metadata.State, Profile: spec.Profile, Provider: spec.Provider, Model: spec.Model,
889 WorkspaceMode: spec.WorkspaceMode, Required: spec.Required, UpdatedAt: metadata.UpdatedAt,896 WorkspaceMode: spec.WorkspaceMode, Required: spec.Required, UpdatedAt: metadata.UpdatedAt,
890 Usage: metadata.Usage, ContextUsed: metadata.ContextUsed, ContextMax: metadata.ContextMax, Subscription: metadata.Subscription,897 Usage: metadata.Usage, ContextUsed: metadata.ContextUsed, ContextMax: metadata.ContextMax, Subscription: metadata.Subscription,
891- Budget: budget, BudgetSource: budgetSource,
892 }898 }
893 m.recoveredSpec[childID] = spec899 m.recoveredSpec[childID] = spec
894 }900 }
M packages/agent/subagents/resident_manager_test.go +52 -10
@@ -55,14 +55,14 @@ func TestResidentManagerSpawnsJournaledInProcessChild(t *testing.T) {
5555 }
5656 }
5757
58-func TestResidentManagerCompletedAnswerAtBudgetLimitCanResume(t *testing.T) {
58+func TestResidentManagerCompletedAnswerCanResume(t *testing.T) {
5959 manager := NewResidentManager(t.TempDir(), func(_ ResidentChildSpec, journal *ResidentJournal) (ResidentTurnRunner, error) {
6060 return func(context.Context, string) error {
6161 return journal.RecordAgentEvent(core.EvUsage{Cumulative: provider.Usage{InputTokens: 100}})
6262 }, nil
6363 })
6464 t.Cleanup(func() { _ = manager.Close(context.Background()) })
65- spec := ResidentChildSpec{ID: "budget-exhausted", SessionID: "budget-session", InitialTurnID: "turn-1", Provider: "openai", Model: "gpt-5", BudgetLimit: 100}
65+ spec := ResidentChildSpec{ID: "completed-child", SessionID: "completed-session", InitialTurnID: "turn-1", Provider: "openai", Model: "gpt-5"}
6666 completion, cancelWait := manager.WatchCompletion(spec.ID, spec.InitialTurnID)
6767 defer cancelWait()
6868 if _, err := manager.Spawn(context.Background(), spec, "review"); err != nil {
@@ -73,7 +73,7 @@ func TestResidentManagerCompletedAnswerAtBudgetLimitCanResume(t *testing.T) {
7373 t.Fatalf("initial completion error = %v", result.Err)
7474 }
7575 snapshot, ok := manager.SnapshotFor(spec.ID)
76- if !ok || snapshot.State != ResidentCompleted || snapshot.Budget.State != BudgetExceeded {
76+ if !ok || snapshot.State != ResidentIdle {
7777 t.Fatalf("terminal snapshot = %#v, found=%t", snapshot, ok)
7878 }
7979 if err := manager.Resume(context.Background(), spec.ID, "continue"); err != nil {
@@ -472,6 +472,46 @@ func TestResidentManagerCapturesSuccessfulWorktreeBeforeCleanup(t *testing.T) {
472472 }
473473 }
474474
475+func TestResidentManagerCapturesFailedWorktreePatch(t *testing.T) {
476+ root := t.TempDir()
477+ workspace := &testResidentWorkspace{dir: "/isolated", mode: WorkspaceWorktree, capture: WorkspaceCapture{Patch: []byte("partial patch"), ChangedFiles: []string{"changed.go"}}}
478+ manager := NewResidentManagerWithWorkspace(root, 0, func(context.Context, WorkspaceRequest) (WorkspaceHandle, error) {
479+ return workspace, nil
480+ }, func(ResidentChildSpec, *ResidentJournal) (ResidentTurnRunner, error) {
481+ return func(context.Context, string) error { return errors.New("turn failed") }, nil
482+ })
483+ defer manager.Close(context.Background())
484+ if _, err := manager.Spawn(context.Background(), ResidentChildSpec{ID: "failed-capture", SessionID: "child-session", Provider: "openai", Model: "gpt-5", RepositoryRoot: "/repo", WorkspaceMode: WorkspaceWorktree}, "task"); err != nil {
485+ t.Fatal(err)
486+ }
487+ deadline := time.Now().Add(time.Second)
488+ for manager.Snapshot()[0].State == ResidentRunning || manager.Snapshot()[0].State == ResidentQueued {
489+ if time.Now().After(deadline) {
490+ t.Fatal("turn did not finish")
491+ }
492+ time.Sleep(time.Millisecond)
493+ }
494+ // A non-canceled failure still captures partial work, but the turn
495+ // stays failed: capture never converts failure to success.
496+ if !workspace.captured {
497+ t.Fatal("failed worktree was not captured")
498+ }
499+ snapshot := manager.Snapshot()[0]
500+ if snapshot.State != ResidentFailed {
501+ t.Fatalf("snapshot state = %q, want failed", snapshot.State)
502+ }
503+ result, err := manager.Result("failed-capture")
504+ if err != nil {
505+ t.Fatal(err)
506+ }
507+ if result.PatchRef != PatchRef("failed-capture") || len(result.ChangedFiles) != 1 || result.ChangedFiles[0] != "changed.go" {
508+ t.Fatalf("result = %#v", result)
509+ }
510+ if result.Handoff != "" {
511+ t.Fatalf("new failure result carries handoff %q, want empty", result.Handoff)
512+ }
513+}
514+
475515 func TestResidentManagerRetainsFailedWorktree(t *testing.T) {
476516 workspace := &testResidentWorkspace{dir: "/isolated", mode: WorkspaceWorktree}
477517 manager := NewResidentManagerWithWorkspace(t.TempDir(), 0, func(context.Context, WorkspaceRequest) (WorkspaceHandle, error) { return workspace, nil }, func(ResidentChildSpec, *ResidentJournal) (ResidentTurnRunner, error) {
@@ -488,7 +528,9 @@ func TestResidentManagerRetainsFailedWorktree(t *testing.T) {
488528 }
489529 time.Sleep(time.Millisecond)
490530 }
491- if workspace.captured || workspace.cleaned {
531+ // Non-canceled failures capture partial work for recovery, but the
532+ // worktree is retained (never cleaned) until an explicit stop or close.
533+ if !workspace.captured || workspace.cleaned {
492534 t.Fatalf("failed worktree captured=%t cleaned=%t", workspace.captured, workspace.cleaned)
493535 }
494536 }
@@ -584,13 +626,13 @@ func TestResidentManagerRejectsDuplicateBeforeSecondJournalAcceptance(t *testing
584626 }
585627 }
586628
587-func TestResidentManagerReconcileSkipsIncompatibleBudgetJournal(t *testing.T) {
629+func TestResidentManagerReconcileSkipsIncompatibleJournal(t *testing.T) {
588630 root := t.TempDir()
589- journal, err := OpenResidentJournal(root, "legacy-budget")
631+ journal, err := OpenResidentJournal(root, "legacy-version")
590632 if err != nil {
591633 t.Fatal(err)
592634 }
593- spec := ResidentChildSpec{ID: "legacy-budget", SessionID: "child-session", Provider: "openai", Model: "gpt-5"}
635+ spec := ResidentChildSpec{ID: "legacy-version", SessionID: "child-session", Provider: "openai", Model: "gpt-5"}
594636 if err := journal.Accept(spec, "review"); err != nil {
595637 t.Fatal(err)
596638 }
@@ -602,7 +644,7 @@ func TestResidentManagerReconcileSkipsIncompatibleBudgetJournal(t *testing.T) {
602644 if err != nil {
603645 t.Fatal(err)
604646 }
605- legacy := strings.Replace(string(data), `"version":2`, `"version":1`, 1)
647+ legacy := strings.Replace(string(data), `"version":3`, `"version":1`, 1)
606648 if legacy == string(data) {
607649 t.Fatal("accepted record has no journal version")
608650 }
@@ -616,8 +658,8 @@ func TestResidentManagerReconcileSkipsIncompatibleBudgetJournal(t *testing.T) {
616658 t.Fatalf("Reconcile errors = %v", errs)
617659 }
618660 }
619- if _, err := ReconcileResidentJournal(filepath.Dir(transcript)); !errors.Is(err, ErrIncompatibleResidentBudget) {
620- t.Fatalf("explicit reconciliation error = %v, want incompatible budget", err)
661+ if _, err := ReconcileResidentJournal(filepath.Dir(transcript)); !errors.Is(err, ErrIncompatibleResidentJournal) {
662+ t.Fatalf("explicit reconciliation error = %v, want incompatible version", err)
621663 }
622664 if snapshots := manager.Snapshot(); len(snapshots) != 0 {
623665 t.Fatalf("snapshots = %#v", snapshots)
@@ -55,14 +55,14 @@ func TestResidentManagerSpawnsJournaledInProcessChild(t *testing.T) {
55 }55 }
56 }56 }
57 57
58-func TestResidentManagerCompletedAnswerAtBudgetLimitCanResume(t *testing.T) {58+func TestResidentManagerCompletedAnswerCanResume(t *testing.T) {
59 manager := NewResidentManager(t.TempDir(), func(_ ResidentChildSpec, journal *ResidentJournal) (ResidentTurnRunner, error) {59 manager := NewResidentManager(t.TempDir(), func(_ ResidentChildSpec, journal *ResidentJournal) (ResidentTurnRunner, error) {
60 return func(context.Context, string) error {60 return func(context.Context, string) error {
61 return journal.RecordAgentEvent(core.EvUsage{Cumulative: provider.Usage{InputTokens: 100}})61 return journal.RecordAgentEvent(core.EvUsage{Cumulative: provider.Usage{InputTokens: 100}})
62 }, nil62 }, nil
63 })63 })
64 t.Cleanup(func() { _ = manager.Close(context.Background()) })64 t.Cleanup(func() { _ = manager.Close(context.Background()) })
65- spec := ResidentChildSpec{ID: "budget-exhausted", SessionID: "budget-session", InitialTurnID: "turn-1", Provider: "openai", Model: "gpt-5", BudgetLimit: 100}65+ spec := ResidentChildSpec{ID: "completed-child", SessionID: "completed-session", InitialTurnID: "turn-1", Provider: "openai", Model: "gpt-5"}
66 completion, cancelWait := manager.WatchCompletion(spec.ID, spec.InitialTurnID)66 completion, cancelWait := manager.WatchCompletion(spec.ID, spec.InitialTurnID)
67 defer cancelWait()67 defer cancelWait()
68 if _, err := manager.Spawn(context.Background(), spec, "review"); err != nil {68 if _, err := manager.Spawn(context.Background(), spec, "review"); err != nil {
@@ -73,7 +73,7 @@ func TestResidentManagerCompletedAnswerAtBudgetLimitCanResume(t *testing.T) {
73 t.Fatalf("initial completion error = %v", result.Err)73 t.Fatalf("initial completion error = %v", result.Err)
74 }74 }
75 snapshot, ok := manager.SnapshotFor(spec.ID)75 snapshot, ok := manager.SnapshotFor(spec.ID)
76- if !ok || snapshot.State != ResidentCompleted || snapshot.Budget.State != BudgetExceeded {76+ if !ok || snapshot.State != ResidentIdle {
77 t.Fatalf("terminal snapshot = %#v, found=%t", snapshot, ok)77 t.Fatalf("terminal snapshot = %#v, found=%t", snapshot, ok)
78 }78 }
79 if err := manager.Resume(context.Background(), spec.ID, "continue"); err != nil {79 if err := manager.Resume(context.Background(), spec.ID, "continue"); err != nil {
@@ -472,6 +472,46 @@ func TestResidentManagerCapturesSuccessfulWorktreeBeforeCleanup(t *testing.T) {
472 }472 }
473 }473 }
474 474
475+func TestResidentManagerCapturesFailedWorktreePatch(t *testing.T) {
476+ root := t.TempDir()
477+ workspace := &testResidentWorkspace{dir: "/isolated", mode: WorkspaceWorktree, capture: WorkspaceCapture{Patch: []byte("partial patch"), ChangedFiles: []string{"changed.go"}}}
478+ manager := NewResidentManagerWithWorkspace(root, 0, func(context.Context, WorkspaceRequest) (WorkspaceHandle, error) {
479+ return workspace, nil
480+ }, func(ResidentChildSpec, *ResidentJournal) (ResidentTurnRunner, error) {
481+ return func(context.Context, string) error { return errors.New("turn failed") }, nil
482+ })
483+ defer manager.Close(context.Background())
484+ if _, err := manager.Spawn(context.Background(), ResidentChildSpec{ID: "failed-capture", SessionID: "child-session", Provider: "openai", Model: "gpt-5", RepositoryRoot: "/repo", WorkspaceMode: WorkspaceWorktree}, "task"); err != nil {
485+ t.Fatal(err)
486+ }
487+ deadline := time.Now().Add(time.Second)
488+ for manager.Snapshot()[0].State == ResidentRunning || manager.Snapshot()[0].State == ResidentQueued {
489+ if time.Now().After(deadline) {
490+ t.Fatal("turn did not finish")
491+ }
492+ time.Sleep(time.Millisecond)
493+ }
494+ // A non-canceled failure still captures partial work, but the turn
495+ // stays failed: capture never converts failure to success.
496+ if !workspace.captured {
497+ t.Fatal("failed worktree was not captured")
498+ }
499+ snapshot := manager.Snapshot()[0]
500+ if snapshot.State != ResidentFailed {
501+ t.Fatalf("snapshot state = %q, want failed", snapshot.State)
502+ }
503+ result, err := manager.Result("failed-capture")
504+ if err != nil {
505+ t.Fatal(err)
506+ }
507+ if result.PatchRef != PatchRef("failed-capture") || len(result.ChangedFiles) != 1 || result.ChangedFiles[0] != "changed.go" {
508+ t.Fatalf("result = %#v", result)
509+ }
510+ if result.Handoff != "" {
511+ t.Fatalf("new failure result carries handoff %q, want empty", result.Handoff)
512+ }
513+}
514+
475 func TestResidentManagerRetainsFailedWorktree(t *testing.T) {515 func TestResidentManagerRetainsFailedWorktree(t *testing.T) {
476 workspace := &testResidentWorkspace{dir: "/isolated", mode: WorkspaceWorktree}516 workspace := &testResidentWorkspace{dir: "/isolated", mode: WorkspaceWorktree}
477 manager := NewResidentManagerWithWorkspace(t.TempDir(), 0, func(context.Context, WorkspaceRequest) (WorkspaceHandle, error) { return workspace, nil }, func(ResidentChildSpec, *ResidentJournal) (ResidentTurnRunner, error) {517 manager := NewResidentManagerWithWorkspace(t.TempDir(), 0, func(context.Context, WorkspaceRequest) (WorkspaceHandle, error) { return workspace, nil }, func(ResidentChildSpec, *ResidentJournal) (ResidentTurnRunner, error) {
@@ -488,7 +528,9 @@ func TestResidentManagerRetainsFailedWorktree(t *testing.T) {
488 }528 }
489 time.Sleep(time.Millisecond)529 time.Sleep(time.Millisecond)
490 }530 }
491- if workspace.captured || workspace.cleaned {531+ // Non-canceled failures capture partial work for recovery, but the
532+ // worktree is retained (never cleaned) until an explicit stop or close.
533+ if !workspace.captured || workspace.cleaned {
492 t.Fatalf("failed worktree captured=%t cleaned=%t", workspace.captured, workspace.cleaned)534 t.Fatalf("failed worktree captured=%t cleaned=%t", workspace.captured, workspace.cleaned)
493 }535 }
494 }536 }
@@ -584,13 +626,13 @@ func TestResidentManagerRejectsDuplicateBeforeSecondJournalAcceptance(t *testing
584 }626 }
585 }627 }
586 628
587-func TestResidentManagerReconcileSkipsIncompatibleBudgetJournal(t *testing.T) {629+func TestResidentManagerReconcileSkipsIncompatibleJournal(t *testing.T) {
588 root := t.TempDir()630 root := t.TempDir()
589- journal, err := OpenResidentJournal(root, "legacy-budget")631+ journal, err := OpenResidentJournal(root, "legacy-version")
590 if err != nil {632 if err != nil {
591 t.Fatal(err)633 t.Fatal(err)
592 }634 }
593- spec := ResidentChildSpec{ID: "legacy-budget", SessionID: "child-session", Provider: "openai", Model: "gpt-5"}635+ spec := ResidentChildSpec{ID: "legacy-version", SessionID: "child-session", Provider: "openai", Model: "gpt-5"}
594 if err := journal.Accept(spec, "review"); err != nil {636 if err := journal.Accept(spec, "review"); err != nil {
595 t.Fatal(err)637 t.Fatal(err)
596 }638 }
@@ -602,7 +644,7 @@ func TestResidentManagerReconcileSkipsIncompatibleBudgetJournal(t *testing.T) {
602 if err != nil {644 if err != nil {
603 t.Fatal(err)645 t.Fatal(err)
604 }646 }
605- legacy := strings.Replace(string(data), `"version":2`, `"version":1`, 1)647+ legacy := strings.Replace(string(data), `"version":3`, `"version":1`, 1)
606 if legacy == string(data) {648 if legacy == string(data) {
607 t.Fatal("accepted record has no journal version")649 t.Fatal("accepted record has no journal version")
608 }650 }
@@ -616,8 +658,8 @@ func TestResidentManagerReconcileSkipsIncompatibleBudgetJournal(t *testing.T) {
616 t.Fatalf("Reconcile errors = %v", errs)658 t.Fatalf("Reconcile errors = %v", errs)
617 }659 }
618 }660 }
619- if _, err := ReconcileResidentJournal(filepath.Dir(transcript)); !errors.Is(err, ErrIncompatibleResidentBudget) {661+ if _, err := ReconcileResidentJournal(filepath.Dir(transcript)); !errors.Is(err, ErrIncompatibleResidentJournal) {
620- t.Fatalf("explicit reconciliation error = %v, want incompatible budget", err)662+ t.Fatalf("explicit reconciliation error = %v, want incompatible version", err)
621 }663 }
622 if snapshots := manager.Snapshot(); len(snapshots) != 0 {664 if snapshots := manager.Snapshot(); len(snapshots) != 0 {
623 t.Fatalf("snapshots = %#v", snapshots)665 t.Fatalf("snapshots = %#v", snapshots)
M packages/agent/tools/resident_tools_test.go +18 -14
@@ -14,17 +14,18 @@ import (
1414 "github.com/bnema/zut/packages/provider"
1515 )
1616
17-func TestResidentStatusRetrievesBudgetHandoffWithoutExecution(t *testing.T) {
17+func TestResidentStatusRetrievesFailedResultWithoutExecution(t *testing.T) {
18+ failure := errors.New("worker failed")
1819 manager := subagents.NewResidentManager(t.TempDir(), func(_ subagents.ResidentChildSpec, journal *subagents.ResidentJournal) (subagents.ResidentTurnRunner, error) {
1920 return func(context.Context, string) error {
2021 if err := journal.RecordAgentEvent(core.EvAssistantMessage{Message: provider.Message{Role: provider.RoleAssistant, Content: []provider.Content{provider.TextBlock{Text: "partial finding"}}}}); err != nil {
2122 return err
2223 }
23- return subagents.ErrBudgetExceeded
24+ return failure
2425 }, nil
2526 })
2627 t.Cleanup(func() { _ = manager.Close(context.Background()) })
27- spec := subagents.ResidentChildSpec{ID: "budget-status", InitialTurnID: "initial", SessionID: "session", Provider: "openai", Model: "test", Required: true}
28+ spec := subagents.ResidentChildSpec{ID: "failed-status", InitialTurnID: "initial", SessionID: "session", Provider: "openai", Model: "test", Required: true}
2829 completed, cancel := manager.WatchCompletion(spec.ID, spec.InitialTurnID)
2930 defer cancel()
3031 if _, err := manager.Spawn(t.Context(), spec, "investigate"); err != nil {
@@ -37,19 +38,19 @@ func TestResidentStatusRetrievesBudgetHandoffWithoutExecution(t *testing.T) {
3738 }
3839 status := &SubagentStatusTool{ResidentManager: manager, Enabled: func() bool { return true }}
3940 for _, include := range []bool{false, true} {
40- raw := json.RawMessage(`{"agent_id":"budget-status"}`)
41+ raw := json.RawMessage(`{"agent_id":"failed-status"}`)
4142 if include {
42- raw = json.RawMessage(`{"agent_id":"budget-status","include_result":true}`)
43+ raw = json.RawMessage(`{"agent_id":"failed-status","include_result":true}`)
4344 }
4445 result, err := status.Execute(t.Context(), raw, nil)
4546 if err != nil || result.IsError {
4647 t.Fatalf("status: %#v, %v", result, err)
4748 }
4849 response := result.Details.(subagentStatusResponse)
49- if response.Agent.State != subagents.ResidentBudgetExhausted || (response.Result != nil) != include {
50+ if response.Agent.State != subagents.ResidentFailed || (response.Result != nil) != include {
5051 t.Fatalf("response = %#v", response)
5152 }
52- if include && (!strings.Contains(response.Result.Handoff, "partial finding") || response.Result.ErrorCode != "budget_exhausted") {
53+ if include && (response.Result.Handoff != "" || response.Result.ErrorCode != "turn_failed" || response.Result.Summary != "partial finding") {
5354 t.Fatalf("result = %#v", response.Result)
5455 }
5556 }
@@ -58,7 +59,7 @@ func TestResidentStatusRetrievesBudgetHandoffWithoutExecution(t *testing.T) {
5859 t.Fatalf("missing child ID must fail: %#v, %v", result, err)
5960 }
6061 if len(manager.UnmetRequired()) != 1 {
61- t.Fatal("reading a handoff satisfied required work")
62+ t.Fatal("reading a result satisfied required work")
6263 }
6364 }
6465
@@ -135,9 +136,8 @@ func TestResidentSpawnWaitReturnsInitialCompletion(t *testing.T) {
135136 }{
136137 {"success", nil, "completed"},
137138 {"failure", errors.New("worker failed"), "failed"},
138- {"budget", subagents.ErrBudgetExceeded, "budget_exhausted"},
139139 {"cancellation", context.Canceled, "interrupted"},
140- {"cancellation and budget", errors.Join(context.Canceled, subagents.ErrBudgetExceeded), "interrupted"},
140+ {"cancellation with failure", errors.Join(context.Canceled, errors.New("worker failed")), "interrupted"},
141141 } {
142142 t.Run(tc.name, func(t *testing.T) {
143143 manager := subagents.NewResidentManager(t.TempDir(), func(subagents.ResidentChildSpec, *subagents.ResidentJournal) (subagents.ResidentTurnRunner, error) {
@@ -326,12 +326,16 @@ func toolResultText(t *testing.T, result core.ToolResult) string {
326326 return block.Text
327327 }
328328
329-func TestPublicResidentStatusIncludesRolloutBudget(t *testing.T) {
329+func TestPublicResidentStatusOmitsBudget(t *testing.T) {
330330 entry := publicResidentStatus(subagents.ResidentSnapshot{
331- ID: "budgeted", Budget: subagents.BudgetSnapshot{Used: 70, Limit: 100, Percent: 70, State: subagents.BudgetNormal}, BudgetSource: "model_context",
331+ ID: "resident", State: subagents.ResidentIdle, Provider: "openai", Model: "test",
332332 })
333- if entry.Budget == nil || entry.Budget.State != subagents.BudgetNormal || entry.BudgetSource != "model_context" {
334- t.Fatalf("status budget = %#v", entry)
333+ encoded, err := json.Marshal(entry)
334+ if err != nil {
335+ t.Fatal(err)
336+ }
337+ if strings.Contains(string(encoded), "budget") {
338+ t.Fatalf("status entry carries budget = %s", encoded)
335339 }
336340 }
337341
@@ -14,17 +14,18 @@ import (
14 "github.com/bnema/zut/packages/provider"14 "github.com/bnema/zut/packages/provider"
15 )15 )
16 16
17-func TestResidentStatusRetrievesBudgetHandoffWithoutExecution(t *testing.T) {17+func TestResidentStatusRetrievesFailedResultWithoutExecution(t *testing.T) {
18+ failure := errors.New("worker failed")
18 manager := subagents.NewResidentManager(t.TempDir(), func(_ subagents.ResidentChildSpec, journal *subagents.ResidentJournal) (subagents.ResidentTurnRunner, error) {19 manager := subagents.NewResidentManager(t.TempDir(), func(_ subagents.ResidentChildSpec, journal *subagents.ResidentJournal) (subagents.ResidentTurnRunner, error) {
19 return func(context.Context, string) error {20 return func(context.Context, string) error {
20 if err := journal.RecordAgentEvent(core.EvAssistantMessage{Message: provider.Message{Role: provider.RoleAssistant, Content: []provider.Content{provider.TextBlock{Text: "partial finding"}}}}); err != nil {21 if err := journal.RecordAgentEvent(core.EvAssistantMessage{Message: provider.Message{Role: provider.RoleAssistant, Content: []provider.Content{provider.TextBlock{Text: "partial finding"}}}}); err != nil {
21 return err22 return err
22 }23 }
23- return subagents.ErrBudgetExceeded24+ return failure
24 }, nil25 }, nil
25 })26 })
26 t.Cleanup(func() { _ = manager.Close(context.Background()) })27 t.Cleanup(func() { _ = manager.Close(context.Background()) })
27- spec := subagents.ResidentChildSpec{ID: "budget-status", InitialTurnID: "initial", SessionID: "session", Provider: "openai", Model: "test", Required: true}28+ spec := subagents.ResidentChildSpec{ID: "failed-status", InitialTurnID: "initial", SessionID: "session", Provider: "openai", Model: "test", Required: true}
28 completed, cancel := manager.WatchCompletion(spec.ID, spec.InitialTurnID)29 completed, cancel := manager.WatchCompletion(spec.ID, spec.InitialTurnID)
29 defer cancel()30 defer cancel()
30 if _, err := manager.Spawn(t.Context(), spec, "investigate"); err != nil {31 if _, err := manager.Spawn(t.Context(), spec, "investigate"); err != nil {
@@ -37,19 +38,19 @@ func TestResidentStatusRetrievesBudgetHandoffWithoutExecution(t *testing.T) {
37 }38 }
38 status := &SubagentStatusTool{ResidentManager: manager, Enabled: func() bool { return true }}39 status := &SubagentStatusTool{ResidentManager: manager, Enabled: func() bool { return true }}
39 for _, include := range []bool{false, true} {40 for _, include := range []bool{false, true} {
40- raw := json.RawMessage(`{"agent_id":"budget-status"}`)41+ raw := json.RawMessage(`{"agent_id":"failed-status"}`)
41 if include {42 if include {
42- raw = json.RawMessage(`{"agent_id":"budget-status","include_result":true}`)43+ raw = json.RawMessage(`{"agent_id":"failed-status","include_result":true}`)
43 }44 }
44 result, err := status.Execute(t.Context(), raw, nil)45 result, err := status.Execute(t.Context(), raw, nil)
45 if err != nil || result.IsError {46 if err != nil || result.IsError {
46 t.Fatalf("status: %#v, %v", result, err)47 t.Fatalf("status: %#v, %v", result, err)
47 }48 }
48 response := result.Details.(subagentStatusResponse)49 response := result.Details.(subagentStatusResponse)
49- if response.Agent.State != subagents.ResidentBudgetExhausted || (response.Result != nil) != include {50+ if response.Agent.State != subagents.ResidentFailed || (response.Result != nil) != include {
50 t.Fatalf("response = %#v", response)51 t.Fatalf("response = %#v", response)
51 }52 }
52- if include && (!strings.Contains(response.Result.Handoff, "partial finding") || response.Result.ErrorCode != "budget_exhausted") {53+ if include && (response.Result.Handoff != "" || response.Result.ErrorCode != "turn_failed" || response.Result.Summary != "partial finding") {
53 t.Fatalf("result = %#v", response.Result)54 t.Fatalf("result = %#v", response.Result)
54 }55 }
55 }56 }
@@ -58,7 +59,7 @@ func TestResidentStatusRetrievesBudgetHandoffWithoutExecution(t *testing.T) {
58 t.Fatalf("missing child ID must fail: %#v, %v", result, err)59 t.Fatalf("missing child ID must fail: %#v, %v", result, err)
59 }60 }
60 if len(manager.UnmetRequired()) != 1 {61 if len(manager.UnmetRequired()) != 1 {
61- t.Fatal("reading a handoff satisfied required work")62+ t.Fatal("reading a result satisfied required work")
62 }63 }
63 }64 }
64 65
@@ -135,9 +136,8 @@ func TestResidentSpawnWaitReturnsInitialCompletion(t *testing.T) {
135 }{136 }{
136 {"success", nil, "completed"},137 {"success", nil, "completed"},
137 {"failure", errors.New("worker failed"), "failed"},138 {"failure", errors.New("worker failed"), "failed"},
138- {"budget", subagents.ErrBudgetExceeded, "budget_exhausted"},
139 {"cancellation", context.Canceled, "interrupted"},139 {"cancellation", context.Canceled, "interrupted"},
140- {"cancellation and budget", errors.Join(context.Canceled, subagents.ErrBudgetExceeded), "interrupted"},140+ {"cancellation with failure", errors.Join(context.Canceled, errors.New("worker failed")), "interrupted"},
141 } {141 } {
142 t.Run(tc.name, func(t *testing.T) {142 t.Run(tc.name, func(t *testing.T) {
143 manager := subagents.NewResidentManager(t.TempDir(), func(subagents.ResidentChildSpec, *subagents.ResidentJournal) (subagents.ResidentTurnRunner, error) {143 manager := subagents.NewResidentManager(t.TempDir(), func(subagents.ResidentChildSpec, *subagents.ResidentJournal) (subagents.ResidentTurnRunner, error) {
@@ -326,12 +326,16 @@ func toolResultText(t *testing.T, result core.ToolResult) string {
326 return block.Text326 return block.Text
327 }327 }
328 328
329-func TestPublicResidentStatusIncludesRolloutBudget(t *testing.T) {329+func TestPublicResidentStatusOmitsBudget(t *testing.T) {
330 entry := publicResidentStatus(subagents.ResidentSnapshot{330 entry := publicResidentStatus(subagents.ResidentSnapshot{
331- ID: "budgeted", Budget: subagents.BudgetSnapshot{Used: 70, Limit: 100, Percent: 70, State: subagents.BudgetNormal}, BudgetSource: "model_context",331+ ID: "resident", State: subagents.ResidentIdle, Provider: "openai", Model: "test",
332 })332 })
333- if entry.Budget == nil || entry.Budget.State != subagents.BudgetNormal || entry.BudgetSource != "model_context" {333+ encoded, err := json.Marshal(entry)
334- t.Fatalf("status budget = %#v", entry)334+ if err != nil {
335+ t.Fatal(err)
336+ }
337+ if strings.Contains(string(encoded), "budget") {
338+ t.Fatalf("status entry carries budget = %s", encoded)
335 }339 }
336 }340 }
337 341
M packages/agent/tools/subagent_resume.go +1 -1
@@ -41,7 +41,7 @@ const subagentResumeSchema = `{
4141 func (t *SubagentResumeTool) Name() string { return SubagentResumeToolName }
4242
4343 func (t *SubagentResumeTool) Description() string {
44- return "Continue a resident sub-agent with a follow-up prompt and retained session. After terminal budget exhaustion, inspect its saved handoff first; explicit resume grants a fresh same-size allowance without discarding progress or satisfying required work."
44+ return "Continue a resident sub-agent with a follow-up prompt and retained session. After a terminal failure, inspect its saved result first; explicit resume continues the retained session without discarding progress or satisfying required work."
4545 }
4646
4747 func (t *SubagentResumeTool) Schema() json.RawMessage {
@@ -41,7 +41,7 @@ const subagentResumeSchema = `{
41 func (t *SubagentResumeTool) Name() string { return SubagentResumeToolName }41 func (t *SubagentResumeTool) Name() string { return SubagentResumeToolName }
42 42
43 func (t *SubagentResumeTool) Description() string {43 func (t *SubagentResumeTool) Description() string {
44- return "Continue a resident sub-agent with a follow-up prompt and retained session. After terminal budget exhaustion, inspect its saved handoff first; explicit resume grants a fresh same-size allowance without discarding progress or satisfying required work."44+ return "Continue a resident sub-agent with a follow-up prompt and retained session. After a terminal failure, inspect its saved result first; explicit resume continues the retained session without discarding progress or satisfying required work."
45 }45 }
46 46
47 func (t *SubagentResumeTool) Schema() json.RawMessage {47 func (t *SubagentResumeTool) Schema() json.RawMessage {
M packages/agent/tools/subagent_spawn.go +0 -3
@@ -285,9 +285,6 @@ func (t *SubagentSpawnTool) Execute(ctx context.Context, raw json.RawMessage, _
285285 if spec.Reasoning != "" {
286286 fmt.Fprintf(&sb, "reasoning: %s\n", spec.Reasoning)
287287 }
288- if spec.BudgetLimit > 0 {
289- fmt.Fprintf(&sb, "budget: %d weighted tokens (%s)\n", spec.BudgetLimit, spec.BudgetSource)
290- }
291288 if spec.Required {
292289 fmt.Fprintf(&sb, "required: %s\n", state)
293290 }
@@ -285,9 +285,6 @@ func (t *SubagentSpawnTool) Execute(ctx context.Context, raw json.RawMessage, _
285 if spec.Reasoning != "" {285 if spec.Reasoning != "" {
286 fmt.Fprintf(&sb, "reasoning: %s\n", spec.Reasoning)286 fmt.Fprintf(&sb, "reasoning: %s\n", spec.Reasoning)
287 }287 }
288- if spec.BudgetLimit > 0 {
289- fmt.Fprintf(&sb, "budget: %d weighted tokens (%s)\n", spec.BudgetLimit, spec.BudgetSource)
290- }
291 if spec.Required {288 if spec.Required {
292 fmt.Fprintf(&sb, "required: %s\n", state)289 fmt.Fprintf(&sb, "required: %s\n", state)
293 }290 }
M packages/agent/tools/subagent_status.go +11 -18
@@ -34,16 +34,14 @@ type subagentStatusResponse struct {
3434 }
3535
3636 type subagentStatusEntry struct {
37- ID string `json:"agent_id"`
38- State subagents.ResidentState `json:"state"`
39- Profile string `json:"profile,omitempty"`
40- Provider string `json:"provider"`
41- Model string `json:"model"`
42- Workspace subagents.WorkspaceMode `json:"workspace_mode,omitempty"`
43- Required bool `json:"required,omitempty"`
44- OwnedElsewhere bool `json:"owned_elsewhere,omitempty"`
45- Budget *subagents.BudgetSnapshot `json:"budget,omitempty"`
46- BudgetSource string `json:"budget_source,omitempty"`
37+ ID string `json:"agent_id"`
38+ State subagents.ResidentState `json:"state"`
39+ Profile string `json:"profile,omitempty"`
40+ Provider string `json:"provider"`
41+ Model string `json:"model"`
42+ Workspace subagents.WorkspaceMode `json:"workspace_mode,omitempty"`
43+ Required bool `json:"required,omitempty"`
44+ OwnedElsewhere bool `json:"owned_elsewhere,omitempty"`
4745 }
4846
4947 const subagentStatusSchema = `{
@@ -55,7 +53,7 @@ const subagentStatusSchema = `{
5553 },
5654 "include_result": {
5755 "type": "boolean",
58- "description": "With agent_id, retrieve the saved terminal result or partial budget handoff without running the child."
56+ "description": "With agent_id, retrieve the saved terminal result without running the child."
5957 }
6058 }
6159 }`
@@ -63,7 +61,7 @@ const subagentStatusSchema = `{
6361 func (t *SubagentStatusTool) Name() string { return SubagentStatusToolName }
6462
6563 func (t *SubagentStatusTool) Description() string {
66- return "Query live status for one background sub-agent or list all visible workers without waiting for completion. Set include_result with agent_id to retrieve its saved result or partial handoff without model execution."
64+ return "Query live status for one background sub-agent or list all visible workers without waiting for completion. Set include_result with agent_id to retrieve its saved result without model execution."
6765 }
6866
6967 func (t *SubagentStatusTool) Schema() json.RawMessage {
@@ -147,12 +145,7 @@ func findResidentStatusSnapshot(snapshots []subagents.ResidentSnapshot, id strin
147145 }
148146
149147 func publicResidentStatus(snapshot subagents.ResidentSnapshot) subagentStatusEntry {
150- entry := subagentStatusEntry{ID: snapshot.ID, State: snapshot.State, Profile: snapshot.Profile, Provider: snapshot.Provider, Model: snapshot.Model, Workspace: snapshot.WorkspaceMode, Required: snapshot.Required, OwnedElsewhere: snapshot.OwnedElsewhere, BudgetSource: snapshot.BudgetSource}
151- if snapshot.Budget.Limit > 0 {
152- budget := snapshot.Budget
153- entry.Budget = &budget
154- }
155- return entry
148+ return subagentStatusEntry{ID: snapshot.ID, State: snapshot.State, Profile: snapshot.Profile, Provider: snapshot.Provider, Model: snapshot.Model, Workspace: snapshot.WorkspaceMode, Required: snapshot.Required, OwnedElsewhere: snapshot.OwnedElsewhere}
156149 }
157150
158151 func renderSubagentStatus(response subagentStatusResponse) (core.ToolResult, error) {
@@ -34,16 +34,14 @@ type subagentStatusResponse struct {
34 }34 }
35 35
36 type subagentStatusEntry struct {36 type subagentStatusEntry struct {
37- ID string `json:"agent_id"`37+ ID string `json:"agent_id"`
38- State subagents.ResidentState `json:"state"`38+ State subagents.ResidentState `json:"state"`
39- Profile string `json:"profile,omitempty"`39+ Profile string `json:"profile,omitempty"`
40- Provider string `json:"provider"`40+ Provider string `json:"provider"`
41- Model string `json:"model"`41+ Model string `json:"model"`
42- Workspace subagents.WorkspaceMode `json:"workspace_mode,omitempty"`42+ Workspace subagents.WorkspaceMode `json:"workspace_mode,omitempty"`
43- Required bool `json:"required,omitempty"`43+ Required bool `json:"required,omitempty"`
44- OwnedElsewhere bool `json:"owned_elsewhere,omitempty"`44+ OwnedElsewhere bool `json:"owned_elsewhere,omitempty"`
45- Budget *subagents.BudgetSnapshot `json:"budget,omitempty"`
46- BudgetSource string `json:"budget_source,omitempty"`
47 }45 }
48 46
49 const subagentStatusSchema = `{47 const subagentStatusSchema = `{
@@ -55,7 +53,7 @@ const subagentStatusSchema = `{
55 },53 },
56 "include_result": {54 "include_result": {
57 "type": "boolean",55 "type": "boolean",
58- "description": "With agent_id, retrieve the saved terminal result or partial budget handoff without running the child."56+ "description": "With agent_id, retrieve the saved terminal result without running the child."
59 }57 }
60 }58 }
61 }`59 }`
@@ -63,7 +61,7 @@ const subagentStatusSchema = `{
63 func (t *SubagentStatusTool) Name() string { return SubagentStatusToolName }61 func (t *SubagentStatusTool) Name() string { return SubagentStatusToolName }
64 62
65 func (t *SubagentStatusTool) Description() string {63 func (t *SubagentStatusTool) Description() string {
66- return "Query live status for one background sub-agent or list all visible workers without waiting for completion. Set include_result with agent_id to retrieve its saved result or partial handoff without model execution."64+ return "Query live status for one background sub-agent or list all visible workers without waiting for completion. Set include_result with agent_id to retrieve its saved result without model execution."
67 }65 }
68 66
69 func (t *SubagentStatusTool) Schema() json.RawMessage {67 func (t *SubagentStatusTool) Schema() json.RawMessage {
@@ -147,12 +145,7 @@ func findResidentStatusSnapshot(snapshots []subagents.ResidentSnapshot, id strin
147 }145 }
148 146
149 func publicResidentStatus(snapshot subagents.ResidentSnapshot) subagentStatusEntry {147 func publicResidentStatus(snapshot subagents.ResidentSnapshot) subagentStatusEntry {
150- entry := subagentStatusEntry{ID: snapshot.ID, State: snapshot.State, Profile: snapshot.Profile, Provider: snapshot.Provider, Model: snapshot.Model, Workspace: snapshot.WorkspaceMode, Required: snapshot.Required, OwnedElsewhere: snapshot.OwnedElsewhere, BudgetSource: snapshot.BudgetSource}148+ return subagentStatusEntry{ID: snapshot.ID, State: snapshot.State, Profile: snapshot.Profile, Provider: snapshot.Provider, Model: snapshot.Model, Workspace: snapshot.WorkspaceMode, Required: snapshot.Required, OwnedElsewhere: snapshot.OwnedElsewhere}
151- if snapshot.Budget.Limit > 0 {
152- budget := snapshot.Budget
153- entry.Budget = &budget
154- }
155- return entry
156 }149 }
157 150
158 func renderSubagentStatus(response subagentStatusResponse) (core.ToolResult, error) {151 func renderSubagentStatus(response subagentStatusResponse) (core.ToolResult, error) {

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 1

Tags 476

↑↓ moveenter openesc close