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 Unverified
B5690EEEBB952194The 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 | |||
| 442 | 442 | ||
| 443 | 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 | 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 | 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 | 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 | 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 | 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 | 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 | 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`. | |
| @@ -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>/ | |||
| 56 | 56 | owner.lock # host-ownership coordination; do not modify or remove | |
| 57 | 57 | .transcript-backup-* # retained private pre-repair transcript, when repaired | |
| 58 | 58 | metadata.json # rebuildable bounded state | |
| 59 | - | result.json # latest outcome, final summary or partial handoff, artifacts | |
| 59 | + | result.json # latest outcome, final summary, artifacts | |
| 60 | 60 | 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 | 89 | unlimited by default; a positive `subagents.queue_timeout` cancels an accepted | |
| 90 | 90 | prompt that has not received a slot and records its terminal failure durably. | |
| 91 | 91 | `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. | |
| 120 | 127 | ||
| 121 | 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 | 134 | 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. | |
| 134 | 139 | ||
| 135 | 140 | `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 | |
| 138 | 143 | accepting a recovery prompt does not satisfy the obligation. A host restart never | |
| 139 | 144 | retries it automatically. | |
| 140 | 145 | ||
| @@ -175,14 +180,14 @@ The model-facing tools retain their logical names: | |||
| 175 | 180 | returns a logical `subagent://<id>` reference. | |
| 176 | 181 | - `subagent_status` returns bounded current state for one child or the current | |
| 177 | 182 | 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 | |
| 179 | 184 | option it remains metadata-only; foreign-owned results cannot be read. | |
| 180 | 185 | Result-read errors distinguish foreign ownership, a missing saved result, | |
| 181 | 186 | and permission denial without exposing filesystem paths or saved content. | |
| 182 | 187 | - `subagent_stop` stops one live child. | |
| 183 | 188 | - `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. | |
| 186 | 191 | ||
| 187 | 192 | Child execution started by `subagent_spawn` is asynchronous unless it receives | |
| 188 | 193 | an explicit `wait` value. For an unwaited spawn, completion arrives through the | |
| @@ -224,14 +229,14 @@ private local session data. | |||
| 224 | 229 | ||
| 225 | 230 | Shared children use the host working directory. With `isolation: "worktree"`, | |
| 226 | 231 | 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 | |
| 228 | 233 | partial work in the worktree, along with any captured patch and changed-file | |
| 229 | 234 | list. zut never applies a captured patch automatically. Failed or interrupted | |
| 230 | 235 | worktrees are retained for inspection; an idle successful worktree is cleaned | |
| 231 | 236 | when its child is explicitly stopped. | |
| 232 | 237 | ||
| 233 | 238 | 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 | |
| 235 | 240 | patch bytes are stored in `subagents/<child-id>/patch.diff` under the managed | |
| 236 | 241 | state root shown above; logical references are identifiers, not filesystem | |
| 237 | 242 | 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 remove | 56 | owner.lock # host-ownership coordination; do not modify or remove | ||
| 57 | .transcript-backup-* # retained private pre-repair transcript, when repaired | 57 | .transcript-backup-* # retained private pre-repair transcript, when repaired | ||
| 58 | metadata.json # rebuildable bounded state | 58 | metadata.json # rebuildable bounded state | ||
| 59 | - | result.json # latest outcome, final summary or partial handoff, artifacts | 59 | + | result.json # latest outcome, final summary, artifacts |
| 60 | patch.diff # optional worktree capture | 60 | 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 accepted | 89 | 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 and | 91 | `subagents.allowed_tools` is an allowlist for child-visible tools and | ||
| 92 | - | `subagents.allowed_roots` limits eligible child workspaces. Each child receives | 92 | + | `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` and | 94 | + | per-turn step default: a child whose parent resolution is unlimited runs with |
| 95 | - | `subagents.budget_ratio`, are ignored. Resident journals created before this | 95 | + | `MaxSteps=50`; an explicit parent limit is preserved. Removed legacy |
| 96 | - | budget policy are incompatible: zut leaves them untouched on disk and excludes | 96 | + | 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 recovery | 98 | + | 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; cache | 101 | + | instead. Other journal recovery errors are still reported. |
| 102 | - | reads count at 25%. It is separate from active context-window usage and survives | 102 | + | |
| 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 terminal | 105 | + | explicit translation and their transcripts are never rewritten by |
| 106 | - | response can make displayed usage exceed 100%; no later request is sent in that | 106 | + | translation. `result.json` keeps a deprecated `handoff` field that stays |
| 107 | - | turn. If the worker still needs another model step, it reports | 107 | + | empty for new results and remains readable for archives. `subagent_status` |
| 108 | - | `budget_exhausted`, not successful idle work. A final answer that completes on | 108 | + | 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 partial | 111 | + | |
| 112 | - | handoff, capped at 16 KiB. It includes recent visible progress messages, tool | 112 | + | 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 because | 114 | + | |
| 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. Worktree | 117 | + | results, limits, and remaining verifications, and do not start broad new work. |
| 118 | - | changes are captured even on budget exhaustion, and the worktree is retained | 118 | + | ``` |
| 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 before | 134 | Pass this to `subagent_status`, then reconcile the history and artifacts before | ||
| 128 | - | repeating any side effects. Explicit `subagent_resume` after terminal exhaustion | 135 | + | 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 the | 137 | + | automatically retry the task. Ordinary follow-ups continue the existing |
| 131 | - | configured limit or automatically retry the task. The allowance baseline is | 138 | + | 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 remains | 141 | + | Failed, cancelled, and interrupted required work remains |
| 137 | - | unresolved until an explicit successful follow-up. Reading a partial handoff or | 142 | + | 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 never | 143 | 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 current | 181 | - `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 the | 182 | 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 |
| 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 allowance | 189 | + | 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 receives | 192 | Child execution started by `subagent_spawn` is asynchronous unless it receives | ||
| 188 | an explicit `wait` value. For an unwaited spawn, completion arrives through the | 193 | 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 list | 231 | 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 |
| 228 | partial work in the worktree, along with any captured patch and changed-file | 233 | partial work in the worktree, along with any captured patch and changed-file | ||
| 229 | list. zut never applies a captured patch automatically. Failed or interrupted | 234 | list. zut never applies a captured patch automatically. Failed or interrupted | ||
| 230 | worktrees are retained for inspection; an idle successful worktree is cleaned | 235 | 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 the | 238 | 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 |
| 235 | patch bytes are stored in `subagents/<child-id>/patch.diff` under the managed | 240 | patch bytes are stored in `subagents/<child-id>/patch.diff` under the managed | ||
| 236 | state root shown above; logical references are identifiers, not filesystem | 241 | state root shown above; logical references are identifiers, not filesystem | ||
| 237 | paths. Inspect the retained worktree and patch before resuming or applying | 242 | 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 { | |||
| 101 | 101 | if context := tui.ContextUsageText(snapshot.ContextUsed, snapshot.ContextMax); context != "" { | |
| 102 | 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 | 104 | return strings.Join(parts, " ") | |
| 108 | 105 | } | |
| 109 | 106 | ||
| @@ -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 | |||
| 233 | 233 | } | |
| 234 | 234 | } | |
| 235 | 235 | ||
| 236 | - | func TestResidentUsageMetadataShowsRolloutBudget(t *testing.T) { | |
| 236 | + | func TestResidentUsageMetadataShowsContextOnly(t *testing.T) { | |
| 237 | 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 | - | 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) | |
| 242 | 246 | } | |
| 243 | 247 | } | |
| 244 | 248 | ||
| @@ -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) { | |||
| 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 | 106 | got := residentResultStatus("child", subagents.ResidentResult{ | |
| 105 | - | State: subagents.ResidentBudgetExhausted, | |
| 107 | + | State: subagents.ResidentFailed, | |
| 106 | 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 | 111 | t.Fatalf("result status = %q", got) | |
| 110 | 112 | } | |
| 111 | 113 | } | |
| @@ -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 | |||
| 2 | 2 | ||
| 3 | 3 | import ( | |
| 4 | 4 | "context" | |
| 5 | - | "errors" | |
| 6 | 5 | "fmt" | |
| 7 | 6 | "strings" | |
| 8 | 7 | "sync" | |
| 9 | - | "sync/atomic" | |
| 10 | 8 | ||
| 11 | 9 | "github.com/bnema/zut/packages/agent/subagents" | |
| 12 | 10 | "github.com/bnema/zut/packages/agent/tools" | |
| @@ -38,12 +36,14 @@ func newResidentChildRunner(args Args, spec subagents.ResidentChildSpec, journal | |||
| 38 | 36 | 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 | 39 | agent := core.NewAgent(resolved.NewClient(), resolved.Model, system, registry) | |
| 46 | 40 | 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 | 47 | agent.ContextWindow = resolved.ContextWindow | |
| 48 | 48 | agent.MaxTokens = resolved.MaxOutput | |
| 49 | 49 | agent.Reasoning = resolved.Reasoning | |
| @@ -54,8 +54,9 @@ func newResidentChildRunner(args Args, spec subagents.ResidentChildSpec, journal | |||
| 54 | 54 | baseline = journal.ConfigureUsage(resolved.ContextWindow, resolved.AuthMethod == "oauth") | |
| 55 | 55 | agent.SeedCost(baseline.Usage) | |
| 56 | 56 | } | |
| 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) | |
| 59 | 60 | rootCacheID := strings.TrimSpace(spec.RootCacheID) | |
| 60 | 61 | if rootCacheID == "" { | |
| 61 | 62 | // 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 | 85 | turnCtx, cancel := context.WithCancel(ctx) | |
| 85 | 86 | defer cancel() | |
| 86 | - | budget.SetBaseline(journal.BudgetBaseline()) | |
| 87 | - | startedExhausted := budget.Snapshot().State == subagents.BudgetExceeded | |
| 88 | - | var budgetStopped atomic.Bool | |
| 89 | 87 | var journalErr error | |
| 90 | 88 | var journalMu sync.Mutex | |
| 91 | 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 | 90 | if err := journal.RecordAgentEvent(event); err != nil { | |
| 99 | 91 | journalMu.Lock() | |
| 100 | 92 | if journalErr == nil { | |
| @@ -109,25 +101,35 @@ func newResidentChildRunner(args Args, spec subagents.ResidentChildSpec, journal | |||
| 109 | 101 | if journalErr != nil { | |
| 110 | 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 | 104 | return err | |
| 119 | 105 | }, nil | |
| 120 | 106 | } | |
| 121 | 107 | ||
| 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 { | |
| 124 | 115 | 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 | 122 | 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, "", "" | |
| 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 | ||
| @@ -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" + extra | 36 | 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.MaxSteps | 40 | 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.ContextWindow | 47 | agent.ContextWindow = resolved.ContextWindow | ||
| 48 | agent.MaxTokens = resolved.MaxOutput | 48 | agent.MaxTokens = resolved.MaxOutput | ||
| 49 | agent.Reasoning = resolved.Reasoning | 49 | 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 still | 62 | // 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 error | 87 | var journalErr error | ||
| 90 | var journalMu sync.Mutex | 88 | 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 err | 104 | return err | ||
| 119 | }, nil | 105 | }, 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 | return | 115 | 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 ( | |||
| 8 | 8 | "net/http" | |
| 9 | 9 | "net/http/httptest" | |
| 10 | 10 | "strings" | |
| 11 | + | "sync" | |
| 11 | 12 | "sync/atomic" | |
| 12 | 13 | "testing" | |
| 13 | 14 | "time" | |
| @@ -17,6 +18,168 @@ import ( | |||
| 17 | 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 | 183 | func TestResidentChildRegistryUsesExactToolListAndForbidsDelegation(t *testing.T) { | |
| 21 | 184 | catalogue := core.Registry{ | |
| 22 | 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 | 206 | 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) | |
| 46 | 208 | ||
| 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) | |
| 51 | 232 | } | |
| 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) | |
| 54 | 244 | } | |
| 55 | 245 | ||
| 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) | |
| 60 | 251 | } | |
| 61 | 252 | } | |
| 62 | 253 | ||
| 63 | - | func TestResidentChildRunnerKeepsToolsAtNinetyPercent(t *testing.T) { | |
| 254 | + | func TestResidentChildRunnerKeepsToolsAcrossTurns(t *testing.T) { | |
| 64 | 255 | var requests atomic.Int32 | |
| 65 | 256 | server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { | |
| 66 | 257 | requestNumber := requests.Add(1) | |
| @@ -99,7 +290,7 @@ func TestResidentChildRunnerKeepsToolsAtNinetyPercent(t *testing.T) { | |||
| 99 | 290 | })) | |
| 100 | 291 | defer server.Close() | |
| 101 | 292 | ||
| 102 | - | runner, journal := newBudgetedResidentTestRunner(t, server.URL, 1_000) | |
| 293 | + | runner, journal := newResidentTestRunner(t, server.URL) | |
| 103 | 294 | if err := runner(t.Context(), "review"); err != nil { | |
| 104 | 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 | 306 | var requests atomic.Int32 | |
| 116 | 307 | server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { | |
| 117 | 308 | requests.Add(1) | |
| @@ -130,7 +321,7 @@ func TestResidentChildRunnerTerminatesAtBudgetLimit(t *testing.T) { | |||
| 130 | 321 | })) | |
| 131 | 322 | defer server.Close() | |
| 132 | 323 | ||
| 133 | - | runner, journal := newBudgetedResidentTestRunner(t, server.URL, 1_000) | |
| 324 | + | runner, journal := newResidentTestRunner(t, server.URL) | |
| 134 | 325 | if err := runner(t.Context(), "review"); err != nil { | |
| 135 | 326 | t.Fatalf("runner error = %v, want terminal result", err) | |
| 136 | 327 | } | |
| @@ -140,42 +331,16 @@ func TestResidentChildRunnerTerminatesAtBudgetLimit(t *testing.T) { | |||
| 140 | 331 | if summary := latestResidentAssistantText(t, journal); summary != "best available result" { | |
| 141 | 332 | t.Fatalf("latest summary = %q", summary) | |
| 142 | 333 | } | |
| 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) | |
| 148 | 337 | } | |
| 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) | |
| 175 | 340 | } | |
| 176 | 341 | } | |
| 177 | 342 | ||
| 178 | - | func TestResidentChildRunnerResumesWithSameSizeAllowance(t *testing.T) { | |
| 343 | + | func TestResidentChildRunnerResumesWithRetainedHistory(t *testing.T) { | |
| 179 | 344 | var requests atomic.Int32 | |
| 180 | 345 | server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { | |
| 181 | 346 | request := requests.Add(1) | |
| @@ -208,7 +373,7 @@ func TestResidentChildRunnerResumesWithSameSizeAllowance(t *testing.T) { | |||
| 208 | 373 | t.Cleanup(func() { _ = manager.Close(context.Background()) }) | |
| 209 | 374 | completed := make(chan subagents.ResidentCompletion, 2) | |
| 210 | 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 | 377 | if _, err := manager.Spawn(t.Context(), spec, "review"); err != nil { | |
| 213 | 378 | t.Fatal(err) | |
| 214 | 379 | } | |
| @@ -228,16 +393,16 @@ func TestResidentChildRunnerResumesWithSameSizeAllowance(t *testing.T) { | |||
| 228 | 393 | } | |
| 229 | 394 | } | |
| 230 | 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 | 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 | 402 | t.Helper() | |
| 238 | 403 | 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"}, | |
| 241 | 406 | } | |
| 242 | 407 | journal, err := subagents.OpenResidentJournal(t.TempDir(), spec.ID) | |
| 243 | 408 | 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.Int32 | 255 | 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.Int32 | 306 | 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.Int32 | 344 | 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 | |||
| 388 | 388 | } | |
| 389 | 389 | childTools = expandWebCapabilityTools(childTools, allTools, permitted) | |
| 390 | 390 | 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 | 391 | return spec, nil | |
| 398 | 392 | } | |
| 399 | 393 | ||
| @@ -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 = childTools | 390 | 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, nil | 391 | return spec, nil | ||
| 398 | } | 392 | } | ||
| 399 | 393 | ||||
M packages/agent/subagent_runtime_test.go +14 -3
| @@ -2,7 +2,9 @@ package agent | |||
| 2 | 2 | ||
| 3 | 3 | import ( | |
| 4 | 4 | "context" | |
| 5 | + | "encoding/json" | |
| 5 | 6 | "slices" | |
| 7 | + | "strings" | |
| 6 | 8 | "testing" | |
| 7 | 9 | ||
| 8 | 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 | 99 | runtime := newSubagentRuntime(subagentRuntimeConfig{ | |
| 98 | 100 | Args: Args{}, Root: t.TempDir(), RepoRoot: t.TempDir(), | |
| 99 | 101 | Provider: "openai", Model: "gpt-5.6-sol", ContextWindow: 42_000, | |
| @@ -104,8 +106,17 @@ func TestResidentChildSpecUsesSelectedModelContextForBudget(t *testing.T) { | |||
| 104 | 106 | if err != nil { | |
| 105 | 107 | t.Fatal(err) | |
| 106 | 108 | } | |
| 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) | |
| 109 | 120 | } | |
| 110 | 121 | } | |
| 111 | 122 | ||
| @@ -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 { | |||
| 26 | 26 | result.Status, result.Error = string(ResidentFailed), c.Err.Error() | |
| 27 | 27 | if errors.Is(c.Err, context.Canceled) { | |
| 28 | 28 | result.Status = string(ResidentInterrupted) | |
| 29 | - | } else if errors.Is(c.Err, ErrBudgetExceeded) { | |
| 30 | - | result.Status = string(ResidentBudgetExhausted) | |
| 31 | 29 | } | |
| 32 | 30 | } | |
| 33 | 31 | 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 result | 31 | return result | ||
M packages/agent/subagents/completion_test.go +2 -2
| @@ -15,8 +15,8 @@ func TestResidentCompletionProjection(t *testing.T) { | |||
| 15 | 15 | {nil, "completed"}, | |
| 16 | 16 | {errors.New("failure"), "failed"}, | |
| 17 | 17 | {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"}, | |
| 20 | 20 | } { | |
| 21 | 21 | t.Run(tc.status, func(t *testing.T) { | |
| 22 | 22 | 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 { | |||
| 161 | 161 | } | |
| 162 | 162 | var sb strings.Builder | |
| 163 | 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 | 165 | for _, profile := range profiles { | |
| 166 | 166 | if profile == nil { | |
| 167 | 167 | continue | |
| @@ -161,7 +161,7 @@ func SystemPromptAddendum(profiles []*Profile) string { | |||||
| 161 | } | 161 | } | ||
| 162 | var sb strings.Builder | 162 | 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 | continue | 167 | continue | ||
M packages/agent/subagents/resident_artifact_recovery_test.go +3 -2
| @@ -1,6 +1,7 @@ | |||
| 1 | 1 | package subagents | |
| 2 | 2 | ||
| 3 | 3 | import ( | |
| 4 | + | "errors" | |
| 4 | 5 | "os" | |
| 5 | 6 | "path/filepath" | |
| 6 | 7 | "reflect" | |
| @@ -34,7 +35,7 @@ func TestReconcileResidentJournalRestoresCapturedArtifacts(t *testing.T) { | |||
| 34 | 35 | } else if err := writeResidentPatch(journal.Dir(), []byte("stale patch from an earlier turn\n")); err != nil { | |
| 35 | 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 | 39 | t.Fatal(err) | |
| 39 | 40 | } | |
| 40 | 41 | original, err := journal.Result() | |
| @@ -70,7 +71,7 @@ func TestReconcileResidentJournalRestoresCapturedArtifacts(t *testing.T) { | |||
| 70 | 71 | if err != nil { | |
| 71 | 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 | 75 | t.Fatalf("reconstructed result = %#v, original = %#v", result, original) | |
| 75 | 76 | } | |
| 76 | 77 | } | |
| @@ -1,6 +1,7 @@ | |||||
| 1 | package subagents | 1 | 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 ( | |||
| 8 | 8 | ) | |
| 9 | 9 | ||
| 10 | 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 | 12 | t.Run(turnErr.Error(), func(t *testing.T) { | |
| 13 | 13 | root := t.TempDir() | |
| 14 | 14 | manager := NewResidentManager(root, func(ResidentChildSpec, *ResidentJournal) (ResidentTurnRunner, error) { | |
| @@ -24,13 +24,13 @@ func TestResidentCancellationPreservesInterruptedState(t *testing.T) { | |||
| 24 | 24 | t.Fatal(err) | |
| 25 | 25 | } | |
| 26 | 26 | cancel() // Accepted children outlive the caller's spawn context. | |
| 27 | - | completion := awaitBudgetCompletion(t, completions) | |
| 27 | + | completion := awaitResidentCompletion(t, completions) | |
| 28 | 28 | snapshot, ok := manager.SnapshotFor(spec.ID) | |
| 29 | 29 | if !ok || snapshot.State != ResidentInterrupted || completion.Completion().Status != string(ResidentInterrupted) { | |
| 30 | 30 | t.Fatalf("snapshot = %#v, completion = %#v", snapshot, completion) | |
| 31 | 31 | } | |
| 32 | 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 | 34 | t.Fatalf("result = %#v, error = %v", result, err) | |
| 35 | 35 | } | |
| 36 | 36 | 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() { | |||
| 412 | 412 | } | |
| 413 | 413 | terminalErr := result.err | |
| 414 | 414 | 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 | 416 | captured, err := c.workspace.Capture(c.ctx) | |
| 417 | 417 | if err != nil { | |
| 418 | 418 | terminalErr = errors.Join(terminalErr, fmt.Errorf("capture resident worktree: %w", err)) | |
| @@ -423,9 +423,6 @@ func (c *ResidentChild) run() { | |||
| 423 | 423 | state := ResidentIdle | |
| 424 | 424 | if terminalErr != nil { | |
| 425 | 425 | state = ResidentFailed | |
| 426 | - | if errors.Is(terminalErr, ErrBudgetExceeded) { | |
| 427 | - | state = ResidentBudgetExhausted | |
| 428 | - | } | |
| 429 | 426 | } | |
| 430 | 427 | summary := "" | |
| 431 | 428 | persistenceFailed := false | |
| @@ -412,7 +412,7 @@ func (c *ResidentChild) run() { | |||||
| 412 | } | 412 | } | ||
| 413 | terminalErr := result.err | 413 | terminalErr := result.err | ||
| 414 | var capture *WorkspaceCapture | 414 | 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 := ResidentIdle | 423 | state := ResidentIdle | ||
| 424 | if terminalErr != nil { | 424 | if terminalErr != nil { | ||
| 425 | state = ResidentFailed | 425 | state = ResidentFailed | ||
| 426 | - | if errors.Is(terminalErr, ErrBudgetExceeded) { | |||
| 427 | - | state = ResidentBudgetExhausted | |||
| 428 | - | } | |||
| 429 | } | 426 | } | ||
| 430 | summary := "" | 427 | summary := "" | ||
| 431 | persistenceFailed := false | 428 | 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 ( | |||
| 15 | 15 | "github.com/bnema/zut/packages/provider" | |
| 16 | 16 | ) | |
| 17 | 17 | ||
| 18 | - | func TestResidentBudgetExhaustionHandoffAndRecovery(t *testing.T) { | |
| 18 | + | func TestResidentFailureRecoveryKeepsRequiredUnmetUntilResume(t *testing.T) { | |
| 19 | 19 | for _, mode := range []string{"read-only", "shared", "worktree"} { | |
| 20 | 20 | for _, restart := range []bool{false, true} { | |
| 21 | 21 | t.Run(fmt.Sprintf("%s/restart=%t", mode, restart), func(t *testing.T) { | |
| @@ -23,16 +23,14 @@ func TestResidentBudgetExhaustionHandoffAndRecovery(t *testing.T) { | |||
| 23 | 23 | if mode == "worktree" { | |
| 24 | 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 | 27 | if mode == "worktree" { | |
| 28 | 28 | spec.WorkspaceMode, spec.WorkspaceCapture = WorkspaceWorktree, CapturePatch | |
| 29 | 29 | } | |
| 30 | + | turnErr := errors.New("turn failed") | |
| 30 | 31 | factory := func(spec ResidentChildSpec, journal *ResidentJournal) (ResidentTurnRunner, error) { | |
| 31 | 32 | return func(_ context.Context, prompt string) error { | |
| 32 | 33 | if prompt == "continue remaining work" { | |
| 33 | - | if journal.BudgetBaseline() != 100 { | |
| 34 | - | return fmt.Errorf("recovery baseline = %d", journal.BudgetBaseline()) | |
| 35 | - | } | |
| 36 | 34 | messages, err := ReadResidentTranscriptMessages(journal.Dir()) | |
| 37 | 35 | if err != nil || len(messages) < 3 { | |
| 38 | 36 | return fmt.Errorf("missing restored progress: %d messages, %v", len(messages), err) | |
| @@ -67,7 +65,7 @@ func TestResidentBudgetExhaustionHandoffAndRecovery(t *testing.T) { | |||
| 67 | 65 | return err | |
| 68 | 66 | } | |
| 69 | 67 | } | |
| 70 | - | return ErrBudgetExceeded | |
| 68 | + | return turnErr | |
| 71 | 69 | }, nil | |
| 72 | 70 | } | |
| 73 | 71 | manager := NewResidentManager(root, factory) | |
| @@ -77,23 +75,23 @@ func TestResidentBudgetExhaustionHandoffAndRecovery(t *testing.T) { | |||
| 77 | 75 | if _, err := manager.Spawn(t.Context(), spec, "inspect and verify README"); err != nil { | |
| 78 | 76 | t.Fatal(err) | |
| 79 | 77 | } | |
| 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) { | |
| 82 | 80 | t.Fatalf("terminal notification = %#v", completion) | |
| 83 | 81 | } | |
| 84 | - | assertBudgetPartial := func() ResidentResult { | |
| 82 | + | assertFailedPartial := func() ResidentResult { | |
| 85 | 83 | t.Helper() | |
| 86 | 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 | 86 | t.Fatalf("partial status = %#v; unmet = %#v", snapshot, manager.UnmetRequired()) | |
| 89 | 87 | } | |
| 90 | 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 | 90 | t.Fatalf("saved partial result = %#v, %v", result, err) | |
| 93 | 91 | } | |
| 94 | 92 | return result | |
| 95 | 93 | } | |
| 96 | - | result := assertBudgetPartial() | |
| 94 | + | result := assertFailedPartial() | |
| 97 | 95 | if mode == "worktree" { | |
| 98 | 96 | if result.PatchRef != PatchRef(spec.ID) || len(result.ChangedFiles) != 1 { | |
| 99 | 97 | t.Fatalf("missing partial patch: %#v", result) | |
| @@ -112,40 +110,40 @@ func TestResidentBudgetExhaustionHandoffAndRecovery(t *testing.T) { | |||
| 112 | 110 | if errs := manager.Reconcile(); len(errs) != 0 { | |
| 113 | 111 | t.Fatal(errs) | |
| 114 | 112 | } | |
| 115 | - | assertBudgetPartial() | |
| 113 | + | assertFailedPartial() | |
| 116 | 114 | } | |
| 117 | 115 | if err := manager.Resume(t.Context(), spec.ID, "continue remaining work"); err != nil { | |
| 118 | 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 | 119 | t.Fatal(completion.Err) | |
| 122 | 120 | } | |
| 123 | 121 | if unmet := manager.UnmetRequired(); len(unmet) != 0 { | |
| 124 | 122 | t.Fatalf("successful recovery remains unmet: %#v", unmet) | |
| 125 | 123 | } | |
| 126 | 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 | 126 | t.Fatalf("recovery lost cumulative accounting: %#v", snapshot) | |
| 129 | 127 | } | |
| 130 | 128 | if err := manager.Close(t.Context()); err != nil { | |
| 131 | 129 | t.Fatal(err) | |
| 132 | 130 | } | |
| 133 | 131 | 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) | |
| 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 | 141 | t.Helper() | |
| 144 | 142 | select { | |
| 145 | 143 | case completion := <-completions: | |
| 146 | 144 | return completion | |
| 147 | 145 | case <-time.After(5 * time.Second): | |
| 148 | - | t.Fatal("no terminal budget notification") | |
| 146 | + | t.Fatal("no terminal notification") | |
| 149 | 147 | return ResidentCompletion{} | |
| 150 | 148 | } | |
| 151 | 149 | } | |
| @@ -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, CapturePatch | 28 | 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 err | 65 | return err | ||
| 68 | } | 66 | } | ||
| 69 | } | 67 | } | ||
| 70 | - | return ErrBudgetExceeded | 68 | + | return turnErr |
| 71 | }, nil | 69 | }, 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 result | 92 | 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 completion | 144 | 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 | |||
| 2 | 2 | ||
| 3 | 3 | import ( | |
| 4 | 4 | "encoding/json" | |
| 5 | + | "errors" | |
| 6 | + | "path/filepath" | |
| 5 | 7 | "strings" | |
| 6 | 8 | "testing" | |
| 7 | 9 | ||
| @@ -9,7 +11,7 @@ import ( | |||
| 9 | 11 | "github.com/bnema/zut/packages/provider" | |
| 10 | 12 | ) | |
| 11 | 13 | ||
| 12 | - | func TestResidentHandoffOmitsRawToolSecrets(t *testing.T) { | |
| 14 | + | func TestResidentHandoffStaysEmptyForNewResults(t *testing.T) { | |
| 13 | 15 | journal, err := OpenResidentJournal(t.TempDir(), "handoff-secrets") | |
| 14 | 16 | if err != nil { | |
| 15 | 17 | t.Fatal(err) | |
| @@ -27,37 +29,54 @@ func TestResidentHandoffOmitsRawToolSecrets(t *testing.T) { | |||
| 27 | 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 | 30 | core.EvToolCall{ID: "call", Name: "bash", Args: args}, | |
| 29 | 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 | 34 | if err := journal.RecordAgentEvent(event); err != nil { | |
| 32 | 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 | 40 | t.Fatal(err) | |
| 37 | 41 | } | |
| 38 | 42 | result, err := journal.Result() | |
| 39 | 43 | if err != nil { | |
| 40 | 44 | t.Fatal(err) | |
| 41 | 45 | } | |
| 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) | |
| 53 | 63 | } | |
| 54 | 64 | } | |
| 55 | 65 | items, err := ReadResidentHistory(journal.Dir(), 16) | |
| 56 | 66 | if err != nil { | |
| 57 | 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 | 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 | } | |
| @@ -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 ( | |||
| 19 | 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 | 24 | const ( | |
| 25 | 25 | residentMetadataName = "metadata.json" | |
| @@ -37,7 +37,8 @@ const ( | |||
| 37 | 37 | residentRecordToolCall = "tool.call" | |
| 38 | 38 | residentRecordToolResult = "tool.result" | |
| 39 | 39 | residentRecordUsage = "usage" | |
| 40 | - | residentJournalVersion = 2 | |
| 40 | + | residentJournalVersion = 3 | |
| 41 | + | residentJournalVersionV2 = 2 | |
| 41 | 42 | residentInterruptedText = "tool interrupted by resident host restart" | |
| 42 | 43 | residentMaxRecordBytes = 2 << 20 | |
| 43 | 44 | residentResultSummaryBytes = 256 << 10 | |
| @@ -54,25 +55,26 @@ func PatchRef(id string) string { return AgentRef(id) + "/patch" } | |||
| 54 | 55 | type ResidentState string | |
| 55 | 56 | ||
| 56 | 57 | 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" | |
| 65 | 65 | ) | |
| 66 | 66 | ||
| 67 | 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 | 70 | 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" | |
| 76 | 78 | ) | |
| 77 | 79 | ||
| 78 | 80 | // ResidentChildSpec is the non-secret configuration required to reconstruct | |
| @@ -106,32 +108,28 @@ type ResidentChildSpec struct { | |||
| 106 | 108 | InheritSkills *bool `json:"inherit_skills,omitempty"` | |
| 107 | 109 | Permissions []string `json:"permissions,omitempty"` | |
| 108 | 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 | 113 | 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:"-"` | |
| 135 | 133 | } | |
| 136 | 134 | ||
| 137 | 135 | // RecordAgentEvent persists finalized provider-neutral transcript events. It | |
| @@ -245,7 +243,6 @@ type ResidentMetadata struct { | |||
| 245 | 243 | State ResidentState `json:"state"` | |
| 246 | 244 | UpdatedAt time.Time `json:"updated_at"` | |
| 247 | 245 | Usage provider.Usage `json:"usage,omitempty"` | |
| 248 | - | BudgetBaseline int64 `json:"budget_baseline,omitempty"` | |
| 249 | 246 | ContextUsed int `json:"context_used,omitempty"` | |
| 250 | 247 | ContextMax int `json:"context_max,omitempty"` | |
| 251 | 248 | Subscription bool `json:"subscription,omitempty"` | |
| @@ -255,16 +252,19 @@ type ResidentMetadata struct { | |||
| 255 | 252 | // remains authoritative for detailed content; this file allows status and | |
| 256 | 253 | // resource readers to locate a terminal outcome without reading it all. | |
| 257 | 254 | 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"` | |
| 268 | 268 | } | |
| 269 | 269 | ||
| 270 | 270 | // ResidentJournal serializes durable child records. It is intentionally a | |
| @@ -277,7 +277,6 @@ type ResidentJournal struct { | |||
| 277 | 277 | latestSummary string | |
| 278 | 278 | usageMu sync.RWMutex | |
| 279 | 279 | usage provider.Usage | |
| 280 | - | budgetBaseline int64 | |
| 281 | 280 | usageRecordPersisted bool | |
| 282 | 281 | usageMetadataPersisted bool | |
| 283 | 282 | contextUsed int | |
| @@ -291,11 +290,10 @@ type ResidentJournal struct { | |||
| 291 | 290 | // ResidentUsageSnapshot is the bounded usage projection shared by durable and | |
| 292 | 291 | // live resident state. | |
| 293 | 292 | 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 | |
| 299 | 297 | } | |
| 300 | 298 | ||
| 301 | 299 | // ConfigureUsage records resolved model metadata and restores the latest | |
| @@ -307,12 +305,11 @@ func (j *ResidentJournal) ConfigureUsage(contextMax int, subscription bool) Resi | |||
| 307 | 305 | j.usageMu.Lock() | |
| 308 | 306 | if metadata, err := ReadResidentMetadata(filepath.Join(j.dir, residentMetadataName)); err == nil { | |
| 309 | 307 | j.usage = metadata.Usage | |
| 310 | - | j.budgetBaseline = metadata.BudgetBaseline | |
| 311 | 308 | j.contextUsed = metadata.ContextUsed | |
| 312 | 309 | } | |
| 313 | 310 | j.contextMax = contextMax | |
| 314 | 311 | 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 | 313 | j.usageMu.Unlock() | |
| 317 | 314 | return snapshot | |
| 318 | 315 | } | |
| @@ -323,12 +320,7 @@ func (j *ResidentJournal) usageSnapshot() ResidentUsageSnapshot { | |||
| 323 | 320 | } | |
| 324 | 321 | j.usageMu.RLock() | |
| 325 | 322 | 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} | |
| 332 | 324 | } | |
| 333 | 325 | ||
| 334 | 326 | func (j *ResidentJournal) usageRecordPersistence(record residentRecord) (durable, metadataPersisted bool) { | |
| @@ -412,14 +404,6 @@ func (j *ResidentJournal) Dir() string { | |||
| 412 | 404 | 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 | 407 | // Accept establishes the authoritative spawn commit point. The transcript is | |
| 424 | 408 | // synced before metadata is projected so a crash cannot fabricate acceptance. | |
| 425 | 409 | func (j *ResidentJournal) Accept(spec ResidentChildSpec, prompt string) error { | |
| @@ -465,21 +449,9 @@ func (j *ResidentJournal) AcceptFollowUp(spec ResidentChildSpec, turnID, prompt | |||
| 465 | 449 | return errors.New("resident journal: closed") | |
| 466 | 450 | } | |
| 467 | 451 | 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 { | |
| 478 | 453 | return err | |
| 479 | 454 | } | |
| 480 | - | j.usageMu.Lock() | |
| 481 | - | j.budgetBaseline = baseline | |
| 482 | - | j.usageMu.Unlock() | |
| 483 | 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 | 487 | if errors.Is(turnErr, context.Canceled) { | |
| 516 | 488 | state, outcome = ResidentInterrupted, residentOutcomeInterrupted | |
| 517 | 489 | 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 | 491 | result := ResidentResult{Version: residentJournalVersion, ID: spec.ID, TurnID: turnID, State: state, Summary: j.latestAssistantSummary(), CreatedAt: time.Now().UTC()} | |
| 524 | 492 | if turnErr != nil { | |
| 525 | 493 | result.ErrorCode = residentErrorTurnFailed | |
| 526 | 494 | } | |
| 527 | - | if state == ResidentBudgetExhausted { | |
| 528 | - | result.ErrorCode = residentErrorBudgetExhausted | |
| 529 | - | result.Handoff = residentBudgetHandoff(j.dir, spec) | |
| 530 | - | } | |
| 531 | 495 | if capture != nil { | |
| 532 | 496 | result.ChangedFiles = append([]string(nil), capture.ChangedFiles...) | |
| 533 | 497 | if len(capture.Patch) > 0 { | |
| @@ -625,7 +589,7 @@ func (j *ResidentJournal) metadata(spec ResidentChildSpec, state ResidentState, | |||
| 625 | 589 | return ResidentMetadata{ | |
| 626 | 590 | Version: residentJournalVersion, ID: spec.ID, SessionID: spec.SessionID, | |
| 627 | 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 | 701 | if err := json.Unmarshal(data, &result); err != nil { | |
| 738 | 702 | 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 | 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 | 716 | return result, nil | |
| 744 | 717 | } | |
| 745 | 718 | ||
| @@ -807,8 +780,8 @@ func reconcileOwnedResidentJournal(journal *ResidentJournal) (ResidentMetadata, | |||
| 807 | 780 | if len(records) == 0 || records[0].Type != residentRecordAccepted || records[0].Spec == nil { | |
| 808 | 781 | return ResidentMetadata{}, errors.New("resident journal: missing accepted child record") | |
| 809 | 782 | } | |
| 810 | - | if records[0].Version != residentJournalVersion { | |
| 811 | - | return ResidentMetadata{}, ErrIncompatibleResidentBudget | |
| 783 | + | if records[0].Version != residentJournalVersion && records[0].Version != residentJournalVersionV2 { | |
| 784 | + | return ResidentMetadata{}, ErrIncompatibleResidentJournal | |
| 812 | 785 | } | |
| 813 | 786 | if repaired, ok := repairLegacyFalseRecovery(records); ok { | |
| 814 | 787 | if err := journal.rewriteTranscript(repaired); err != nil { | |
| @@ -820,6 +793,13 @@ func reconcileOwnedResidentJournal(journal *ResidentJournal) (ResidentMetadata, | |||
| 820 | 793 | if spec.ID == "" || spec.SessionID == "" { | |
| 821 | 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 | 803 | state := ResidentQueued | |
| 824 | 804 | lastStateAt := records[0].Time | |
| 825 | 805 | var lastFinished residentRecord | |
| @@ -838,13 +818,18 @@ func reconcileOwnedResidentJournal(journal *ResidentJournal) (ResidentMetadata, | |||
| 838 | 818 | if index > 0 && record.Type == residentRecordAccepted { | |
| 839 | 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 | 827 | switch record.Type { | |
| 842 | 828 | case residentRecordTurnAccepted: | |
| 843 | 829 | if record.TurnID == "" || seenTurns[record.TurnID] != "" { | |
| 844 | 830 | return ResidentMetadata{}, errors.New("resident journal: invalid accepted turn record") | |
| 845 | 831 | } | |
| 846 | 832 | seenTurns[record.TurnID] = residentRecordTurnAccepted | |
| 847 | - | usage.BudgetBaseline = record.BudgetBaseline | |
| 848 | 833 | lastStateAt = record.Time | |
| 849 | 834 | case residentRecordTurnStarted: | |
| 850 | 835 | if record.TurnID == "" || (seenTurns[record.TurnID] != residentRecordTurnAccepted && record.TurnID != spec.InitialTurnID) { | |
| @@ -863,11 +848,28 @@ func reconcileOwnedResidentJournal(journal *ResidentJournal) (ResidentMetadata, | |||
| 863 | 848 | switch record.Outcome { | |
| 864 | 849 | case residentOutcomeFailed: | |
| 865 | 850 | 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. | |
| 869 | 868 | state = ResidentCompleted | |
| 870 | 869 | default: | |
| 870 | + | if record.Outcome != residentOutcomeCompleted { | |
| 871 | + | return ResidentMetadata{}, errors.New("resident journal: invalid turn outcome") | |
| 872 | + | } | |
| 871 | 873 | state = ResidentIdle | |
| 872 | 874 | } | |
| 873 | 875 | lastStateAt = record.Time | |
| @@ -914,7 +916,7 @@ func reconcileOwnedResidentJournal(journal *ResidentJournal) (ResidentMetadata, | |||
| 914 | 916 | if record.Usage == nil { | |
| 915 | 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 | 922 | if lastStateAt.IsZero() { | |
| @@ -923,7 +925,7 @@ func reconcileOwnedResidentJournal(journal *ResidentJournal) (ResidentMetadata, | |||
| 923 | 925 | metadata := ResidentMetadata{ | |
| 924 | 926 | Version: residentJournalVersion, ID: spec.ID, SessionID: spec.SessionID, | |
| 925 | 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 | 930 | needsInterruption := state == ResidentQueued || state == ResidentRunning | |
| 929 | 931 | needsToolRepair := len(toolCalls) != len(toolResults) | |
| @@ -1170,9 +1172,10 @@ func rebuildResidentResult(dir string, spec ResidentChildSpec, metadata Resident | |||
| 1170 | 1172 | switch finished.Outcome { | |
| 1171 | 1173 | case residentOutcomeFailed: | |
| 1172 | 1174 | 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 | |
| 1176 | 1179 | } | |
| 1177 | 1180 | return writeResidentResult(dir, result) | |
| 1178 | 1181 | } | |
| @@ -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 = 2 | 40 | + | residentJournalVersion = 3 |
| 41 | + | residentJournalVersionV2 = 2 | |||
| 41 | residentInterruptedText = "tool interrupted by resident host restart" | 42 | residentInterruptedText = "tool interrupted by resident host restart" | ||
| 42 | residentMaxRecordBytes = 2 << 20 | 43 | residentMaxRecordBytes = 2 << 20 | ||
| 43 | residentResultSummaryBytes = 256 << 10 | 44 | residentResultSummaryBytes = 256 << 10 | ||
| @@ -54,25 +55,26 @@ func PatchRef(id string) string { return AgentRef(id) + "/patch" } | |||||
| 54 | type ResidentState string | 55 | 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 reconstruct | 80 | // 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. It | 135 | // 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 and | 252 | // 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 a | 270 | // ResidentJournal serializes durable child records. It is intentionally a | ||
| @@ -277,7 +277,6 @@ type ResidentJournal struct { | |||||
| 277 | latestSummary string | 277 | latestSummary string | ||
| 278 | usageMu sync.RWMutex | 278 | usageMu sync.RWMutex | ||
| 279 | usage provider.Usage | 279 | usage provider.Usage | ||
| 280 | - | budgetBaseline int64 | |||
| 281 | usageRecordPersisted bool | 280 | usageRecordPersisted bool | ||
| 282 | usageMetadataPersisted bool | 281 | usageMetadataPersisted bool | ||
| 283 | contextUsed int | 282 | contextUsed int | ||
| @@ -291,11 +290,10 @@ type ResidentJournal struct { | |||||
| 291 | // ResidentUsageSnapshot is the bounded usage projection shared by durable and | 290 | // 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.Usage | 293 | + | Usage provider.Usage |
| 295 | - | ContextUsed int | 294 | + | ContextUsed int |
| 296 | - | ContextMax int | 295 | + | ContextMax int |
| 297 | - | Subscription bool | 296 | + | Subscription bool |
| 298 | - | BudgetBaseline int64 | |||
| 299 | } | 297 | } | ||
| 300 | 298 | ||||
| 301 | // ConfigureUsage records resolved model metadata and restores the latest | 299 | // 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.Usage | 307 | j.usage = metadata.Usage | ||
| 310 | - | j.budgetBaseline = metadata.BudgetBaseline | |||
| 311 | j.contextUsed = metadata.ContextUsed | 308 | j.contextUsed = metadata.ContextUsed | ||
| 312 | } | 309 | } | ||
| 313 | j.contextMax = contextMax | 310 | j.contextMax = contextMax | ||
| 314 | j.subscription = subscription | 311 | 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 snapshot | 314 | 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.dir | 404 | 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 is | 407 | // 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 err | 453 | 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, residentOutcomeInterrupted | 488 | state, outcome = ResidentInterrupted, residentOutcomeInterrupted | ||
| 517 | recordType = residentRecordInterrupted | 489 | 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 = residentErrorTurnFailed | 493 | 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{}, err | 702 | 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, nil | 716 | 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{}, ErrIncompatibleResidentBudget | 784 | + | 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 := ResidentQueued | 803 | state := ResidentQueued | ||
| 824 | lastStateAt := records[0].Time | 804 | lastStateAt := records[0].Time | ||
| 825 | var lastFinished residentRecord | 805 | 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] = residentRecordTurnAccepted | 832 | seenTurns[record.TurnID] = residentRecordTurnAccepted | ||
| 847 | - | usage.BudgetBaseline = record.BudgetBaseline | |||
| 848 | lastStateAt = record.Time | 833 | 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 = ResidentFailed | 850 | state = ResidentFailed | ||
| 866 | - | case residentOutcomeBudgetExhausted: | 851 | + | case residentV2OutcomeBudgetExhausted: |
| 867 | - | state = ResidentBudgetExhausted | 852 | + | 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 = ResidentCompleted | 868 | state = ResidentCompleted | ||
| 870 | default: | 869 | default: | ||
| 870 | + | if record.Outcome != residentOutcomeCompleted { | |||
| 871 | + | return ResidentMetadata{}, errors.New("resident journal: invalid turn outcome") | |||
| 872 | + | } | |||
| 871 | state = ResidentIdle | 873 | state = ResidentIdle | ||
| 872 | } | 874 | } | ||
| 873 | lastStateAt = record.Time | 875 | 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 == ResidentRunning | 930 | 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 = residentErrorTurnFailed | 1174 | result.ErrorCode = residentErrorTurnFailed | ||
| 1173 | - | case residentOutcomeBudgetExhausted: | 1175 | + | case residentV2OutcomeBudgetExhausted, residentV2OutcomeCompletedBudgetExhausted: |
| 1174 | - | result.ErrorCode = residentErrorBudgetExhausted | 1176 | + | // 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 @@ | |||
| 1 | 1 | package subagents | |
| 2 | 2 | ||
| 3 | 3 | import ( | |
| 4 | + | "context" | |
| 4 | 5 | "encoding/json" | |
| 6 | + | "errors" | |
| 5 | 7 | "os" | |
| 6 | 8 | "path/filepath" | |
| 7 | 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 | 982 | func TestRepairLegacyFalseRecoveryRefusesAmbiguousBlock(t *testing.T) { | |
| 685 | 983 | synthetic := json.RawMessage(`{"Content":[{"text":"tool interrupted by resident host restart"}],"IsError":true}`) | |
| 686 | 984 | real := json.RawMessage(`{"Content":[{"text":"ok"}],"IsError":false}`) | |
| @@ -1,7 +1,9 @@ | |||||
| 1 | package subagents | 1 | 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 { | |||
| 221 | 221 | ContextUsed int | |
| 222 | 222 | ContextMax int | |
| 223 | 223 | Subscription bool | |
| 224 | - | Budget BudgetSnapshot | |
| 225 | - | BudgetSource string | |
| 226 | 224 | } | |
| 227 | 225 | ||
| 228 | 226 | func residentSnapshot(child *ResidentChild) ResidentSnapshot { | |
| @@ -230,18 +228,12 @@ func residentSnapshot(child *ResidentChild) ResidentSnapshot { | |||
| 230 | 228 | return ResidentSnapshot{} | |
| 231 | 229 | } | |
| 232 | 230 | live := child.Live() | |
| 233 | - | budget, budgetSource := residentBudgetSnapshot(live.Usage, child.spec.BudgetLimit, child.spec.BudgetSource, child.journal.BudgetBaseline()) | |
| 234 | 231 | return ResidentSnapshot{ID: child.spec.ID, State: child.State(), Profile: child.spec.Profile, | |
| 235 | 232 | Provider: child.spec.Provider, Model: child.spec.Model, | |
| 236 | 233 | WorkspaceMode: child.spec.WorkspaceMode, Required: child.spec.Required, | |
| 237 | 234 | UpdatedAt: child.StateUpdatedAt(), TurnStartedAt: child.TurnStartedAt(), | |
| 238 | 235 | 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} | |
| 245 | 237 | } | |
| 246 | 238 | ||
| 247 | 239 | func NewResidentManager(root string, factory ResidentFactory) *ResidentManager { | |
| @@ -478,9 +470,6 @@ func (m *ResidentManager) Resume(ctx context.Context, childID, prompt string) er | |||
| 478 | 470 | m.pending[childID] = struct{}{} | |
| 479 | 471 | } | |
| 480 | 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 | 473 | if child == nil && recovered { | |
| 485 | 474 | defer func() { m.mu.Lock(); delete(m.pending, childID); m.mu.Unlock() }() | |
| 486 | 475 | journal, err := OpenResidentJournal(m.root, childID) | |
| @@ -809,7 +798,7 @@ func (m *ResidentManager) SnapshotFor(childID string) (ResidentSnapshot, bool) { | |||
| 809 | 798 | ||
| 810 | 799 | // Reconcile discovers journals left by an earlier host, repairs their bounded | |
| 811 | 800 | // 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 | |
| 813 | 802 | // and excluded from discovery. Other per-child errors are returned for callers | |
| 814 | 803 | // to surface without hiding valid sibling journals. | |
| 815 | 804 | func (m *ResidentManager) Reconcile() []error { | |
| @@ -851,10 +840,11 @@ func (m *ResidentManager) Reconcile() []error { | |||
| 851 | 840 | } | |
| 852 | 841 | childDir := filepath.Join(m.root, childID) | |
| 853 | 842 | 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 { | |
| 858 | 848 | continue | |
| 859 | 849 | } | |
| 860 | 850 | if errors.Is(reconcileErr, ErrResidentLeaseBusy) { | |
| @@ -863,12 +853,30 @@ func (m *ResidentManager) Reconcile() []error { | |||
| 863 | 853 | errs = append(errs, fmt.Errorf("resident child %s: read foreign metadata: %w", childID, readErr)) | |
| 864 | 854 | 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 | 869 | state, updatedAt := foreign.State, foreign.UpdatedAt | |
| 867 | 870 | if readErr != nil || state == "" { | |
| 868 | 871 | // Ownership begins before child.accepted is durable. Do not read | |
| 869 | 872 | // that live transcript merely to improve this transient snapshot. | |
| 870 | 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 | 880 | m.mu.Lock() | |
| 873 | 881 | m.recovered[childID] = ResidentSnapshot{ | |
| 874 | 882 | ID: childID, State: state, UpdatedAt: updatedAt, OwnedElsewhere: true, | |
| @@ -881,14 +889,12 @@ func (m *ResidentManager) Reconcile() []error { | |||
| 881 | 889 | errs = append(errs, fmt.Errorf("resident child %s: %w", childID, reconcileErr)) | |
| 882 | 890 | continue | |
| 883 | 891 | } | |
| 884 | - | budget, budgetSource := residentBudgetSnapshot(metadata.Usage, spec.BudgetLimit, spec.BudgetSource, metadata.BudgetBaseline) | |
| 885 | 892 | m.mu.Lock() | |
| 886 | 893 | if _, live := m.children[childID]; !live { | |
| 887 | 894 | m.recovered[childID] = ResidentSnapshot{ | |
| 888 | 895 | ID: childID, State: metadata.State, Profile: spec.Profile, Provider: spec.Provider, Model: spec.Model, | |
| 889 | 896 | WorkspaceMode: spec.WorkspaceMode, Required: spec.Required, UpdatedAt: metadata.UpdatedAt, | |
| 890 | 897 | Usage: metadata.Usage, ContextUsed: metadata.ContextUsed, ContextMax: metadata.ContextMax, Subscription: metadata.Subscription, | |
| 891 | - | Budget: budget, BudgetSource: budgetSource, | |
| 892 | 898 | } | |
| 893 | 899 | m.recoveredSpec[childID] = spec | |
| 894 | 900 | } | |
| @@ -221,8 +221,6 @@ type ResidentSnapshot struct { | |||||
| 221 | ContextUsed int | 221 | ContextUsed int | ||
| 222 | ContextMax int | 222 | ContextMax int | ||
| 223 | Subscription bool | 223 | 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 bounded | 799 | // Reconcile discovers journals left by an earlier host, repairs their bounded | ||
| 811 | // projections, and marks queued/running work interrupted. It never creates a | 800 | // 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 |
| 813 | // and excluded from discovery. Other per-child errors are returned for callers | 802 | // 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 without | 843 | + | // Unknown journal versions cannot be resumed. Leave them on disk |
| 855 | - | // reporting the same incompatibility at every host startup. Match the | 844 | + | // 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 | continue | 848 | 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 | continue | 854 | 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.UpdatedAt | 869 | 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 read | 871 | // 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 | continue | 890 | 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] = spec | 899 | 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) { | |||
| 55 | 55 | } | |
| 56 | 56 | } | |
| 57 | 57 | ||
| 58 | - | func TestResidentManagerCompletedAnswerAtBudgetLimitCanResume(t *testing.T) { | |
| 58 | + | func TestResidentManagerCompletedAnswerCanResume(t *testing.T) { | |
| 59 | 59 | manager := NewResidentManager(t.TempDir(), func(_ ResidentChildSpec, journal *ResidentJournal) (ResidentTurnRunner, error) { | |
| 60 | 60 | return func(context.Context, string) error { | |
| 61 | 61 | return journal.RecordAgentEvent(core.EvUsage{Cumulative: provider.Usage{InputTokens: 100}}) | |
| 62 | 62 | }, nil | |
| 63 | 63 | }) | |
| 64 | 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 | 66 | completion, cancelWait := manager.WatchCompletion(spec.ID, spec.InitialTurnID) | |
| 67 | 67 | defer cancelWait() | |
| 68 | 68 | if _, err := manager.Spawn(context.Background(), spec, "review"); err != nil { | |
| @@ -73,7 +73,7 @@ func TestResidentManagerCompletedAnswerAtBudgetLimitCanResume(t *testing.T) { | |||
| 73 | 73 | t.Fatalf("initial completion error = %v", result.Err) | |
| 74 | 74 | } | |
| 75 | 75 | snapshot, ok := manager.SnapshotFor(spec.ID) | |
| 76 | - | if !ok || snapshot.State != ResidentCompleted || snapshot.Budget.State != BudgetExceeded { | |
| 76 | + | if !ok || snapshot.State != ResidentIdle { | |
| 77 | 77 | t.Fatalf("terminal snapshot = %#v, found=%t", snapshot, ok) | |
| 78 | 78 | } | |
| 79 | 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 | 515 | func TestResidentManagerRetainsFailedWorktree(t *testing.T) { | |
| 476 | 516 | workspace := &testResidentWorkspace{dir: "/isolated", mode: WorkspaceWorktree} | |
| 477 | 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 | 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 | 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 | 630 | root := t.TempDir() | |
| 589 | - | journal, err := OpenResidentJournal(root, "legacy-budget") | |
| 631 | + | journal, err := OpenResidentJournal(root, "legacy-version") | |
| 590 | 632 | if err != nil { | |
| 591 | 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 | 636 | if err := journal.Accept(spec, "review"); err != nil { | |
| 595 | 637 | t.Fatal(err) | |
| 596 | 638 | } | |
| @@ -602,7 +644,7 @@ func TestResidentManagerReconcileSkipsIncompatibleBudgetJournal(t *testing.T) { | |||
| 602 | 644 | if err != nil { | |
| 603 | 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 | 648 | if legacy == string(data) { | |
| 607 | 649 | t.Fatal("accepted record has no journal version") | |
| 608 | 650 | } | |
| @@ -616,8 +658,8 @@ func TestResidentManagerReconcileSkipsIncompatibleBudgetJournal(t *testing.T) { | |||
| 616 | 658 | t.Fatalf("Reconcile errors = %v", errs) | |
| 617 | 659 | } | |
| 618 | 660 | } | |
| 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) | |
| 621 | 663 | } | |
| 622 | 664 | if snapshots := manager.Snapshot(); len(snapshots) != 0 { | |
| 623 | 665 | 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 | }, nil | 62 | }, 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 ( | |||
| 14 | 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 | 19 | manager := subagents.NewResidentManager(t.TempDir(), func(_ subagents.ResidentChildSpec, journal *subagents.ResidentJournal) (subagents.ResidentTurnRunner, error) { | |
| 19 | 20 | return func(context.Context, string) error { | |
| 20 | 21 | if err := journal.RecordAgentEvent(core.EvAssistantMessage{Message: provider.Message{Role: provider.RoleAssistant, Content: []provider.Content{provider.TextBlock{Text: "partial finding"}}}}); err != nil { | |
| 21 | 22 | return err | |
| 22 | 23 | } | |
| 23 | - | return subagents.ErrBudgetExceeded | |
| 24 | + | return failure | |
| 24 | 25 | }, nil | |
| 25 | 26 | }) | |
| 26 | 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 | 29 | completed, cancel := manager.WatchCompletion(spec.ID, spec.InitialTurnID) | |
| 29 | 30 | defer cancel() | |
| 30 | 31 | if _, err := manager.Spawn(t.Context(), spec, "investigate"); err != nil { | |
| @@ -37,19 +38,19 @@ func TestResidentStatusRetrievesBudgetHandoffWithoutExecution(t *testing.T) { | |||
| 37 | 38 | } | |
| 38 | 39 | status := &SubagentStatusTool{ResidentManager: manager, Enabled: func() bool { return true }} | |
| 39 | 40 | for _, include := range []bool{false, true} { | |
| 40 | - | raw := json.RawMessage(`{"agent_id":"budget-status"}`) | |
| 41 | + | raw := json.RawMessage(`{"agent_id":"failed-status"}`) | |
| 41 | 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 | 45 | result, err := status.Execute(t.Context(), raw, nil) | |
| 45 | 46 | if err != nil || result.IsError { | |
| 46 | 47 | t.Fatalf("status: %#v, %v", result, err) | |
| 47 | 48 | } | |
| 48 | 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 | 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 | 54 | t.Fatalf("result = %#v", response.Result) | |
| 54 | 55 | } | |
| 55 | 56 | } | |
| @@ -58,7 +59,7 @@ func TestResidentStatusRetrievesBudgetHandoffWithoutExecution(t *testing.T) { | |||
| 58 | 59 | t.Fatalf("missing child ID must fail: %#v, %v", result, err) | |
| 59 | 60 | } | |
| 60 | 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 | 137 | {"success", nil, "completed"}, | |
| 137 | 138 | {"failure", errors.New("worker failed"), "failed"}, | |
| 138 | - | {"budget", subagents.ErrBudgetExceeded, "budget_exhausted"}, | |
| 139 | 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 | 142 | t.Run(tc.name, func(t *testing.T) { | |
| 143 | 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 | 326 | return block.Text | |
| 327 | 327 | } | |
| 328 | 328 | ||
| 329 | - | func TestPublicResidentStatusIncludesRolloutBudget(t *testing.T) { | |
| 329 | + | func TestPublicResidentStatusOmitsBudget(t *testing.T) { | |
| 330 | 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" { | |
| 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) | |
| 335 | 339 | } | |
| 336 | 340 | } | |
| 337 | 341 | ||
| @@ -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 err | 22 | return err | ||
| 22 | } | 23 | } | ||
| 23 | - | return subagents.ErrBudgetExceeded | 24 | + | return failure |
| 24 | }, nil | 25 | }, 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.Text | 326 | 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 = `{ | |||
| 41 | 41 | func (t *SubagentResumeTool) Name() string { return SubagentResumeToolName } | |
| 42 | 42 | ||
| 43 | 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 | 47 | 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, _ | |||
| 285 | 285 | if spec.Reasoning != "" { | |
| 286 | 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 | 288 | if spec.Required { | |
| 292 | 289 | fmt.Fprintf(&sb, "required: %s\n", state) | |
| 293 | 290 | } | |
| @@ -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 { | |||
| 34 | 34 | } | |
| 35 | 35 | ||
| 36 | 36 | 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"` | |
| 47 | 45 | } | |
| 48 | 46 | ||
| 49 | 47 | const subagentStatusSchema = `{ | |
| @@ -55,7 +53,7 @@ const subagentStatusSchema = `{ | |||
| 55 | 53 | }, | |
| 56 | 54 | "include_result": { | |
| 57 | 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 | 61 | func (t *SubagentStatusTool) Name() string { return SubagentStatusToolName } | |
| 64 | 62 | ||
| 65 | 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 | 67 | func (t *SubagentStatusTool) Schema() json.RawMessage { | |
| @@ -147,12 +145,7 @@ func findResidentStatusSnapshot(snapshots []subagents.ResidentSnapshot, id strin | |||
| 147 | 145 | } | |
| 148 | 146 | ||
| 149 | 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} | |
| 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} | |
| 156 | 149 | } | |
| 157 | 150 | ||
| 158 | 151 | 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) { | ||