fix(subagents): recover budget-exhausted work with durable handoffs (#175)
* fix(subagents): preserve budget-exhausted handoffs and recovery * docs(subagents): explain budget handoff recovery * refactor(subagents): reuse persisted terminal outcomes * fix(subagents): omit raw tool payloads from automatic handoffs * fix(subagents): share synchronous completion status mapping * refactor(subagents): name persisted outcomes and error codes * fix(tui): sanitize resident handoff status text * fix(subagents): preserve cancellation and clarify result errors * fix(subagents): restore captured artifacts during reconciliation
- commit
- d92a60f57fe9b9e67302afc7b0d520220cb31bb8
- author
- bnema · · 2026-09-04 22:03 UTC
- committer
- GitHub · · 2026-09-04 22:03 UTC
- parent
- 7c9b489
- signature
- GPG Unverified
B5690EEEBB952194The key is unknown - refs
- tag: v0.29.1
25 files · +1023 -163
M README.md +1 -1
| @@ -424,7 +424,7 @@ Resident subagents run alongside the main session as independent in-process agen | |||
| 424 | 424 | ||
| 425 | 425 | 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. | |
| 426 | 426 | ||
| 427 | - | Each child receives a cumulative rollout budget equal to its resolved model's context window. The dashboard shows its usage, and zut allows normal work until the hard limit is reached. Budget-exhausted children reject follow-ups. | |
| 427 | + | 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. | |
| 428 | 428 | ||
| 429 | 429 | 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`. | |
| 430 | 430 | ||
| @@ -424,7 +424,7 @@ Resident subagents run alongside the main session as independent in-process agen | |||||
| 424 | 424 | ||||
| 425 | 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. | 425 | 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. | ||
| 426 | 426 | ||||
| 427 | - | Each child receives a cumulative rollout budget equal to its resolved model's context window. The dashboard shows its usage, and zut allows normal work until the hard limit is reached. Budget-exhausted children reject follow-ups. | 427 | + | 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. |
| 428 | 428 | ||||
| 429 | 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`. | 429 | 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`. | ||
| 430 | 430 | ||||
M docs/subagents.md +59 -14
| @@ -53,7 +53,7 @@ subagents/<child-id>/ | |||
| 53 | 53 | owner.lock # host-ownership coordination; do not modify or remove | |
| 54 | 54 | .transcript-backup-* # retained private pre-repair transcript, when repaired | |
| 55 | 55 | metadata.json # rebuildable bounded state | |
| 56 | - | result.json # latest state plus a bounded final assistant summary | |
| 56 | + | result.json # latest outcome, final summary or partial handoff, artifacts | |
| 57 | 57 | patch.diff # optional worktree capture | |
| 58 | 58 | ``` | |
| 59 | 59 | ||
| @@ -98,13 +98,40 @@ reads count at 25%. It is separate from active context-window usage and survives | |||
| 98 | 98 | follow-up turns and compaction. The TUI shows the child's current budget usage, | |
| 99 | 99 | but does not alter its instructions, tools, or output cap before exhaustion. | |
| 100 | 100 | Providers report exact input usage only after a response, so the terminal | |
| 101 | - | response can make displayed usage exceed 100%; no later request is sent. An | |
| 102 | - | exhausted child cannot accept follow-up turns; spawn a replacement when more | |
| 103 | - | work is required. | |
| 101 | + | response can make displayed usage exceed 100%; no later request is sent in that | |
| 102 | + | turn. If the worker still needs another model step, it reports | |
| 103 | + | `budget_exhausted`, not successful idle work. A final answer that completes on | |
| 104 | + | the response crossing the limit remains successful and reports `completed`. | |
| 105 | + | ||
| 106 | + | Budget exhaustion publishes a terminal completion with a host-generated partial | |
| 107 | + | handoff, capped at 16 KiB. It includes recent visible progress messages, tool | |
| 108 | + | names and error flags, workspace and history references, and recovery guidance. | |
| 109 | + | Raw tool arguments and output are omitted from this automatic handoff because | |
| 110 | + | they may contain secrets; they remain retrievable in the child's durable history. | |
| 111 | + | These are observations, not a claim that the task or verification finished; | |
| 112 | + | older or truncated details also remain in the durable transcript. Worktree | |
| 113 | + | changes are captured even on budget exhaustion, and the worktree is retained | |
| 114 | + | for recovery. Shared-workspace changes are not rolled back. | |
| 115 | + | ||
| 116 | + | Retrieve the saved result without executing a model: | |
| 117 | + | ||
| 118 | + | ```json | |
| 119 | + | {"agent_id":"<child-id>","include_result":true} | |
| 120 | + | ``` | |
| 121 | + | ||
| 122 | + | Pass this to `subagent_status`, then reconcile the history and artifacts before | |
| 123 | + | repeating any side effects. Explicit `subagent_resume` after terminal exhaustion | |
| 124 | + | opens a fresh allowance of the **same size**, retaining the session, workspace, | |
| 125 | + | and cumulative cost. This permits additional spending; it does not enlarge the | |
| 126 | + | configured limit or automatically retry the task. The allowance baseline is | |
| 127 | + | journaled and survives restart. Ordinary non-exhausted follow-ups continue using | |
| 128 | + | the existing allowance. | |
| 104 | 129 | ||
| 105 | 130 | `required: true` makes a delegated result an obligation of the parent turn. | |
| 106 | - | Failed, cancelled, and interrupted required work remains unresolved until an | |
| 107 | - | explicit successful follow-up. A host restart never retries it automatically. | |
| 131 | + | Failed, cancelled, interrupted, and budget-exhausted required work remains | |
| 132 | + | unresolved until an explicit successful follow-up. Reading a partial handoff or | |
| 133 | + | accepting a recovery prompt does not satisfy the obligation. A host restart never | |
| 134 | + | retries it automatically. | |
| 108 | 135 | ||
| 109 | 136 | ## Delegation ownership | |
| 110 | 137 | ||
| @@ -142,15 +169,20 @@ The model-facing tools retain their logical names: | |||
| 142 | 169 | task until it reaches a terminal failure, cancellation, or interruption. It | |
| 143 | 170 | returns a logical `subagent://<id>` reference. | |
| 144 | 171 | - `subagent_status` returns bounded current state for one child or the current | |
| 145 | - | set immediately. | |
| 172 | + | set immediately. With `agent_id` and `include_result: true`, it also reads the | |
| 173 | + | saved terminal result or partial handoff without model execution. Without this | |
| 174 | + | option it remains metadata-only; foreign-owned results cannot be read. | |
| 175 | + | Result-read errors distinguish foreign ownership, a missing saved result, | |
| 176 | + | and permission denial without exposing filesystem paths or saved content. | |
| 146 | 177 | - `subagent_stop` stops one live child. | |
| 147 | 178 | - `subagent_resume` accepts an explicit follow-up prompt for an existing child. | |
| 148 | - | Budget-exhausted children are terminal and reject follow-ups. | |
| 179 | + | After terminal budget exhaustion, this grants a fresh same-size allowance | |
| 180 | + | while preserving progress. Inspect the saved handoff before resuming. | |
| 149 | 181 | ||
| 150 | 182 | Child execution started by `subagent_spawn` is asynchronous unless it receives | |
| 151 | 183 | an explicit `wait` value. For an unwaited spawn, completion arrives through the | |
| 152 | - | host’s typed completion update; `subagent_status` only returns the current | |
| 153 | - | state immediately. Do not use sleep loops, repeated status calls, journal | |
| 184 | + | host’s typed completion update; `subagent_status` returns immediately and does | |
| 185 | + | not wait for completion. Do not use sleep loops, repeated status calls, journal | |
| 154 | 186 | files, or terminal UI inspection as a completion signal. | |
| 155 | 187 | Successful completions include the final visible assistant summary, capped at | |
| 156 | 188 | 256 KiB; open the child session for the complete durable transcript. | |
| @@ -175,10 +207,23 @@ private local session data. | |||
| 175 | 207 | ## Worktrees and references | |
| 176 | 208 | ||
| 177 | 209 | Shared children use the host working directory. With `isolation: "worktree"`, | |
| 178 | - | zut prepares a detached Git worktree, captures a patch and changed-file list on | |
| 179 | - | successful completion, and never applies that patch automatically. Failed or | |
| 180 | - | interrupted worktrees are retained for inspection; an idle successful worktree | |
| 181 | - | is cleaned when its child is explicitly stopped. | |
| 210 | + | zut prepares a detached Git worktree and captures a patch and changed-file list | |
| 211 | + | on successful completion or budget exhaustion. Budget-exhausted turns retain | |
| 212 | + | partial work in the worktree, along with any captured patch and changed-file | |
| 213 | + | list. zut never applies a captured patch automatically. Failed or interrupted | |
| 214 | + | worktrees are retained for inspection; an idle successful worktree is cleaned | |
| 215 | + | when its child is explicitly stopped. | |
| 216 | + | ||
| 217 | + | Use `subagent_status` with `agent_id` and `include_result: true` to retrieve the | |
| 218 | + | saved handoff, `changed_files`, and `patch_ref` when a patch was captured. The | |
| 219 | + | patch bytes are stored in `subagents/<child-id>/patch.diff` under the managed | |
| 220 | + | state root shown above; logical references are identifiers, not filesystem | |
| 221 | + | paths. Inspect the retained worktree and patch before resuming or applying | |
| 222 | + | partial changes. | |
| 223 | + | ||
| 224 | + | New terminal journal records also save the capture metadata, so reconciliation | |
| 225 | + | can restore it if `result.json` is missing or corrupt. Older records remain | |
| 226 | + | readable, but cannot reconstruct artifact metadata they never recorded. | |
| 182 | 227 | ||
| 183 | 228 | Logical references avoid exposing runtime paths: | |
| 184 | 229 | ||
| @@ -53,7 +53,7 @@ subagents/<child-id>/ | |||||
| 53 | owner.lock # host-ownership coordination; do not modify or remove | 53 | owner.lock # host-ownership coordination; do not modify or remove | ||
| 54 | .transcript-backup-* # retained private pre-repair transcript, when repaired | 54 | .transcript-backup-* # retained private pre-repair transcript, when repaired | ||
| 55 | metadata.json # rebuildable bounded state | 55 | metadata.json # rebuildable bounded state | ||
| 56 | - | result.json # latest state plus a bounded final assistant summary | 56 | + | result.json # latest outcome, final summary or partial handoff, artifacts |
| 57 | patch.diff # optional worktree capture | 57 | patch.diff # optional worktree capture | ||
| 58 | ``` | 58 | ``` | ||
| 59 | 59 | ||||
| @@ -98,13 +98,40 @@ reads count at 25%. It is separate from active context-window usage and survives | |||||
| 98 | follow-up turns and compaction. The TUI shows the child's current budget usage, | 98 | follow-up turns and compaction. The TUI shows the child's current budget usage, | ||
| 99 | but does not alter its instructions, tools, or output cap before exhaustion. | 99 | but does not alter its instructions, tools, or output cap before exhaustion. | ||
| 100 | Providers report exact input usage only after a response, so the terminal | 100 | Providers report exact input usage only after a response, so the terminal | ||
| 101 | - | response can make displayed usage exceed 100%; no later request is sent. An | 101 | + | response can make displayed usage exceed 100%; no later request is sent in that |
| 102 | - | exhausted child cannot accept follow-up turns; spawn a replacement when more | 102 | + | turn. If the worker still needs another model step, it reports |
| 103 | - | work is required. | 103 | + | `budget_exhausted`, not successful idle work. A final answer that completes on |
| 104 | + | the response crossing the limit remains successful and reports `completed`. | |||
| 105 | + | ||||
| 106 | + | Budget exhaustion publishes a terminal completion with a host-generated partial | |||
| 107 | + | handoff, capped at 16 KiB. It includes recent visible progress messages, tool | |||
| 108 | + | names and error flags, workspace and history references, and recovery guidance. | |||
| 109 | + | Raw tool arguments and output are omitted from this automatic handoff because | |||
| 110 | + | they may contain secrets; they remain retrievable in the child's durable history. | |||
| 111 | + | These are observations, not a claim that the task or verification finished; | |||
| 112 | + | older or truncated details also remain in the durable transcript. Worktree | |||
| 113 | + | changes are captured even on budget exhaustion, and the worktree is retained | |||
| 114 | + | for recovery. Shared-workspace changes are not rolled back. | |||
| 115 | + | ||||
| 116 | + | Retrieve the saved result without executing a model: | |||
| 117 | + | ||||
| 118 | + | ```json | |||
| 119 | + | {"agent_id":"<child-id>","include_result":true} | |||
| 120 | + | ``` | |||
| 121 | + | ||||
| 122 | + | Pass this to `subagent_status`, then reconcile the history and artifacts before | |||
| 123 | + | repeating any side effects. Explicit `subagent_resume` after terminal exhaustion | |||
| 124 | + | opens a fresh allowance of the **same size**, retaining the session, workspace, | |||
| 125 | + | and cumulative cost. This permits additional spending; it does not enlarge the | |||
| 126 | + | configured limit or automatically retry the task. The allowance baseline is | |||
| 127 | + | journaled and survives restart. Ordinary non-exhausted follow-ups continue using | |||
| 128 | + | the existing allowance. | |||
| 104 | 129 | ||||
| 105 | `required: true` makes a delegated result an obligation of the parent turn. | 130 | `required: true` makes a delegated result an obligation of the parent turn. | ||
| 106 | - | Failed, cancelled, and interrupted required work remains unresolved until an | 131 | + | Failed, cancelled, interrupted, and budget-exhausted required work remains |
| 107 | - | explicit successful follow-up. A host restart never retries it automatically. | 132 | + | unresolved until an explicit successful follow-up. Reading a partial handoff or |
| 133 | + | accepting a recovery prompt does not satisfy the obligation. A host restart never | |||
| 134 | + | retries it automatically. | |||
| 108 | 135 | ||||
| 109 | ## Delegation ownership | 136 | ## Delegation ownership | ||
| 110 | 137 | ||||
| @@ -142,15 +169,20 @@ The model-facing tools retain their logical names: | |||||
| 142 | task until it reaches a terminal failure, cancellation, or interruption. It | 169 | task until it reaches a terminal failure, cancellation, or interruption. It | ||
| 143 | returns a logical `subagent://<id>` reference. | 170 | returns a logical `subagent://<id>` reference. | ||
| 144 | - `subagent_status` returns bounded current state for one child or the current | 171 | - `subagent_status` returns bounded current state for one child or the current | ||
| 145 | - | set immediately. | 172 | + | set immediately. With `agent_id` and `include_result: true`, it also reads the |
| 173 | + | saved terminal result or partial handoff without model execution. Without this | |||
| 174 | + | option it remains metadata-only; foreign-owned results cannot be read. | |||
| 175 | + | Result-read errors distinguish foreign ownership, a missing saved result, | |||
| 176 | + | and permission denial without exposing filesystem paths or saved content. | |||
| 146 | - `subagent_stop` stops one live child. | 177 | - `subagent_stop` stops one live child. | ||
| 147 | - `subagent_resume` accepts an explicit follow-up prompt for an existing child. | 178 | - `subagent_resume` accepts an explicit follow-up prompt for an existing child. | ||
| 148 | - | Budget-exhausted children are terminal and reject follow-ups. | 179 | + | After terminal budget exhaustion, this grants a fresh same-size allowance |
| 180 | + | while preserving progress. Inspect the saved handoff before resuming. | |||
| 149 | 181 | ||||
| 150 | Child execution started by `subagent_spawn` is asynchronous unless it receives | 182 | Child execution started by `subagent_spawn` is asynchronous unless it receives | ||
| 151 | an explicit `wait` value. For an unwaited spawn, completion arrives through the | 183 | an explicit `wait` value. For an unwaited spawn, completion arrives through the | ||
| 152 | - | host’s typed completion update; `subagent_status` only returns the current | 184 | + | host’s typed completion update; `subagent_status` returns immediately and does |
| 153 | - | state immediately. Do not use sleep loops, repeated status calls, journal | 185 | + | not wait for completion. Do not use sleep loops, repeated status calls, journal |
| 154 | files, or terminal UI inspection as a completion signal. | 186 | files, or terminal UI inspection as a completion signal. | ||
| 155 | Successful completions include the final visible assistant summary, capped at | 187 | Successful completions include the final visible assistant summary, capped at | ||
| 156 | 256 KiB; open the child session for the complete durable transcript. | 188 | 256 KiB; open the child session for the complete durable transcript. | ||
| @@ -175,10 +207,23 @@ private local session data. | |||||
| 175 | ## Worktrees and references | 207 | ## Worktrees and references | ||
| 176 | 208 | ||||
| 177 | Shared children use the host working directory. With `isolation: "worktree"`, | 209 | Shared children use the host working directory. With `isolation: "worktree"`, | ||
| 178 | - | zut prepares a detached Git worktree, captures a patch and changed-file list on | 210 | + | zut prepares a detached Git worktree and captures a patch and changed-file list |
| 179 | - | successful completion, and never applies that patch automatically. Failed or | 211 | + | on successful completion or budget exhaustion. Budget-exhausted turns retain |
| 180 | - | interrupted worktrees are retained for inspection; an idle successful worktree | 212 | + | partial work in the worktree, along with any captured patch and changed-file |
| 181 | - | is cleaned when its child is explicitly stopped. | 213 | + | list. zut never applies a captured patch automatically. Failed or interrupted |
| 214 | + | worktrees are retained for inspection; an idle successful worktree is cleaned | |||
| 215 | + | when its child is explicitly stopped. | |||
| 216 | + | ||||
| 217 | + | Use `subagent_status` with `agent_id` and `include_result: true` to retrieve the | |||
| 218 | + | saved handoff, `changed_files`, and `patch_ref` when a patch was captured. The | |||
| 219 | + | patch bytes are stored in `subagents/<child-id>/patch.diff` under the managed | |||
| 220 | + | state root shown above; logical references are identifiers, not filesystem | |||
| 221 | + | paths. Inspect the retained worktree and patch before resuming or applying | |||
| 222 | + | partial changes. | |||
| 223 | + | ||||
| 224 | + | New terminal journal records also save the capture metadata, so reconciliation | |||
| 225 | + | can restore it if `result.json` is missing or corrupt. Older records remain | |||
| 226 | + | readable, but cannot reconstruct artifact metadata they never recorded. | |||
| 182 | 227 | ||||
| 183 | Logical references avoid exposing runtime paths: | 228 | Logical references avoid exposing runtime paths: | ||
| 184 | 229 | ||||
M packages/agent/modes/interactive_resident_subagents.go +1 -9
| @@ -2,7 +2,6 @@ package modes | |||
| 2 | 2 | ||
| 3 | 3 | import ( | |
| 4 | 4 | "context" | |
| 5 | - | "errors" | |
| 6 | 5 | "fmt" | |
| 7 | 6 | "os" | |
| 8 | 7 | "path/filepath" | |
| @@ -33,14 +32,7 @@ func (i *Interactive) ReportResidentSubagent(completion subagents.ResidentComple | |||
| 33 | 32 | if i == nil { | |
| 34 | 33 | return | |
| 35 | 34 | } | |
| 36 | - | status, errText := "completed", "" | |
| 37 | - | if completion.Err != nil { | |
| 38 | - | status, errText = "failed", completion.Err.Error() | |
| 39 | - | if errors.Is(completion.Err, context.Canceled) { | |
| 40 | - | status = "interrupted" | |
| 41 | - | } | |
| 42 | - | } | |
| 43 | - | if !i.ensureCompletionTracker().Report(subagents.Completion{AgentID: completion.ChildID, TurnID: completion.TurnID, Status: status, Task: completion.Task, Error: errText, Summary: completion.Summary}) { | |
| 35 | + | if !i.ensureCompletionTracker().Report(completion.Completion()) { | |
| 44 | 36 | return | |
| 45 | 37 | } | |
| 46 | 38 | i.reloadOpenResidentChildSession(completion.ChildID) | |
| @@ -2,7 +2,6 @@ package modes | |||||
| 2 | 2 | ||||
| 3 | import ( | 3 | import ( | ||
| 4 | "context" | 4 | "context" | ||
| 5 | - | "errors" | |||
| 6 | "fmt" | 5 | "fmt" | ||
| 7 | "os" | 6 | "os" | ||
| 8 | "path/filepath" | 7 | "path/filepath" | ||
| @@ -33,14 +32,7 @@ func (i *Interactive) ReportResidentSubagent(completion subagents.ResidentComple | |||||
| 33 | if i == nil { | 32 | if i == nil { | ||
| 34 | return | 33 | return | ||
| 35 | } | 34 | } | ||
| 36 | - | status, errText := "completed", "" | 35 | + | if !i.ensureCompletionTracker().Report(completion.Completion()) { |
| 37 | - | if completion.Err != nil { | |||
| 38 | - | status, errText = "failed", completion.Err.Error() | |||
| 39 | - | if errors.Is(completion.Err, context.Canceled) { | |||
| 40 | - | status = "interrupted" | |||
| 41 | - | } | |||
| 42 | - | } | |||
| 43 | - | if !i.ensureCompletionTracker().Report(subagents.Completion{AgentID: completion.ChildID, TurnID: completion.TurnID, Status: status, Task: completion.Task, Error: errText, Summary: completion.Summary}) { | |||
| 44 | return | 36 | return | ||
| 45 | } | 37 | } | ||
| 46 | i.reloadOpenResidentChildSession(completion.ChildID) | 38 | i.reloadOpenResidentChildSession(completion.ChildID) | ||
M packages/agent/modes/resident_subagents_dialog_test.go +10 -0
| @@ -100,6 +100,16 @@ func TestResidentResultStatusIncludesFinalSummary(t *testing.T) { | |||
| 100 | 100 | } | |
| 101 | 101 | } | |
| 102 | 102 | ||
| 103 | + | func TestResidentResultStatusSanitizesHandoff(t *testing.T) { | |
| 104 | + | got := residentResultStatus("child", subagents.ResidentResult{ | |
| 105 | + | State: subagents.ResidentBudgetExhausted, | |
| 106 | + | Handoff: "Workspace: /tmp/\x1b[31mrepo\x1b[0m\x1b]52;c;secret\a\r\x00", | |
| 107 | + | }) | |
| 108 | + | if got != "budget_exhausted result child: Workspace: /tmp/repo" { | |
| 109 | + | t.Fatalf("result status = %q", got) | |
| 110 | + | } | |
| 111 | + | } | |
| 112 | + | ||
| 103 | 113 | func TestResidentSubagentsDialogRefreshAllowsNilReceiver(t *testing.T) { | |
| 104 | 114 | var dialog *residentSubagentsDialog | |
| 105 | 115 | dialog.refresh(1) | |
| @@ -100,6 +100,16 @@ func TestResidentResultStatusIncludesFinalSummary(t *testing.T) { | |||||
| 100 | } | 100 | } | ||
| 101 | } | 101 | } | ||
| 102 | 102 | ||||
| 103 | + | func TestResidentResultStatusSanitizesHandoff(t *testing.T) { | |||
| 104 | + | got := residentResultStatus("child", subagents.ResidentResult{ | |||
| 105 | + | State: subagents.ResidentBudgetExhausted, | |||
| 106 | + | Handoff: "Workspace: /tmp/\x1b[31mrepo\x1b[0m\x1b]52;c;secret\a\r\x00", | |||
| 107 | + | }) | |||
| 108 | + | if got != "budget_exhausted result child: Workspace: /tmp/repo" { | |||
| 109 | + | t.Fatalf("result status = %q", got) | |||
| 110 | + | } | |||
| 111 | + | } | |||
| 112 | + | ||||
| 103 | func TestResidentSubagentsDialogRefreshAllowsNilReceiver(t *testing.T) { | 113 | func TestResidentSubagentsDialogRefreshAllowsNilReceiver(t *testing.T) { | ||
| 104 | var dialog *residentSubagentsDialog | 114 | var dialog *residentSubagentsDialog | ||
| 105 | dialog.refresh(1) | 115 | dialog.refresh(1) | ||
M packages/agent/modes/subagents_slash.go +5 -1
| @@ -89,7 +89,11 @@ func (i *Interactive) runResidentSubagents(ctx context.Context, args []string) { | |||
| 89 | 89 | ||
| 90 | 90 | func residentResultStatus(id string, result subagents.ResidentResult) string { | |
| 91 | 91 | status := fmt.Sprintf("%s result %s", residentDisplayState(result.State), id) | |
| 92 | - | if summary := truncateStatus(result.Summary, 240); summary != "" { | |
| 92 | + | text := result.Summary | |
| 93 | + | if result.Handoff != "" { | |
| 94 | + | text = result.Handoff | |
| 95 | + | } | |
| 96 | + | if summary := truncateStatus(sanitizeSessionTreeText(text), 240); summary != "" { | |
| 93 | 97 | status += ": " + summary | |
| 94 | 98 | } | |
| 95 | 99 | return status | |
| @@ -89,7 +89,11 @@ func (i *Interactive) runResidentSubagents(ctx context.Context, args []string) { | |||||
| 89 | 89 | ||||
| 90 | func residentResultStatus(id string, result subagents.ResidentResult) string { | 90 | func residentResultStatus(id string, result subagents.ResidentResult) string { | ||
| 91 | status := fmt.Sprintf("%s result %s", residentDisplayState(result.State), id) | 91 | status := fmt.Sprintf("%s result %s", residentDisplayState(result.State), id) | ||
| 92 | - | if summary := truncateStatus(result.Summary, 240); summary != "" { | 92 | + | text := result.Summary |
| 93 | + | if result.Handoff != "" { | |||
| 94 | + | text = result.Handoff | |||
| 95 | + | } | |||
| 96 | + | if summary := truncateStatus(sanitizeSessionTreeText(text), 240); summary != "" { | |||
| 93 | status += ": " + summary | 97 | status += ": " + summary | ||
| 94 | } | 98 | } | ||
| 95 | return status | 99 | return status | ||
M packages/agent/orchestration_modes.go +1 -9
| @@ -209,15 +209,7 @@ func newOrchestratedRuntime(_ context.Context, args Args, r Resolved, cfg Config | |||
| 209 | 209 | Policy: subagentPolicyFromConfig(cfg.Subagents), | |
| 210 | 210 | WebSearchPolicy: webSearchPolicyForRegistry(r.WebSearchPolicy, r.ToolRegistry), | |
| 211 | 211 | ResidentCompletion: func(completion subagents.ResidentCompletion) { | |
| 212 | - | status := "completed" | |
| 213 | - | errText := "" | |
| 214 | - | if completion.Err != nil { | |
| 215 | - | status, errText = "failed", completion.Err.Error() | |
| 216 | - | if errors.Is(completion.Err, context.Canceled) { | |
| 217 | - | status = "interrupted" | |
| 218 | - | } | |
| 219 | - | } | |
| 220 | - | tracker.Report(subagents.Completion{AgentID: completion.ChildID, TurnID: completion.TurnID, Status: status, Task: completion.Task, Error: errText, Summary: completion.Summary}) | |
| 212 | + | tracker.Report(completion.Completion()) | |
| 221 | 213 | }, | |
| 222 | 214 | OnResidentSpawned: func(spec subagents.ResidentChildSpec, turnID, _ string) { tracker.TrackResident(spec.ID, turnID) }, | |
| 223 | 215 | }) | |
| @@ -209,15 +209,7 @@ func newOrchestratedRuntime(_ context.Context, args Args, r Resolved, cfg Config | |||||
| 209 | Policy: subagentPolicyFromConfig(cfg.Subagents), | 209 | Policy: subagentPolicyFromConfig(cfg.Subagents), | ||
| 210 | WebSearchPolicy: webSearchPolicyForRegistry(r.WebSearchPolicy, r.ToolRegistry), | 210 | WebSearchPolicy: webSearchPolicyForRegistry(r.WebSearchPolicy, r.ToolRegistry), | ||
| 211 | ResidentCompletion: func(completion subagents.ResidentCompletion) { | 211 | ResidentCompletion: func(completion subagents.ResidentCompletion) { | ||
| 212 | - | status := "completed" | 212 | + | tracker.Report(completion.Completion()) |
| 213 | - | errText := "" | |||
| 214 | - | if completion.Err != nil { | |||
| 215 | - | status, errText = "failed", completion.Err.Error() | |||
| 216 | - | if errors.Is(completion.Err, context.Canceled) { | |||
| 217 | - | status = "interrupted" | |||
| 218 | - | } | |||
| 219 | - | } | |||
| 220 | - | tracker.Report(subagents.Completion{AgentID: completion.ChildID, TurnID: completion.TurnID, Status: status, Task: completion.Task, Error: errText, Summary: completion.Summary}) | |||
| 221 | }, | 213 | }, | ||
| 222 | OnResidentSpawned: func(spec subagents.ResidentChildSpec, turnID, _ string) { tracker.TrackResident(spec.ID, turnID) }, | 214 | OnResidentSpawned: func(spec subagents.ResidentChildSpec, turnID, _ string) { tracker.TrackResident(spec.ID, turnID) }, | ||
| 223 | }) | 215 | }) | ||
M packages/agent/subagent_factory.go +7 -1
| @@ -6,6 +6,7 @@ import ( | |||
| 6 | 6 | "fmt" | |
| 7 | 7 | "strings" | |
| 8 | 8 | "sync" | |
| 9 | + | "sync/atomic" | |
| 9 | 10 | ||
| 10 | 11 | "github.com/bnema/zut/packages/agent/subagents" | |
| 11 | 12 | "github.com/bnema/zut/packages/agent/tools" | |
| @@ -82,10 +83,15 @@ func newResidentChildRunner(args Args, spec subagents.ResidentChildSpec, journal | |||
| 82 | 83 | } | |
| 83 | 84 | turnCtx, cancel := context.WithCancel(ctx) | |
| 84 | 85 | defer cancel() | |
| 86 | + | budget.SetBaseline(journal.BudgetBaseline()) | |
| 85 | 87 | startedExhausted := budget.Snapshot().State == subagents.BudgetExceeded | |
| 88 | + | var budgetStopped atomic.Bool | |
| 86 | 89 | var journalErr error | |
| 87 | 90 | var journalMu sync.Mutex | |
| 88 | 91 | 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 | + | } | |
| 89 | 95 | if usage, ok := event.(core.EvUsage); ok { | |
| 90 | 96 | budget.Observe(usage.Cumulative) | |
| 91 | 97 | } | |
| @@ -104,7 +110,7 @@ func newResidentChildRunner(args Args, spec subagents.ResidentChildSpec, journal | |||
| 104 | 110 | return fmt.Errorf("persist resident child transcript: %w", journalErr) | |
| 105 | 111 | } | |
| 106 | 112 | if budget.Snapshot().State == subagents.BudgetExceeded { | |
| 107 | - | if !startedExhausted && err == nil { | |
| 113 | + | if !startedExhausted && !budgetStopped.Load() && err == nil { | |
| 108 | 114 | return nil | |
| 109 | 115 | } | |
| 110 | 116 | return errors.Join(err, subagents.ErrBudgetExceeded) | |
| @@ -6,6 +6,7 @@ import ( | |||||
| 6 | "fmt" | 6 | "fmt" | ||
| 7 | "strings" | 7 | "strings" | ||
| 8 | "sync" | 8 | "sync" | ||
| 9 | + | "sync/atomic" | |||
| 9 | 10 | ||||
| 10 | "github.com/bnema/zut/packages/agent/subagents" | 11 | "github.com/bnema/zut/packages/agent/subagents" | ||
| 11 | "github.com/bnema/zut/packages/agent/tools" | 12 | "github.com/bnema/zut/packages/agent/tools" | ||
| @@ -82,10 +83,15 @@ func newResidentChildRunner(args Args, spec subagents.ResidentChildSpec, journal | |||||
| 82 | } | 83 | } | ||
| 83 | turnCtx, cancel := context.WithCancel(ctx) | 84 | turnCtx, cancel := context.WithCancel(ctx) | ||
| 84 | defer cancel() | 85 | defer cancel() | ||
| 86 | + | budget.SetBaseline(journal.BudgetBaseline()) | |||
| 85 | startedExhausted := budget.Snapshot().State == subagents.BudgetExceeded | 87 | startedExhausted := budget.Snapshot().State == subagents.BudgetExceeded | ||
| 88 | + | var budgetStopped atomic.Bool | |||
| 86 | var journalErr error | 89 | var journalErr error | ||
| 87 | var journalMu sync.Mutex | 90 | var journalMu sync.Mutex | ||
| 88 | err := agent.Prompt(turnCtx, prompt, nil, func(event core.AgentEvent) { | 91 | 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 | + | } | |||
| 89 | if usage, ok := event.(core.EvUsage); ok { | 95 | if usage, ok := event.(core.EvUsage); ok { | ||
| 90 | budget.Observe(usage.Cumulative) | 96 | budget.Observe(usage.Cumulative) | ||
| 91 | } | 97 | } | ||
| @@ -104,7 +110,7 @@ func newResidentChildRunner(args Args, spec subagents.ResidentChildSpec, journal | |||||
| 104 | return fmt.Errorf("persist resident child transcript: %w", journalErr) | 110 | return fmt.Errorf("persist resident child transcript: %w", journalErr) | ||
| 105 | } | 111 | } | ||
| 106 | if budget.Snapshot().State == subagents.BudgetExceeded { | 112 | if budget.Snapshot().State == subagents.BudgetExceeded { | ||
| 107 | - | if !startedExhausted && err == nil { | 113 | + | if !startedExhausted && !budgetStopped.Load() && err == nil { |
| 108 | return nil | 114 | return nil | ||
| 109 | } | 115 | } | ||
| 110 | return errors.Join(err, subagents.ErrBudgetExceeded) | 116 | return errors.Join(err, subagents.ErrBudgetExceeded) | ||
M packages/agent/subagent_factory_test.go +87 -0
| @@ -1,6 +1,7 @@ | |||
| 1 | 1 | package agent | |
| 2 | 2 | ||
| 3 | 3 | import ( | |
| 4 | + | "context" | |
| 4 | 5 | "encoding/json" | |
| 5 | 6 | "errors" | |
| 6 | 7 | "fmt" | |
| @@ -9,6 +10,7 @@ import ( | |||
| 9 | 10 | "strings" | |
| 10 | 11 | "sync/atomic" | |
| 11 | 12 | "testing" | |
| 13 | + | "time" | |
| 12 | 14 | ||
| 13 | 15 | "github.com/bnema/zut/packages/agent/subagents" | |
| 14 | 16 | "github.com/bnema/zut/packages/core" | |
| @@ -146,6 +148,91 @@ func TestResidentChildRunnerTerminatesAtBudgetLimit(t *testing.T) { | |||
| 146 | 148 | } | |
| 147 | 149 | } | |
| 148 | 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) | |
| 175 | + | } | |
| 176 | + | } | |
| 177 | + | ||
| 178 | + | func TestResidentChildRunnerResumesWithSameSizeAllowance(t *testing.T) { | |
| 179 | + | var requests atomic.Int32 | |
| 180 | + | server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { | |
| 181 | + | request := requests.Add(1) | |
| 182 | + | w.Header().Set("content-type", "text/event-stream") | |
| 183 | + | if request == 1 { | |
| 184 | + | writeOpenAIChunk(t, w, map[string]any{ | |
| 185 | + | "choices": []any{map[string]any{"index": 0, "delta": map[string]string{"content": "first result"}, "finish_reason": "stop"}}, | |
| 186 | + | "usage": map[string]int{"prompt_tokens": 1200, "completion_tokens": 0}, | |
| 187 | + | }) | |
| 188 | + | return | |
| 189 | + | } | |
| 190 | + | var body map[string]any | |
| 191 | + | if err := json.NewDecoder(r.Body).Decode(&body); err != nil { | |
| 192 | + | t.Error(err) | |
| 193 | + | } | |
| 194 | + | encoded, _ := json.Marshal(body["messages"]) | |
| 195 | + | if !strings.Contains(string(encoded), "first result") { | |
| 196 | + | t.Errorf("resume lost prior messages: %s", encoded) | |
| 197 | + | } | |
| 198 | + | writeOpenAIChunk(t, w, map[string]any{ | |
| 199 | + | "choices": []any{map[string]any{"index": 0, "delta": map[string]string{"content": "recovered result"}, "finish_reason": "stop"}}, | |
| 200 | + | "usage": map[string]int{"prompt_tokens": 20, "completion_tokens": 10}, | |
| 201 | + | }) | |
| 202 | + | })) | |
| 203 | + | defer server.Close() | |
| 204 | + | cwd := t.TempDir() | |
| 205 | + | manager := subagents.NewResidentManager(t.TempDir(), func(spec subagents.ResidentChildSpec, journal *subagents.ResidentJournal) (subagents.ResidentTurnRunner, error) { | |
| 206 | + | return newResidentChildRunner(Args{Provider: "openai", Model: "gpt-4o", APIKey: "synthetic", BaseURL: server.URL, CWD: cwd, NoContextFiles: true, NoSkill: true}, spec, journal) | |
| 207 | + | }) | |
| 208 | + | t.Cleanup(func() { _ = manager.Close(context.Background()) }) | |
| 209 | + | completed := make(chan subagents.ResidentCompletion, 2) | |
| 210 | + | 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"} | |
| 212 | + | if _, err := manager.Spawn(t.Context(), spec, "review"); err != nil { | |
| 213 | + | t.Fatal(err) | |
| 214 | + | } | |
| 215 | + | for turn := 0; turn < 2; turn++ { | |
| 216 | + | select { | |
| 217 | + | case completion := <-completed: | |
| 218 | + | if completion.Err != nil { | |
| 219 | + | t.Fatal(completion.Err) | |
| 220 | + | } | |
| 221 | + | case <-time.After(5 * time.Second): | |
| 222 | + | t.Fatal("no completion") | |
| 223 | + | } | |
| 224 | + | if turn == 0 { | |
| 225 | + | if err := manager.Resume(t.Context(), spec.ID, "follow up using previous results"); err != nil { | |
| 226 | + | t.Fatal(err) | |
| 227 | + | } | |
| 228 | + | } | |
| 229 | + | } | |
| 230 | + | snapshot, _ := manager.SnapshotFor(spec.ID) | |
| 231 | + | if requests.Load() != 2 || snapshot.Budget.Used != 30 || snapshot.Budget.Limit != 1000 || snapshot.Usage.InputTokens != 1220 { | |
| 232 | + | t.Fatalf("requests=%d, snapshot=%#v", requests.Load(), snapshot) | |
| 233 | + | } | |
| 234 | + | } | |
| 235 | + | ||
| 149 | 236 | func newBudgetedResidentTestRunner(t *testing.T, baseURL string, limit int64) (subagents.ResidentTurnRunner, *subagents.ResidentJournal) { | |
| 150 | 237 | t.Helper() | |
| 151 | 238 | spec := subagents.ResidentChildSpec{ | |
| @@ -1,6 +1,7 @@ | |||||
| 1 | package agent | 1 | package agent | ||
| 2 | 2 | ||||
| 3 | import ( | 3 | import ( | ||
| 4 | + | "context" | |||
| 4 | "encoding/json" | 5 | "encoding/json" | ||
| 5 | "errors" | 6 | "errors" | ||
| 6 | "fmt" | 7 | "fmt" | ||
| @@ -9,6 +10,7 @@ import ( | |||||
| 9 | "strings" | 10 | "strings" | ||
| 10 | "sync/atomic" | 11 | "sync/atomic" | ||
| 11 | "testing" | 12 | "testing" | ||
| 13 | + | "time" | |||
| 12 | 14 | ||||
| 13 | "github.com/bnema/zut/packages/agent/subagents" | 15 | "github.com/bnema/zut/packages/agent/subagents" | ||
| 14 | "github.com/bnema/zut/packages/core" | 16 | "github.com/bnema/zut/packages/core" | ||
| @@ -146,6 +148,91 @@ func TestResidentChildRunnerTerminatesAtBudgetLimit(t *testing.T) { | |||||
| 146 | } | 148 | } | ||
| 147 | } | 149 | } | ||
| 148 | 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) | |||
| 175 | + | } | |||
| 176 | + | } | |||
| 177 | + | ||||
| 178 | + | func TestResidentChildRunnerResumesWithSameSizeAllowance(t *testing.T) { | |||
| 179 | + | var requests atomic.Int32 | |||
| 180 | + | server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { | |||
| 181 | + | request := requests.Add(1) | |||
| 182 | + | w.Header().Set("content-type", "text/event-stream") | |||
| 183 | + | if request == 1 { | |||
| 184 | + | writeOpenAIChunk(t, w, map[string]any{ | |||
| 185 | + | "choices": []any{map[string]any{"index": 0, "delta": map[string]string{"content": "first result"}, "finish_reason": "stop"}}, | |||
| 186 | + | "usage": map[string]int{"prompt_tokens": 1200, "completion_tokens": 0}, | |||
| 187 | + | }) | |||
| 188 | + | return | |||
| 189 | + | } | |||
| 190 | + | var body map[string]any | |||
| 191 | + | if err := json.NewDecoder(r.Body).Decode(&body); err != nil { | |||
| 192 | + | t.Error(err) | |||
| 193 | + | } | |||
| 194 | + | encoded, _ := json.Marshal(body["messages"]) | |||
| 195 | + | if !strings.Contains(string(encoded), "first result") { | |||
| 196 | + | t.Errorf("resume lost prior messages: %s", encoded) | |||
| 197 | + | } | |||
| 198 | + | writeOpenAIChunk(t, w, map[string]any{ | |||
| 199 | + | "choices": []any{map[string]any{"index": 0, "delta": map[string]string{"content": "recovered result"}, "finish_reason": "stop"}}, | |||
| 200 | + | "usage": map[string]int{"prompt_tokens": 20, "completion_tokens": 10}, | |||
| 201 | + | }) | |||
| 202 | + | })) | |||
| 203 | + | defer server.Close() | |||
| 204 | + | cwd := t.TempDir() | |||
| 205 | + | manager := subagents.NewResidentManager(t.TempDir(), func(spec subagents.ResidentChildSpec, journal *subagents.ResidentJournal) (subagents.ResidentTurnRunner, error) { | |||
| 206 | + | return newResidentChildRunner(Args{Provider: "openai", Model: "gpt-4o", APIKey: "synthetic", BaseURL: server.URL, CWD: cwd, NoContextFiles: true, NoSkill: true}, spec, journal) | |||
| 207 | + | }) | |||
| 208 | + | t.Cleanup(func() { _ = manager.Close(context.Background()) }) | |||
| 209 | + | completed := make(chan subagents.ResidentCompletion, 2) | |||
| 210 | + | 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"} | |||
| 212 | + | if _, err := manager.Spawn(t.Context(), spec, "review"); err != nil { | |||
| 213 | + | t.Fatal(err) | |||
| 214 | + | } | |||
| 215 | + | for turn := 0; turn < 2; turn++ { | |||
| 216 | + | select { | |||
| 217 | + | case completion := <-completed: | |||
| 218 | + | if completion.Err != nil { | |||
| 219 | + | t.Fatal(completion.Err) | |||
| 220 | + | } | |||
| 221 | + | case <-time.After(5 * time.Second): | |||
| 222 | + | t.Fatal("no completion") | |||
| 223 | + | } | |||
| 224 | + | if turn == 0 { | |||
| 225 | + | if err := manager.Resume(t.Context(), spec.ID, "follow up using previous results"); err != nil { | |||
| 226 | + | t.Fatal(err) | |||
| 227 | + | } | |||
| 228 | + | } | |||
| 229 | + | } | |||
| 230 | + | snapshot, _ := manager.SnapshotFor(spec.ID) | |||
| 231 | + | if requests.Load() != 2 || snapshot.Budget.Used != 30 || snapshot.Budget.Limit != 1000 || snapshot.Usage.InputTokens != 1220 { | |||
| 232 | + | t.Fatalf("requests=%d, snapshot=%#v", requests.Load(), snapshot) | |||
| 233 | + | } | |||
| 234 | + | } | |||
| 235 | + | ||||
| 149 | func newBudgetedResidentTestRunner(t *testing.T, baseURL string, limit int64) (subagents.ResidentTurnRunner, *subagents.ResidentJournal) { | 236 | func newBudgetedResidentTestRunner(t *testing.T, baseURL string, limit int64) (subagents.ResidentTurnRunner, *subagents.ResidentJournal) { | ||
| 150 | t.Helper() | 237 | t.Helper() | ||
| 151 | spec := subagents.ResidentChildSpec{ | 238 | spec := subagents.ResidentChildSpec{ | ||
M packages/agent/subagents/budget.go +25 -3
| @@ -54,7 +54,14 @@ func BudgetSnapshotFor(usage provider.Usage, limit int64) BudgetSnapshot { | |||
| 54 | 54 | if limit <= 0 { | |
| 55 | 55 | return BudgetSnapshot{} | |
| 56 | 56 | } | |
| 57 | - | used := WeightedBudgetUsage(usage) | |
| 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) | |
| 58 | 65 | percent := int(math.Floor(float64(used) * 100 / float64(limit))) | |
| 59 | 66 | return BudgetSnapshot{Used: used, Limit: limit, Percent: percent, State: budgetState(used, limit)} | |
| 60 | 67 | } | |
| @@ -73,11 +80,13 @@ func budgetState(used, limit int64) BudgetState { | |||
| 73 | 80 | type RolloutBudget struct { | |
| 74 | 81 | mu sync.Mutex | |
| 75 | 82 | limit int64 | |
| 83 | + | baseline int64 | |
| 84 | + | usage provider.Usage | |
| 76 | 85 | snapshot BudgetSnapshot | |
| 77 | 86 | } | |
| 78 | 87 | ||
| 79 | 88 | func NewRolloutBudget(limit int64, usage provider.Usage) *RolloutBudget { | |
| 80 | - | budget := &RolloutBudget{limit: limit} | |
| 89 | + | budget := &RolloutBudget{limit: limit, usage: usage} | |
| 81 | 90 | budget.snapshot = BudgetSnapshotFor(usage, limit) | |
| 82 | 91 | return budget | |
| 83 | 92 | } | |
| @@ -87,12 +96,25 @@ func (b *RolloutBudget) Observe(usage provider.Usage) BudgetSnapshot { | |||
| 87 | 96 | return BudgetSnapshot{} | |
| 88 | 97 | } | |
| 89 | 98 | b.mu.Lock() | |
| 90 | - | b.snapshot = BudgetSnapshotFor(usage, b.limit) | |
| 99 | + | b.usage = usage | |
| 100 | + | b.snapshot = budgetSnapshotSince(usage, b.limit, b.baseline) | |
| 91 | 101 | snapshot := b.snapshot | |
| 92 | 102 | b.mu.Unlock() | |
| 93 | 103 | return snapshot | |
| 94 | 104 | } | |
| 95 | 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 | + | ||
| 96 | 118 | func (b *RolloutBudget) Snapshot() BudgetSnapshot { | |
| 97 | 119 | if b == nil { | |
| 98 | 120 | return BudgetSnapshot{} | |
| @@ -54,7 +54,14 @@ func BudgetSnapshotFor(usage provider.Usage, limit int64) BudgetSnapshot { | |||||
| 54 | if limit <= 0 { | 54 | if limit <= 0 { | ||
| 55 | return BudgetSnapshot{} | 55 | return BudgetSnapshot{} | ||
| 56 | } | 56 | } | ||
| 57 | - | used := WeightedBudgetUsage(usage) | 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) | |||
| 58 | percent := int(math.Floor(float64(used) * 100 / float64(limit))) | 65 | percent := int(math.Floor(float64(used) * 100 / float64(limit))) | ||
| 59 | return BudgetSnapshot{Used: used, Limit: limit, Percent: percent, State: budgetState(used, limit)} | 66 | return BudgetSnapshot{Used: used, Limit: limit, Percent: percent, State: budgetState(used, limit)} | ||
| 60 | } | 67 | } | ||
| @@ -73,11 +80,13 @@ func budgetState(used, limit int64) BudgetState { | |||||
| 73 | type RolloutBudget struct { | 80 | type RolloutBudget struct { | ||
| 74 | mu sync.Mutex | 81 | mu sync.Mutex | ||
| 75 | limit int64 | 82 | limit int64 | ||
| 83 | + | baseline int64 | |||
| 84 | + | usage provider.Usage | |||
| 76 | snapshot BudgetSnapshot | 85 | snapshot BudgetSnapshot | ||
| 77 | } | 86 | } | ||
| 78 | 87 | ||||
| 79 | func NewRolloutBudget(limit int64, usage provider.Usage) *RolloutBudget { | 88 | func NewRolloutBudget(limit int64, usage provider.Usage) *RolloutBudget { | ||
| 80 | - | budget := &RolloutBudget{limit: limit} | 89 | + | budget := &RolloutBudget{limit: limit, usage: usage} |
| 81 | budget.snapshot = BudgetSnapshotFor(usage, limit) | 90 | budget.snapshot = BudgetSnapshotFor(usage, limit) | ||
| 82 | return budget | 91 | return budget | ||
| 83 | } | 92 | } | ||
| @@ -87,12 +96,25 @@ func (b *RolloutBudget) Observe(usage provider.Usage) BudgetSnapshot { | |||||
| 87 | return BudgetSnapshot{} | 96 | return BudgetSnapshot{} | ||
| 88 | } | 97 | } | ||
| 89 | b.mu.Lock() | 98 | b.mu.Lock() | ||
| 90 | - | b.snapshot = BudgetSnapshotFor(usage, b.limit) | 99 | + | b.usage = usage |
| 100 | + | b.snapshot = budgetSnapshotSince(usage, b.limit, b.baseline) | |||
| 91 | snapshot := b.snapshot | 101 | snapshot := b.snapshot | ||
| 92 | b.mu.Unlock() | 102 | b.mu.Unlock() | ||
| 93 | return snapshot | 103 | return snapshot | ||
| 94 | } | 104 | } | ||
| 95 | 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 | + | ||||
| 96 | func (b *RolloutBudget) Snapshot() BudgetSnapshot { | 118 | func (b *RolloutBudget) Snapshot() BudgetSnapshot { | ||
| 97 | if b == nil { | 119 | if b == nil { | ||
| 98 | return BudgetSnapshot{} | 120 | return BudgetSnapshot{} | ||
M packages/agent/subagents/completion.go +21 -1
| @@ -2,6 +2,7 @@ package subagents | |||
| 2 | 2 | ||
| 3 | 3 | import ( | |
| 4 | 4 | "context" | |
| 5 | + | "errors" | |
| 5 | 6 | "fmt" | |
| 6 | 7 | "strings" | |
| 7 | 8 | "sync" | |
| @@ -17,6 +18,21 @@ type Completion struct { | |||
| 17 | 18 | Summary string | |
| 18 | 19 | } | |
| 19 | 20 | ||
| 21 | + | // Completion projects a typed resident outcome into the parent notification | |
| 22 | + | // shared by interactive and orchestrated modes. | |
| 23 | + | func (c ResidentCompletion) Completion() Completion { | |
| 24 | + | result := Completion{AgentID: c.ChildID, TurnID: c.TurnID, Status: string(ResidentCompleted), Task: c.Task, Summary: c.Summary} | |
| 25 | + | if c.Err != nil { | |
| 26 | + | result.Status, result.Error = string(ResidentFailed), c.Err.Error() | |
| 27 | + | if errors.Is(c.Err, context.Canceled) { | |
| 28 | + | result.Status = string(ResidentInterrupted) | |
| 29 | + | } else if errors.Is(c.Err, ErrBudgetExceeded) { | |
| 30 | + | result.Status = string(ResidentBudgetExhausted) | |
| 31 | + | } | |
| 32 | + | } | |
| 33 | + | return result | |
| 34 | + | } | |
| 35 | + | ||
| 20 | 36 | // CompletionTracker coordinates resident child completions with the parent | |
| 21 | 37 | // turn. It has no process or worker-event dependency. | |
| 22 | 38 | type CompletionTracker struct { | |
| @@ -138,7 +154,11 @@ func FormatCompletionUpdate(batch []Completion, instruction string) string { | |||
| 138 | 154 | fmt.Fprintf(&b, " — %s", completion.Task) | |
| 139 | 155 | } | |
| 140 | 156 | if completion.Summary != "" { | |
| 141 | - | fmt.Fprintf(&b, "\n final: %s", completion.Summary) | |
| 157 | + | label := "final" | |
| 158 | + | if completion.Status != string(ResidentCompleted) { | |
| 159 | + | label = "partial" | |
| 160 | + | } | |
| 161 | + | fmt.Fprintf(&b, "\n %s: %s", label, completion.Summary) | |
| 142 | 162 | } | |
| 143 | 163 | b.WriteByte('\n') | |
| 144 | 164 | } | |
| @@ -2,6 +2,7 @@ package subagents | |||||
| 2 | 2 | ||||
| 3 | import ( | 3 | import ( | ||
| 4 | "context" | 4 | "context" | ||
| 5 | + | "errors" | |||
| 5 | "fmt" | 6 | "fmt" | ||
| 6 | "strings" | 7 | "strings" | ||
| 7 | "sync" | 8 | "sync" | ||
| @@ -17,6 +18,21 @@ type Completion struct { | |||||
| 17 | Summary string | 18 | Summary string | ||
| 18 | } | 19 | } | ||
| 19 | 20 | ||||
| 21 | + | // Completion projects a typed resident outcome into the parent notification | |||
| 22 | + | // shared by interactive and orchestrated modes. | |||
| 23 | + | func (c ResidentCompletion) Completion() Completion { | |||
| 24 | + | result := Completion{AgentID: c.ChildID, TurnID: c.TurnID, Status: string(ResidentCompleted), Task: c.Task, Summary: c.Summary} | |||
| 25 | + | if c.Err != nil { | |||
| 26 | + | result.Status, result.Error = string(ResidentFailed), c.Err.Error() | |||
| 27 | + | if errors.Is(c.Err, context.Canceled) { | |||
| 28 | + | result.Status = string(ResidentInterrupted) | |||
| 29 | + | } else if errors.Is(c.Err, ErrBudgetExceeded) { | |||
| 30 | + | result.Status = string(ResidentBudgetExhausted) | |||
| 31 | + | } | |||
| 32 | + | } | |||
| 33 | + | return result | |||
| 34 | + | } | |||
| 35 | + | ||||
| 20 | // CompletionTracker coordinates resident child completions with the parent | 36 | // CompletionTracker coordinates resident child completions with the parent | ||
| 21 | // turn. It has no process or worker-event dependency. | 37 | // turn. It has no process or worker-event dependency. | ||
| 22 | type CompletionTracker struct { | 38 | type CompletionTracker struct { | ||
| @@ -138,7 +154,11 @@ func FormatCompletionUpdate(batch []Completion, instruction string) string { | |||||
| 138 | fmt.Fprintf(&b, " — %s", completion.Task) | 154 | fmt.Fprintf(&b, " — %s", completion.Task) | ||
| 139 | } | 155 | } | ||
| 140 | if completion.Summary != "" { | 156 | if completion.Summary != "" { | ||
| 141 | - | fmt.Fprintf(&b, "\n final: %s", completion.Summary) | 157 | + | label := "final" |
| 158 | + | if completion.Status != string(ResidentCompleted) { | |||
| 159 | + | label = "partial" | |||
| 160 | + | } | |||
| 161 | + | fmt.Fprintf(&b, "\n %s: %s", label, completion.Summary) | |||
| 142 | } | 162 | } | ||
| 143 | b.WriteByte('\n') | 163 | b.WriteByte('\n') | ||
| 144 | } | 164 | } | ||
M packages/agent/subagents/completion_test.go +32 -0
| @@ -2,10 +2,42 @@ package subagents | |||
| 2 | 2 | ||
| 3 | 3 | import ( | |
| 4 | 4 | "context" | |
| 5 | + | "errors" | |
| 5 | 6 | "strings" | |
| 6 | 7 | "testing" | |
| 7 | 8 | ) | |
| 8 | 9 | ||
| 10 | + | func TestResidentCompletionProjection(t *testing.T) { | |
| 11 | + | for _, tc := range []struct { | |
| 12 | + | err error | |
| 13 | + | status string | |
| 14 | + | }{ | |
| 15 | + | {nil, "completed"}, | |
| 16 | + | {errors.New("failure"), "failed"}, | |
| 17 | + | {context.Canceled, "interrupted"}, | |
| 18 | + | {errors.Join(ErrBudgetExceeded, errors.New("capture failed")), "budget_exhausted"}, | |
| 19 | + | {errors.Join(context.Canceled, ErrBudgetExceeded), "interrupted"}, | |
| 20 | + | } { | |
| 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() | |
| 23 | + | if got.Status != tc.status || got.AgentID != "child" || got.TurnID != "turn" || got.Task != "task" || got.Summary != "saved progress" { | |
| 24 | + | t.Fatalf("completion = %#v", got) | |
| 25 | + | } | |
| 26 | + | if tc.err != nil && got.Error != tc.err.Error() { | |
| 27 | + | t.Fatalf("error = %q, want %q", got.Error, tc.err.Error()) | |
| 28 | + | } | |
| 29 | + | text := FormatCompletionUpdate([]Completion{got}, "") | |
| 30 | + | label := "partial: " | |
| 31 | + | if tc.err == nil { | |
| 32 | + | label = "final: " | |
| 33 | + | } | |
| 34 | + | if !strings.Contains(text, label+"saved progress") { | |
| 35 | + | t.Fatalf("update = %q", text) | |
| 36 | + | } | |
| 37 | + | }) | |
| 38 | + | } | |
| 39 | + | } | |
| 40 | + | ||
| 9 | 41 | func TestFormatCompletionUpdateIncludesFinalSummary(t *testing.T) { | |
| 10 | 42 | got := FormatCompletionUpdate([]Completion{{AgentID: "child", Status: "completed", Task: "review", Summary: "found the regression"}}, "") | |
| 11 | 43 | if !strings.Contains(got, "final: found the regression") { | |
| @@ -2,10 +2,42 @@ package subagents | |||||
| 2 | 2 | ||||
| 3 | import ( | 3 | import ( | ||
| 4 | "context" | 4 | "context" | ||
| 5 | + | "errors" | |||
| 5 | "strings" | 6 | "strings" | ||
| 6 | "testing" | 7 | "testing" | ||
| 7 | ) | 8 | ) | ||
| 8 | 9 | ||||
| 10 | + | func TestResidentCompletionProjection(t *testing.T) { | |||
| 11 | + | for _, tc := range []struct { | |||
| 12 | + | err error | |||
| 13 | + | status string | |||
| 14 | + | }{ | |||
| 15 | + | {nil, "completed"}, | |||
| 16 | + | {errors.New("failure"), "failed"}, | |||
| 17 | + | {context.Canceled, "interrupted"}, | |||
| 18 | + | {errors.Join(ErrBudgetExceeded, errors.New("capture failed")), "budget_exhausted"}, | |||
| 19 | + | {errors.Join(context.Canceled, ErrBudgetExceeded), "interrupted"}, | |||
| 20 | + | } { | |||
| 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() | |||
| 23 | + | if got.Status != tc.status || got.AgentID != "child" || got.TurnID != "turn" || got.Task != "task" || got.Summary != "saved progress" { | |||
| 24 | + | t.Fatalf("completion = %#v", got) | |||
| 25 | + | } | |||
| 26 | + | if tc.err != nil && got.Error != tc.err.Error() { | |||
| 27 | + | t.Fatalf("error = %q, want %q", got.Error, tc.err.Error()) | |||
| 28 | + | } | |||
| 29 | + | text := FormatCompletionUpdate([]Completion{got}, "") | |||
| 30 | + | label := "partial: " | |||
| 31 | + | if tc.err == nil { | |||
| 32 | + | label = "final: " | |||
| 33 | + | } | |||
| 34 | + | if !strings.Contains(text, label+"saved progress") { | |||
| 35 | + | t.Fatalf("update = %q", text) | |||
| 36 | + | } | |||
| 37 | + | }) | |||
| 38 | + | } | |||
| 39 | + | } | |||
| 40 | + | ||||
| 9 | func TestFormatCompletionUpdateIncludesFinalSummary(t *testing.T) { | 41 | func TestFormatCompletionUpdateIncludesFinalSummary(t *testing.T) { | ||
| 10 | got := FormatCompletionUpdate([]Completion{{AgentID: "child", Status: "completed", Task: "review", Summary: "found the regression"}}, "") | 42 | got := FormatCompletionUpdate([]Completion{{AgentID: "child", Status: "completed", Task: "review", Summary: "found the regression"}}, "") | ||
| 11 | if !strings.Contains(got, "final: found the regression") { | 43 | if !strings.Contains(got, "final: found the regression") { | ||
A packages/agent/subagents/resident_artifact_recovery_test.go +90 -0
| @@ -0,0 +1,90 @@ | |||
| 1 | + | package subagents | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "os" | |
| 5 | + | "path/filepath" | |
| 6 | + | "reflect" | |
| 7 | + | "testing" | |
| 8 | + | ) | |
| 9 | + | ||
| 10 | + | func TestReconcileResidentJournalRestoresCapturedArtifacts(t *testing.T) { | |
| 11 | + | for _, damage := range []string{"missing", "corrupt"} { | |
| 12 | + | for _, captured := range []bool{false, true} { | |
| 13 | + | name := damage + "/without-capture" | |
| 14 | + | if captured { | |
| 15 | + | name = damage + "/with-capture" | |
| 16 | + | } | |
| 17 | + | t.Run(name, func(t *testing.T) { | |
| 18 | + | root := t.TempDir() | |
| 19 | + | spec := ResidentChildSpec{ID: "artifact-recovery", SessionID: "session", InitialTurnID: "initial", Provider: "openai", Model: "test"} | |
| 20 | + | journal, err := OpenResidentJournal(root, spec.ID) | |
| 21 | + | if err != nil { | |
| 22 | + | t.Fatal(err) | |
| 23 | + | } | |
| 24 | + | t.Cleanup(func() { _ = journal.Close() }) | |
| 25 | + | if err := journal.Accept(spec, "inspect and change files"); err != nil { | |
| 26 | + | t.Fatal(err) | |
| 27 | + | } | |
| 28 | + | if err := journal.RecordTurnStarted(spec, spec.InitialTurnID); err != nil { | |
| 29 | + | t.Fatal(err) | |
| 30 | + | } | |
| 31 | + | var capture *WorkspaceCapture | |
| 32 | + | if captured { | |
| 33 | + | capture = &WorkspaceCapture{Patch: []byte("partial patch\n"), ChangedFiles: []string{"README.md", "file with spaces.go"}} | |
| 34 | + | } else if err := writeResidentPatch(journal.Dir(), []byte("stale patch from an earlier turn\n")); err != nil { | |
| 35 | + | t.Fatal(err) | |
| 36 | + | } | |
| 37 | + | if err := journal.RecordTurnFinishedWithCapture(spec, spec.InitialTurnID, ErrBudgetExceeded, capture); err != nil { | |
| 38 | + | t.Fatal(err) | |
| 39 | + | } | |
| 40 | + | original, err := journal.Result() | |
| 41 | + | if err != nil { | |
| 42 | + | t.Fatal(err) | |
| 43 | + | } | |
| 44 | + | if captured && (original.PatchRef != PatchRef(spec.ID) || !reflect.DeepEqual(original.ChangedFiles, capture.ChangedFiles)) { | |
| 45 | + | t.Fatalf("capture was not published: %#v", original) | |
| 46 | + | } | |
| 47 | + | if err := journal.Close(); err != nil { | |
| 48 | + | t.Fatal(err) | |
| 49 | + | } | |
| 50 | + | dir := filepath.Join(root, spec.ID) | |
| 51 | + | transcriptPath := filepath.Join(dir, residentTranscriptName) | |
| 52 | + | before, err := os.ReadFile(transcriptPath) | |
| 53 | + | if err != nil { | |
| 54 | + | t.Fatal(err) | |
| 55 | + | } | |
| 56 | + | resultPath := filepath.Join(dir, residentResultName) | |
| 57 | + | if damage == "missing" { | |
| 58 | + | err = os.Remove(resultPath) | |
| 59 | + | } else { | |
| 60 | + | err = os.WriteFile(resultPath, []byte("invalid JSON"), 0o600) | |
| 61 | + | } | |
| 62 | + | if err != nil { | |
| 63 | + | t.Fatal(err) | |
| 64 | + | } | |
| 65 | + | for range 2 { | |
| 66 | + | if _, err := ReconcileResidentJournal(dir); err != nil { | |
| 67 | + | t.Fatal(err) | |
| 68 | + | } | |
| 69 | + | result, err := ReadResidentResult(resultPath) | |
| 70 | + | if err != nil { | |
| 71 | + | t.Fatal(err) | |
| 72 | + | } | |
| 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 | + | t.Fatalf("reconstructed result = %#v, original = %#v", result, original) | |
| 75 | + | } | |
| 76 | + | } | |
| 77 | + | after, err := os.ReadFile(transcriptPath) | |
| 78 | + | if err != nil || string(after) != string(before) { | |
| 79 | + | t.Fatalf("reconciliation modified transcript: %v", err) | |
| 80 | + | } | |
| 81 | + | if captured { | |
| 82 | + | patch, err := os.ReadFile(filepath.Join(dir, residentPatchName)) | |
| 83 | + | if err != nil || string(patch) != string(capture.Patch) { | |
| 84 | + | t.Fatalf("patch = %q, error = %v", patch, err) | |
| 85 | + | } | |
| 86 | + | } | |
| 87 | + | }) | |
| 88 | + | } | |
| 89 | + | } | |
| 90 | + | } | |
| @@ -0,0 +1,90 @@ | |||||
| 1 | + | package subagents | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "os" | |||
| 5 | + | "path/filepath" | |||
| 6 | + | "reflect" | |||
| 7 | + | "testing" | |||
| 8 | + | ) | |||
| 9 | + | ||||
| 10 | + | func TestReconcileResidentJournalRestoresCapturedArtifacts(t *testing.T) { | |||
| 11 | + | for _, damage := range []string{"missing", "corrupt"} { | |||
| 12 | + | for _, captured := range []bool{false, true} { | |||
| 13 | + | name := damage + "/without-capture" | |||
| 14 | + | if captured { | |||
| 15 | + | name = damage + "/with-capture" | |||
| 16 | + | } | |||
| 17 | + | t.Run(name, func(t *testing.T) { | |||
| 18 | + | root := t.TempDir() | |||
| 19 | + | spec := ResidentChildSpec{ID: "artifact-recovery", SessionID: "session", InitialTurnID: "initial", Provider: "openai", Model: "test"} | |||
| 20 | + | journal, err := OpenResidentJournal(root, spec.ID) | |||
| 21 | + | if err != nil { | |||
| 22 | + | t.Fatal(err) | |||
| 23 | + | } | |||
| 24 | + | t.Cleanup(func() { _ = journal.Close() }) | |||
| 25 | + | if err := journal.Accept(spec, "inspect and change files"); err != nil { | |||
| 26 | + | t.Fatal(err) | |||
| 27 | + | } | |||
| 28 | + | if err := journal.RecordTurnStarted(spec, spec.InitialTurnID); err != nil { | |||
| 29 | + | t.Fatal(err) | |||
| 30 | + | } | |||
| 31 | + | var capture *WorkspaceCapture | |||
| 32 | + | if captured { | |||
| 33 | + | capture = &WorkspaceCapture{Patch: []byte("partial patch\n"), ChangedFiles: []string{"README.md", "file with spaces.go"}} | |||
| 34 | + | } else if err := writeResidentPatch(journal.Dir(), []byte("stale patch from an earlier turn\n")); err != nil { | |||
| 35 | + | t.Fatal(err) | |||
| 36 | + | } | |||
| 37 | + | if err := journal.RecordTurnFinishedWithCapture(spec, spec.InitialTurnID, ErrBudgetExceeded, capture); err != nil { | |||
| 38 | + | t.Fatal(err) | |||
| 39 | + | } | |||
| 40 | + | original, err := journal.Result() | |||
| 41 | + | if err != nil { | |||
| 42 | + | t.Fatal(err) | |||
| 43 | + | } | |||
| 44 | + | if captured && (original.PatchRef != PatchRef(spec.ID) || !reflect.DeepEqual(original.ChangedFiles, capture.ChangedFiles)) { | |||
| 45 | + | t.Fatalf("capture was not published: %#v", original) | |||
| 46 | + | } | |||
| 47 | + | if err := journal.Close(); err != nil { | |||
| 48 | + | t.Fatal(err) | |||
| 49 | + | } | |||
| 50 | + | dir := filepath.Join(root, spec.ID) | |||
| 51 | + | transcriptPath := filepath.Join(dir, residentTranscriptName) | |||
| 52 | + | before, err := os.ReadFile(transcriptPath) | |||
| 53 | + | if err != nil { | |||
| 54 | + | t.Fatal(err) | |||
| 55 | + | } | |||
| 56 | + | resultPath := filepath.Join(dir, residentResultName) | |||
| 57 | + | if damage == "missing" { | |||
| 58 | + | err = os.Remove(resultPath) | |||
| 59 | + | } else { | |||
| 60 | + | err = os.WriteFile(resultPath, []byte("invalid JSON"), 0o600) | |||
| 61 | + | } | |||
| 62 | + | if err != nil { | |||
| 63 | + | t.Fatal(err) | |||
| 64 | + | } | |||
| 65 | + | for range 2 { | |||
| 66 | + | if _, err := ReconcileResidentJournal(dir); err != nil { | |||
| 67 | + | t.Fatal(err) | |||
| 68 | + | } | |||
| 69 | + | result, err := ReadResidentResult(resultPath) | |||
| 70 | + | if err != nil { | |||
| 71 | + | t.Fatal(err) | |||
| 72 | + | } | |||
| 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 | + | t.Fatalf("reconstructed result = %#v, original = %#v", result, original) | |||
| 75 | + | } | |||
| 76 | + | } | |||
| 77 | + | after, err := os.ReadFile(transcriptPath) | |||
| 78 | + | if err != nil || string(after) != string(before) { | |||
| 79 | + | t.Fatalf("reconciliation modified transcript: %v", err) | |||
| 80 | + | } | |||
| 81 | + | if captured { | |||
| 82 | + | patch, err := os.ReadFile(filepath.Join(dir, residentPatchName)) | |||
| 83 | + | if err != nil || string(patch) != string(capture.Patch) { | |||
| 84 | + | t.Fatalf("patch = %q, error = %v", patch, err) | |||
| 85 | + | } | |||
| 86 | + | } | |||
| 87 | + | }) | |||
| 88 | + | } | |||
| 89 | + | } | |||
| 90 | + | } | |||
A packages/agent/subagents/resident_budget_recovery_test.go +151 -0
| @@ -0,0 +1,151 @@ | |||
| 1 | + | package subagents | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "context" | |
| 5 | + | "encoding/json" | |
| 6 | + | "errors" | |
| 7 | + | "fmt" | |
| 8 | + | "os" | |
| 9 | + | "path/filepath" | |
| 10 | + | "strings" | |
| 11 | + | "testing" | |
| 12 | + | "time" | |
| 13 | + | ||
| 14 | + | "github.com/bnema/zut/packages/core" | |
| 15 | + | "github.com/bnema/zut/packages/provider" | |
| 16 | + | ) | |
| 17 | + | ||
| 18 | + | func TestResidentBudgetExhaustionHandoffAndRecovery(t *testing.T) { | |
| 19 | + | for _, mode := range []string{"read-only", "shared", "worktree"} { | |
| 20 | + | for _, restart := range []bool{false, true} { | |
| 21 | + | t.Run(fmt.Sprintf("%s/restart=%t", mode, restart), func(t *testing.T) { | |
| 22 | + | root, workspace := t.TempDir(), t.TempDir() | |
| 23 | + | if mode == "worktree" { | |
| 24 | + | workspace = initTestRepo(t) | |
| 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} | |
| 27 | + | if mode == "worktree" { | |
| 28 | + | spec.WorkspaceMode, spec.WorkspaceCapture = WorkspaceWorktree, CapturePatch | |
| 29 | + | } | |
| 30 | + | factory := func(spec ResidentChildSpec, journal *ResidentJournal) (ResidentTurnRunner, error) { | |
| 31 | + | return func(_ context.Context, prompt string) error { | |
| 32 | + | 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()) | |
| 37 | + | if err != nil || len(messages) < 3 { | |
| 38 | + | return fmt.Errorf("missing restored progress: %d messages, %v", len(messages), err) | |
| 39 | + | } | |
| 40 | + | if mode != "read-only" { | |
| 41 | + | data, err := os.ReadFile(filepath.Join(spec.Workspace, "README.md")) | |
| 42 | + | if err != nil || string(data) != "partial artifact\n" { | |
| 43 | + | return fmt.Errorf("lost artifact: %q, %v", data, err) | |
| 44 | + | } | |
| 45 | + | } | |
| 46 | + | if err := journal.RecordAgentEvent(core.EvUsage{Cumulative: provider.Usage{InputTokens: 105}}); err != nil { | |
| 47 | + | return err | |
| 48 | + | } | |
| 49 | + | return journal.RecordAgentEvent(core.EvAssistantMessage{Message: provider.Message{Role: provider.RoleAssistant, Content: []provider.Content{provider.TextBlock{Text: "verified remaining work"}}}}) | |
| 50 | + | } | |
| 51 | + | tool, output := "read", "inspected README; verification unfinished" | |
| 52 | + | if mode != "read-only" { | |
| 53 | + | tool, output = "write", "wrote README.md; verification unfinished" | |
| 54 | + | if err := os.WriteFile(filepath.Join(spec.Workspace, "README.md"), []byte("partial artifact\n"), 0o600); err != nil { | |
| 55 | + | return err | |
| 56 | + | } | |
| 57 | + | } | |
| 58 | + | args := json.RawMessage(`{"path":"README.md"}`) | |
| 59 | + | for _, event := range []core.AgentEvent{ | |
| 60 | + | core.EvUserMessage{Message: provider.Message{Role: provider.RoleUser, Content: []provider.Content{provider.TextBlock{Text: prompt}}}}, | |
| 61 | + | core.EvAssistantMessage{Message: provider.Message{Role: provider.RoleAssistant, Content: []provider.Content{provider.TextBlock{Text: "working on README.md; verification unfinished"}, provider.ToolCallBlock{ID: "call", Name: tool, Arguments: args}}}}, | |
| 62 | + | core.EvToolCall{ID: "call", Name: tool, Args: args}, | |
| 63 | + | core.EvToolResult{ID: "call", Result: core.ToolResult{Content: []provider.Content{provider.TextBlock{Text: output}}}}, | |
| 64 | + | core.EvUsage{Cumulative: provider.Usage{InputTokens: 100}}, | |
| 65 | + | } { | |
| 66 | + | if err := journal.RecordAgentEvent(event); err != nil { | |
| 67 | + | return err | |
| 68 | + | } | |
| 69 | + | } | |
| 70 | + | return ErrBudgetExceeded | |
| 71 | + | }, nil | |
| 72 | + | } | |
| 73 | + | manager := NewResidentManager(root, factory) | |
| 74 | + | t.Cleanup(func() { _ = manager.Close(context.Background()) }) | |
| 75 | + | completions := make(chan ResidentCompletion, 2) | |
| 76 | + | manager.SetCompletionObserver(func(c ResidentCompletion) { completions <- c }) | |
| 77 | + | if _, err := manager.Spawn(t.Context(), spec, "inspect and verify README"); err != nil { | |
| 78 | + | t.Fatal(err) | |
| 79 | + | } | |
| 80 | + | completion := awaitBudgetCompletion(t, completions) | |
| 81 | + | if !errors.Is(completion.Err, ErrBudgetExceeded) || !strings.Contains(completion.Summary, "verification unfinished") { | |
| 82 | + | t.Fatalf("terminal notification = %#v", completion) | |
| 83 | + | } | |
| 84 | + | assertBudgetPartial := func() ResidentResult { | |
| 85 | + | t.Helper() | |
| 86 | + | snapshot, ok := manager.SnapshotFor(spec.ID) | |
| 87 | + | if !ok || snapshot.State != ResidentBudgetExhausted || snapshot.Budget.State != BudgetExceeded || len(manager.UnmetRequired()) != 1 { | |
| 88 | + | t.Fatalf("partial status = %#v; unmet = %#v", snapshot, manager.UnmetRequired()) | |
| 89 | + | } | |
| 90 | + | result, err := manager.Result(spec.ID) | |
| 91 | + | if err != nil || result.ErrorCode != "budget_exhausted" || !strings.Contains(result.Handoff, "README.md") || len(result.Handoff) > residentHandoffBytes { | |
| 92 | + | t.Fatalf("saved partial result = %#v, %v", result, err) | |
| 93 | + | } | |
| 94 | + | return result | |
| 95 | + | } | |
| 96 | + | result := assertBudgetPartial() | |
| 97 | + | if mode == "worktree" { | |
| 98 | + | if result.PatchRef != PatchRef(spec.ID) || len(result.ChangedFiles) != 1 { | |
| 99 | + | t.Fatalf("missing partial patch: %#v", result) | |
| 100 | + | } | |
| 101 | + | patch, err := os.ReadFile(filepath.Join(root, spec.ID, residentPatchName)) | |
| 102 | + | if err != nil || !strings.Contains(string(patch), "partial artifact") { | |
| 103 | + | t.Fatalf("partial patch = %q, %v", patch, err) | |
| 104 | + | } | |
| 105 | + | } | |
| 106 | + | if restart { | |
| 107 | + | if err := manager.Close(t.Context()); err != nil { | |
| 108 | + | t.Fatal(err) | |
| 109 | + | } | |
| 110 | + | manager = NewResidentManager(root, factory) | |
| 111 | + | manager.SetCompletionObserver(func(c ResidentCompletion) { completions <- c }) | |
| 112 | + | if errs := manager.Reconcile(); len(errs) != 0 { | |
| 113 | + | t.Fatal(errs) | |
| 114 | + | } | |
| 115 | + | assertBudgetPartial() | |
| 116 | + | } | |
| 117 | + | if err := manager.Resume(t.Context(), spec.ID, "continue remaining work"); err != nil { | |
| 118 | + | t.Fatal(err) | |
| 119 | + | } | |
| 120 | + | if completion := awaitBudgetCompletion(t, completions); completion.Err != nil { | |
| 121 | + | t.Fatal(completion.Err) | |
| 122 | + | } | |
| 123 | + | if unmet := manager.UnmetRequired(); len(unmet) != 0 { | |
| 124 | + | t.Fatalf("successful recovery remains unmet: %#v", unmet) | |
| 125 | + | } | |
| 126 | + | snapshot, _ := manager.SnapshotFor(spec.ID) | |
| 127 | + | if snapshot.Budget.Used != 5 || snapshot.Budget.Limit != 100 || snapshot.Usage.InputTokens != 105 { | |
| 128 | + | t.Fatalf("recovery lost cumulative accounting: %#v", snapshot) | |
| 129 | + | } | |
| 130 | + | if err := manager.Close(t.Context()); err != nil { | |
| 131 | + | t.Fatal(err) | |
| 132 | + | } | |
| 133 | + | 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) | |
| 136 | + | } | |
| 137 | + | }) | |
| 138 | + | } | |
| 139 | + | } | |
| 140 | + | } | |
| 141 | + | ||
| 142 | + | func awaitBudgetCompletion(t *testing.T, completions <-chan ResidentCompletion) ResidentCompletion { | |
| 143 | + | t.Helper() | |
| 144 | + | select { | |
| 145 | + | case completion := <-completions: | |
| 146 | + | return completion | |
| 147 | + | case <-time.After(5 * time.Second): | |
| 148 | + | t.Fatal("no terminal budget notification") | |
| 149 | + | return ResidentCompletion{} | |
| 150 | + | } | |
| 151 | + | } | |
| @@ -0,0 +1,151 @@ | |||||
| 1 | + | package subagents | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "context" | |||
| 5 | + | "encoding/json" | |||
| 6 | + | "errors" | |||
| 7 | + | "fmt" | |||
| 8 | + | "os" | |||
| 9 | + | "path/filepath" | |||
| 10 | + | "strings" | |||
| 11 | + | "testing" | |||
| 12 | + | "time" | |||
| 13 | + | ||||
| 14 | + | "github.com/bnema/zut/packages/core" | |||
| 15 | + | "github.com/bnema/zut/packages/provider" | |||
| 16 | + | ) | |||
| 17 | + | ||||
| 18 | + | func TestResidentBudgetExhaustionHandoffAndRecovery(t *testing.T) { | |||
| 19 | + | for _, mode := range []string{"read-only", "shared", "worktree"} { | |||
| 20 | + | for _, restart := range []bool{false, true} { | |||
| 21 | + | t.Run(fmt.Sprintf("%s/restart=%t", mode, restart), func(t *testing.T) { | |||
| 22 | + | root, workspace := t.TempDir(), t.TempDir() | |||
| 23 | + | if mode == "worktree" { | |||
| 24 | + | workspace = initTestRepo(t) | |||
| 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} | |||
| 27 | + | if mode == "worktree" { | |||
| 28 | + | spec.WorkspaceMode, spec.WorkspaceCapture = WorkspaceWorktree, CapturePatch | |||
| 29 | + | } | |||
| 30 | + | factory := func(spec ResidentChildSpec, journal *ResidentJournal) (ResidentTurnRunner, error) { | |||
| 31 | + | return func(_ context.Context, prompt string) error { | |||
| 32 | + | 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()) | |||
| 37 | + | if err != nil || len(messages) < 3 { | |||
| 38 | + | return fmt.Errorf("missing restored progress: %d messages, %v", len(messages), err) | |||
| 39 | + | } | |||
| 40 | + | if mode != "read-only" { | |||
| 41 | + | data, err := os.ReadFile(filepath.Join(spec.Workspace, "README.md")) | |||
| 42 | + | if err != nil || string(data) != "partial artifact\n" { | |||
| 43 | + | return fmt.Errorf("lost artifact: %q, %v", data, err) | |||
| 44 | + | } | |||
| 45 | + | } | |||
| 46 | + | if err := journal.RecordAgentEvent(core.EvUsage{Cumulative: provider.Usage{InputTokens: 105}}); err != nil { | |||
| 47 | + | return err | |||
| 48 | + | } | |||
| 49 | + | return journal.RecordAgentEvent(core.EvAssistantMessage{Message: provider.Message{Role: provider.RoleAssistant, Content: []provider.Content{provider.TextBlock{Text: "verified remaining work"}}}}) | |||
| 50 | + | } | |||
| 51 | + | tool, output := "read", "inspected README; verification unfinished" | |||
| 52 | + | if mode != "read-only" { | |||
| 53 | + | tool, output = "write", "wrote README.md; verification unfinished" | |||
| 54 | + | if err := os.WriteFile(filepath.Join(spec.Workspace, "README.md"), []byte("partial artifact\n"), 0o600); err != nil { | |||
| 55 | + | return err | |||
| 56 | + | } | |||
| 57 | + | } | |||
| 58 | + | args := json.RawMessage(`{"path":"README.md"}`) | |||
| 59 | + | for _, event := range []core.AgentEvent{ | |||
| 60 | + | core.EvUserMessage{Message: provider.Message{Role: provider.RoleUser, Content: []provider.Content{provider.TextBlock{Text: prompt}}}}, | |||
| 61 | + | core.EvAssistantMessage{Message: provider.Message{Role: provider.RoleAssistant, Content: []provider.Content{provider.TextBlock{Text: "working on README.md; verification unfinished"}, provider.ToolCallBlock{ID: "call", Name: tool, Arguments: args}}}}, | |||
| 62 | + | core.EvToolCall{ID: "call", Name: tool, Args: args}, | |||
| 63 | + | core.EvToolResult{ID: "call", Result: core.ToolResult{Content: []provider.Content{provider.TextBlock{Text: output}}}}, | |||
| 64 | + | core.EvUsage{Cumulative: provider.Usage{InputTokens: 100}}, | |||
| 65 | + | } { | |||
| 66 | + | if err := journal.RecordAgentEvent(event); err != nil { | |||
| 67 | + | return err | |||
| 68 | + | } | |||
| 69 | + | } | |||
| 70 | + | return ErrBudgetExceeded | |||
| 71 | + | }, nil | |||
| 72 | + | } | |||
| 73 | + | manager := NewResidentManager(root, factory) | |||
| 74 | + | t.Cleanup(func() { _ = manager.Close(context.Background()) }) | |||
| 75 | + | completions := make(chan ResidentCompletion, 2) | |||
| 76 | + | manager.SetCompletionObserver(func(c ResidentCompletion) { completions <- c }) | |||
| 77 | + | if _, err := manager.Spawn(t.Context(), spec, "inspect and verify README"); err != nil { | |||
| 78 | + | t.Fatal(err) | |||
| 79 | + | } | |||
| 80 | + | completion := awaitBudgetCompletion(t, completions) | |||
| 81 | + | if !errors.Is(completion.Err, ErrBudgetExceeded) || !strings.Contains(completion.Summary, "verification unfinished") { | |||
| 82 | + | t.Fatalf("terminal notification = %#v", completion) | |||
| 83 | + | } | |||
| 84 | + | assertBudgetPartial := func() ResidentResult { | |||
| 85 | + | t.Helper() | |||
| 86 | + | snapshot, ok := manager.SnapshotFor(spec.ID) | |||
| 87 | + | if !ok || snapshot.State != ResidentBudgetExhausted || snapshot.Budget.State != BudgetExceeded || len(manager.UnmetRequired()) != 1 { | |||
| 88 | + | t.Fatalf("partial status = %#v; unmet = %#v", snapshot, manager.UnmetRequired()) | |||
| 89 | + | } | |||
| 90 | + | result, err := manager.Result(spec.ID) | |||
| 91 | + | if err != nil || result.ErrorCode != "budget_exhausted" || !strings.Contains(result.Handoff, "README.md") || len(result.Handoff) > residentHandoffBytes { | |||
| 92 | + | t.Fatalf("saved partial result = %#v, %v", result, err) | |||
| 93 | + | } | |||
| 94 | + | return result | |||
| 95 | + | } | |||
| 96 | + | result := assertBudgetPartial() | |||
| 97 | + | if mode == "worktree" { | |||
| 98 | + | if result.PatchRef != PatchRef(spec.ID) || len(result.ChangedFiles) != 1 { | |||
| 99 | + | t.Fatalf("missing partial patch: %#v", result) | |||
| 100 | + | } | |||
| 101 | + | patch, err := os.ReadFile(filepath.Join(root, spec.ID, residentPatchName)) | |||
| 102 | + | if err != nil || !strings.Contains(string(patch), "partial artifact") { | |||
| 103 | + | t.Fatalf("partial patch = %q, %v", patch, err) | |||
| 104 | + | } | |||
| 105 | + | } | |||
| 106 | + | if restart { | |||
| 107 | + | if err := manager.Close(t.Context()); err != nil { | |||
| 108 | + | t.Fatal(err) | |||
| 109 | + | } | |||
| 110 | + | manager = NewResidentManager(root, factory) | |||
| 111 | + | manager.SetCompletionObserver(func(c ResidentCompletion) { completions <- c }) | |||
| 112 | + | if errs := manager.Reconcile(); len(errs) != 0 { | |||
| 113 | + | t.Fatal(errs) | |||
| 114 | + | } | |||
| 115 | + | assertBudgetPartial() | |||
| 116 | + | } | |||
| 117 | + | if err := manager.Resume(t.Context(), spec.ID, "continue remaining work"); err != nil { | |||
| 118 | + | t.Fatal(err) | |||
| 119 | + | } | |||
| 120 | + | if completion := awaitBudgetCompletion(t, completions); completion.Err != nil { | |||
| 121 | + | t.Fatal(completion.Err) | |||
| 122 | + | } | |||
| 123 | + | if unmet := manager.UnmetRequired(); len(unmet) != 0 { | |||
| 124 | + | t.Fatalf("successful recovery remains unmet: %#v", unmet) | |||
| 125 | + | } | |||
| 126 | + | snapshot, _ := manager.SnapshotFor(spec.ID) | |||
| 127 | + | if snapshot.Budget.Used != 5 || snapshot.Budget.Limit != 100 || snapshot.Usage.InputTokens != 105 { | |||
| 128 | + | t.Fatalf("recovery lost cumulative accounting: %#v", snapshot) | |||
| 129 | + | } | |||
| 130 | + | if err := manager.Close(t.Context()); err != nil { | |||
| 131 | + | t.Fatal(err) | |||
| 132 | + | } | |||
| 133 | + | 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) | |||
| 136 | + | } | |||
| 137 | + | }) | |||
| 138 | + | } | |||
| 139 | + | } | |||
| 140 | + | } | |||
| 141 | + | ||||
| 142 | + | func awaitBudgetCompletion(t *testing.T, completions <-chan ResidentCompletion) ResidentCompletion { | |||
| 143 | + | t.Helper() | |||
| 144 | + | select { | |||
| 145 | + | case completion := <-completions: | |||
| 146 | + | return completion | |||
| 147 | + | case <-time.After(5 * time.Second): | |||
| 148 | + | t.Fatal("no terminal budget notification") | |||
| 149 | + | return ResidentCompletion{} | |||
| 150 | + | } | |||
| 151 | + | } | |||
A packages/agent/subagents/resident_cancellation_test.go +48 -0
| @@ -0,0 +1,48 @@ | |||
| 1 | + | package subagents | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "context" | |
| 5 | + | "errors" | |
| 6 | + | "path/filepath" | |
| 7 | + | "testing" | |
| 8 | + | ) | |
| 9 | + | ||
| 10 | + | func TestResidentCancellationPreservesInterruptedState(t *testing.T) { | |
| 11 | + | for _, turnErr := range []error{context.Canceled, errors.Join(context.Canceled, ErrBudgetExceeded)} { | |
| 12 | + | t.Run(turnErr.Error(), func(t *testing.T) { | |
| 13 | + | root := t.TempDir() | |
| 14 | + | manager := NewResidentManager(root, func(ResidentChildSpec, *ResidentJournal) (ResidentTurnRunner, error) { | |
| 15 | + | return func(context.Context, string) error { return turnErr }, nil | |
| 16 | + | }) | |
| 17 | + | t.Cleanup(func() { _ = manager.Close(context.Background()) }) | |
| 18 | + | spec := ResidentChildSpec{ID: "canceled", InitialTurnID: "initial", SessionID: "session", Provider: "openai", Model: "test", Required: true} | |
| 19 | + | completions := make(chan ResidentCompletion, 1) | |
| 20 | + | manager.SetCompletionObserver(func(c ResidentCompletion) { completions <- c }) | |
| 21 | + | spawnCtx, cancel := context.WithCancel(t.Context()) | |
| 22 | + | defer cancel() | |
| 23 | + | if _, err := manager.Spawn(spawnCtx, spec, "investigate"); err != nil { | |
| 24 | + | t.Fatal(err) | |
| 25 | + | } | |
| 26 | + | cancel() // Accepted children outlive the caller's spawn context. | |
| 27 | + | completion := awaitBudgetCompletion(t, completions) | |
| 28 | + | snapshot, ok := manager.SnapshotFor(spec.ID) | |
| 29 | + | if !ok || snapshot.State != ResidentInterrupted || completion.Completion().Status != string(ResidentInterrupted) { | |
| 30 | + | t.Fatalf("snapshot = %#v, completion = %#v", snapshot, completion) | |
| 31 | + | } | |
| 32 | + | result, err := manager.Result(spec.ID) | |
| 33 | + | if err != nil || result.State != ResidentInterrupted || result.Handoff != "" || result.ErrorCode == residentErrorBudgetExhausted { | |
| 34 | + | t.Fatalf("result = %#v, error = %v", result, err) | |
| 35 | + | } | |
| 36 | + | if len(manager.UnmetRequired()) != 1 { | |
| 37 | + | t.Fatal("cancellation satisfied required work") | |
| 38 | + | } | |
| 39 | + | if err := manager.Close(t.Context()); err != nil { | |
| 40 | + | t.Fatal(err) | |
| 41 | + | } | |
| 42 | + | metadata, err := ReconcileResidentJournal(filepath.Join(root, spec.ID)) | |
| 43 | + | if err != nil || metadata.State != ResidentInterrupted { | |
| 44 | + | t.Fatalf("reconciled metadata = %#v, error = %v", metadata, err) | |
| 45 | + | } | |
| 46 | + | }) | |
| 47 | + | } | |
| 48 | + | } | |
| @@ -0,0 +1,48 @@ | |||||
| 1 | + | package subagents | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "context" | |||
| 5 | + | "errors" | |||
| 6 | + | "path/filepath" | |||
| 7 | + | "testing" | |||
| 8 | + | ) | |||
| 9 | + | ||||
| 10 | + | func TestResidentCancellationPreservesInterruptedState(t *testing.T) { | |||
| 11 | + | for _, turnErr := range []error{context.Canceled, errors.Join(context.Canceled, ErrBudgetExceeded)} { | |||
| 12 | + | t.Run(turnErr.Error(), func(t *testing.T) { | |||
| 13 | + | root := t.TempDir() | |||
| 14 | + | manager := NewResidentManager(root, func(ResidentChildSpec, *ResidentJournal) (ResidentTurnRunner, error) { | |||
| 15 | + | return func(context.Context, string) error { return turnErr }, nil | |||
| 16 | + | }) | |||
| 17 | + | t.Cleanup(func() { _ = manager.Close(context.Background()) }) | |||
| 18 | + | spec := ResidentChildSpec{ID: "canceled", InitialTurnID: "initial", SessionID: "session", Provider: "openai", Model: "test", Required: true} | |||
| 19 | + | completions := make(chan ResidentCompletion, 1) | |||
| 20 | + | manager.SetCompletionObserver(func(c ResidentCompletion) { completions <- c }) | |||
| 21 | + | spawnCtx, cancel := context.WithCancel(t.Context()) | |||
| 22 | + | defer cancel() | |||
| 23 | + | if _, err := manager.Spawn(spawnCtx, spec, "investigate"); err != nil { | |||
| 24 | + | t.Fatal(err) | |||
| 25 | + | } | |||
| 26 | + | cancel() // Accepted children outlive the caller's spawn context. | |||
| 27 | + | completion := awaitBudgetCompletion(t, completions) | |||
| 28 | + | snapshot, ok := manager.SnapshotFor(spec.ID) | |||
| 29 | + | if !ok || snapshot.State != ResidentInterrupted || completion.Completion().Status != string(ResidentInterrupted) { | |||
| 30 | + | t.Fatalf("snapshot = %#v, completion = %#v", snapshot, completion) | |||
| 31 | + | } | |||
| 32 | + | result, err := manager.Result(spec.ID) | |||
| 33 | + | if err != nil || result.State != ResidentInterrupted || result.Handoff != "" || result.ErrorCode == residentErrorBudgetExhausted { | |||
| 34 | + | t.Fatalf("result = %#v, error = %v", result, err) | |||
| 35 | + | } | |||
| 36 | + | if len(manager.UnmetRequired()) != 1 { | |||
| 37 | + | t.Fatal("cancellation satisfied required work") | |||
| 38 | + | } | |||
| 39 | + | if err := manager.Close(t.Context()); err != nil { | |||
| 40 | + | t.Fatal(err) | |||
| 41 | + | } | |||
| 42 | + | metadata, err := ReconcileResidentJournal(filepath.Join(root, spec.ID)) | |||
| 43 | + | if err != nil || metadata.State != ResidentInterrupted { | |||
| 44 | + | t.Fatalf("reconciled metadata = %#v, error = %v", metadata, err) | |||
| 45 | + | } | |||
| 46 | + | }) | |||
| 47 | + | } | |||
| 48 | + | } | |||
M packages/agent/subagents/resident_child.go +21 -18
| @@ -308,7 +308,7 @@ func (c *ResidentChild) Close(ctx context.Context) error { | |||
| 308 | 308 | } | |
| 309 | 309 | c.once.Do(func() { | |
| 310 | 310 | c.mu.Lock() | |
| 311 | - | c.cleanupWorkspace = c.state == ResidentIdle | |
| 311 | + | c.cleanupWorkspace = c.state == ResidentIdle || c.state == ResidentCompleted | |
| 312 | 312 | c.mu.Unlock() | |
| 313 | 313 | c.cancel() | |
| 314 | 314 | }) | |
| @@ -412,35 +412,38 @@ func (c *ResidentChild) run() { | |||
| 412 | 412 | } | |
| 413 | 413 | terminalErr := result.err | |
| 414 | 414 | var capture *WorkspaceCapture | |
| 415 | - | if terminalErr == nil && c.workspace != nil && c.workspace.Mode() == WorkspaceWorktree { | |
| 415 | + | if (terminalErr == nil || errors.Is(terminalErr, ErrBudgetExceeded)) && c.workspace != nil && c.workspace.Mode() == WorkspaceWorktree { | |
| 416 | 416 | captured, err := c.workspace.Capture(c.ctx) | |
| 417 | 417 | if err != nil { | |
| 418 | - | terminalErr = fmt.Errorf("capture resident worktree: %w", err) | |
| 418 | + | terminalErr = errors.Join(terminalErr, fmt.Errorf("capture resident worktree: %w", err)) | |
| 419 | 419 | } else { | |
| 420 | 420 | capture = &captured | |
| 421 | 421 | } | |
| 422 | 422 | } | |
| 423 | + | state := ResidentIdle | |
| 424 | + | if terminalErr != nil { | |
| 425 | + | state = ResidentFailed | |
| 426 | + | if errors.Is(terminalErr, ErrBudgetExceeded) { | |
| 427 | + | state = ResidentBudgetExhausted | |
| 428 | + | } | |
| 429 | + | } | |
| 430 | + | summary := "" | |
| 423 | 431 | persistenceFailed := false | |
| 424 | 432 | if c.journal != nil { | |
| 425 | - | if err := c.journal.RecordTurnFinishedWithCapture(c.spec, result.turnID, terminalErr, capture); err != nil { | |
| 433 | + | projection, err := c.journal.finishTurn(c.spec, result.turnID, terminalErr, capture) | |
| 434 | + | if err != nil { | |
| 426 | 435 | terminalErr = fmt.Errorf("persist resident child terminal state: %w", err) | |
| 427 | - | persistenceFailed = true | |
| 436 | + | state, persistenceFailed = ResidentFailed, true | |
| 437 | + | } else { | |
| 438 | + | state, summary = projection.State, projection.Summary | |
| 439 | + | if projection.Handoff != "" { | |
| 440 | + | summary = projection.Handoff | |
| 441 | + | } | |
| 428 | 442 | } | |
| 429 | 443 | } | |
| 430 | - | if terminalErr != nil { | |
| 431 | - | c.live.Finish(ResidentFailed) | |
| 432 | - | c.setState(ResidentFailed) | |
| 433 | - | } else { | |
| 434 | - | c.live.Finish(ResidentIdle) | |
| 435 | - | c.setState(ResidentIdle) | |
| 436 | - | } | |
| 444 | + | c.live.Finish(state) | |
| 445 | + | c.setState(state) | |
| 437 | 446 | if c.onCompletion != nil { | |
| 438 | - | summary := "" | |
| 439 | - | if c.journal != nil { | |
| 440 | - | if result, err := c.journal.Result(); err == nil { | |
| 441 | - | summary = result.Summary | |
| 442 | - | } | |
| 443 | - | } | |
| 444 | 447 | c.onCompletion(ResidentCompletion{ChildID: c.spec.ID, TurnID: result.turnID, Task: active.prompt, Err: terminalErr, Summary: summary}) | |
| 445 | 448 | if persistenceFailed { | |
| 446 | 449 | for _, pending := range queue { | |
| @@ -308,7 +308,7 @@ func (c *ResidentChild) Close(ctx context.Context) error { | |||||
| 308 | } | 308 | } | ||
| 309 | c.once.Do(func() { | 309 | c.once.Do(func() { | ||
| 310 | c.mu.Lock() | 310 | c.mu.Lock() | ||
| 311 | - | c.cleanupWorkspace = c.state == ResidentIdle | 311 | + | c.cleanupWorkspace = c.state == ResidentIdle || c.state == ResidentCompleted |
| 312 | c.mu.Unlock() | 312 | c.mu.Unlock() | ||
| 313 | c.cancel() | 313 | c.cancel() | ||
| 314 | }) | 314 | }) | ||
| @@ -412,35 +412,38 @@ 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 && c.workspace != nil && c.workspace.Mode() == WorkspaceWorktree { | 415 | + | if (terminalErr == nil || errors.Is(terminalErr, ErrBudgetExceeded)) && 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 = fmt.Errorf("capture resident worktree: %w", err) | 418 | + | terminalErr = errors.Join(terminalErr, fmt.Errorf("capture resident worktree: %w", err)) |
| 419 | } else { | 419 | } else { | ||
| 420 | capture = &captured | 420 | capture = &captured | ||
| 421 | } | 421 | } | ||
| 422 | } | 422 | } | ||
| 423 | + | state := ResidentIdle | |||
| 424 | + | if terminalErr != nil { | |||
| 425 | + | state = ResidentFailed | |||
| 426 | + | if errors.Is(terminalErr, ErrBudgetExceeded) { | |||
| 427 | + | state = ResidentBudgetExhausted | |||
| 428 | + | } | |||
| 429 | + | } | |||
| 430 | + | summary := "" | |||
| 423 | persistenceFailed := false | 431 | persistenceFailed := false | ||
| 424 | if c.journal != nil { | 432 | if c.journal != nil { | ||
| 425 | - | if err := c.journal.RecordTurnFinishedWithCapture(c.spec, result.turnID, terminalErr, capture); err != nil { | 433 | + | projection, err := c.journal.finishTurn(c.spec, result.turnID, terminalErr, capture) |
| 434 | + | if err != nil { | |||
| 426 | terminalErr = fmt.Errorf("persist resident child terminal state: %w", err) | 435 | terminalErr = fmt.Errorf("persist resident child terminal state: %w", err) | ||
| 427 | - | persistenceFailed = true | 436 | + | state, persistenceFailed = ResidentFailed, true |
| 437 | + | } else { | |||
| 438 | + | state, summary = projection.State, projection.Summary | |||
| 439 | + | if projection.Handoff != "" { | |||
| 440 | + | summary = projection.Handoff | |||
| 441 | + | } | |||
| 428 | } | 442 | } | ||
| 429 | } | 443 | } | ||
| 430 | - | if terminalErr != nil { | 444 | + | c.live.Finish(state) |
| 431 | - | c.live.Finish(ResidentFailed) | 445 | + | c.setState(state) |
| 432 | - | c.setState(ResidentFailed) | |||
| 433 | - | } else { | |||
| 434 | - | c.live.Finish(ResidentIdle) | |||
| 435 | - | c.setState(ResidentIdle) | |||
| 436 | - | } | |||
| 437 | if c.onCompletion != nil { | 446 | if c.onCompletion != nil { | ||
| 438 | - | summary := "" | |||
| 439 | - | if c.journal != nil { | |||
| 440 | - | if result, err := c.journal.Result(); err == nil { | |||
| 441 | - | summary = result.Summary | |||
| 442 | - | } | |||
| 443 | - | } | |||
| 444 | c.onCompletion(ResidentCompletion{ChildID: c.spec.ID, TurnID: result.turnID, Task: active.prompt, Err: terminalErr, Summary: summary}) | 447 | c.onCompletion(ResidentCompletion{ChildID: c.spec.ID, TurnID: result.turnID, Task: active.prompt, Err: terminalErr, Summary: summary}) | ||
| 445 | if persistenceFailed { | 448 | if persistenceFailed { | ||
| 446 | for _, pending := range queue { | 449 | for _, pending := range queue { | ||
A packages/agent/subagents/resident_handoff.go +67 -0
| @@ -0,0 +1,67 @@ | |||
| 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 | + | } | |
| @@ -0,0 +1,67 @@ | |||||
| 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 | + | } | |||
A packages/agent/subagents/resident_handoff_test.go +63 -0
| @@ -0,0 +1,63 @@ | |||
| 1 | + | package subagents | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "encoding/json" | |
| 5 | + | "strings" | |
| 6 | + | "testing" | |
| 7 | + | ||
| 8 | + | "github.com/bnema/zut/packages/core" | |
| 9 | + | "github.com/bnema/zut/packages/provider" | |
| 10 | + | ) | |
| 11 | + | ||
| 12 | + | func TestResidentHandoffOmitsRawToolSecrets(t *testing.T) { | |
| 13 | + | journal, err := OpenResidentJournal(t.TempDir(), "handoff-secrets") | |
| 14 | + | if err != nil { | |
| 15 | + | t.Fatal(err) | |
| 16 | + | } | |
| 17 | + | t.Cleanup(func() { _ = journal.Close() }) | |
| 18 | + | spec := ResidentChildSpec{ID: "handoff-secrets", SessionID: "session", InitialTurnID: "turn", Provider: "openai", Model: "test"} | |
| 19 | + | if err := journal.Accept(spec, "check service"); err != nil { | |
| 20 | + | t.Fatal(err) | |
| 21 | + | } | |
| 22 | + | if err := journal.RecordTurnStarted(spec, spec.InitialTurnID); err != nil { | |
| 23 | + | t.Fatal(err) | |
| 24 | + | } | |
| 25 | + | args := json.RawMessage(`{"command":"curl -H 'Authorization: Bearer synthetic-argument-secret' localhost"}`) | |
| 26 | + | for _, event := range []core.AgentEvent{ | |
| 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}}}}, | |
| 28 | + | 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"}}}}, | |
| 30 | + | } { | |
| 31 | + | if err := journal.RecordAgentEvent(event); err != nil { | |
| 32 | + | t.Fatal(err) | |
| 33 | + | } | |
| 34 | + | } | |
| 35 | + | if err := journal.RecordTurnFinished(spec, spec.InitialTurnID, ErrBudgetExceeded); err != nil { | |
| 36 | + | t.Fatal(err) | |
| 37 | + | } | |
| 38 | + | result, err := journal.Result() | |
| 39 | + | if err != nil { | |
| 40 | + | t.Fatal(err) | |
| 41 | + | } | |
| 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 | + | } | |
| 53 | + | } | |
| 54 | + | } | |
| 55 | + | items, err := ReadResidentHistory(journal.Dir(), 16) | |
| 56 | + | if err != nil { | |
| 57 | + | t.Fatal(err) | |
| 58 | + | } | |
| 59 | + | raw, err := json.Marshal(items) | |
| 60 | + | 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") | |
| 62 | + | } | |
| 63 | + | } | |
| @@ -0,0 +1,63 @@ | |||||
| 1 | + | package subagents | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "encoding/json" | |||
| 5 | + | "strings" | |||
| 6 | + | "testing" | |||
| 7 | + | ||||
| 8 | + | "github.com/bnema/zut/packages/core" | |||
| 9 | + | "github.com/bnema/zut/packages/provider" | |||
| 10 | + | ) | |||
| 11 | + | ||||
| 12 | + | func TestResidentHandoffOmitsRawToolSecrets(t *testing.T) { | |||
| 13 | + | journal, err := OpenResidentJournal(t.TempDir(), "handoff-secrets") | |||
| 14 | + | if err != nil { | |||
| 15 | + | t.Fatal(err) | |||
| 16 | + | } | |||
| 17 | + | t.Cleanup(func() { _ = journal.Close() }) | |||
| 18 | + | spec := ResidentChildSpec{ID: "handoff-secrets", SessionID: "session", InitialTurnID: "turn", Provider: "openai", Model: "test"} | |||
| 19 | + | if err := journal.Accept(spec, "check service"); err != nil { | |||
| 20 | + | t.Fatal(err) | |||
| 21 | + | } | |||
| 22 | + | if err := journal.RecordTurnStarted(spec, spec.InitialTurnID); err != nil { | |||
| 23 | + | t.Fatal(err) | |||
| 24 | + | } | |||
| 25 | + | args := json.RawMessage(`{"command":"curl -H 'Authorization: Bearer synthetic-argument-secret' localhost"}`) | |||
| 26 | + | for _, event := range []core.AgentEvent{ | |||
| 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}}}}, | |||
| 28 | + | 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"}}}}, | |||
| 30 | + | } { | |||
| 31 | + | if err := journal.RecordAgentEvent(event); err != nil { | |||
| 32 | + | t.Fatal(err) | |||
| 33 | + | } | |||
| 34 | + | } | |||
| 35 | + | if err := journal.RecordTurnFinished(spec, spec.InitialTurnID, ErrBudgetExceeded); err != nil { | |||
| 36 | + | t.Fatal(err) | |||
| 37 | + | } | |||
| 38 | + | result, err := journal.Result() | |||
| 39 | + | if err != nil { | |||
| 40 | + | t.Fatal(err) | |||
| 41 | + | } | |||
| 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 | + | } | |||
| 53 | + | } | |||
| 54 | + | } | |||
| 55 | + | items, err := ReadResidentHistory(journal.Dir(), 16) | |||
| 56 | + | if err != nil { | |||
| 57 | + | t.Fatal(err) | |||
| 58 | + | } | |||
| 59 | + | raw, err := json.Marshal(items) | |||
| 60 | + | 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") | |||
| 62 | + | } | |||
| 63 | + | } | |||
M packages/agent/subagents/resident_journal.go +136 -58
| @@ -2,6 +2,7 @@ package subagents | |||
| 2 | 2 | ||
| 3 | 3 | import ( | |
| 4 | 4 | "bufio" | |
| 5 | + | "context" | |
| 5 | 6 | "encoding/json" | |
| 6 | 7 | "errors" | |
| 7 | 8 | "fmt" | |
| @@ -53,13 +54,25 @@ func PatchRef(id string) string { return AgentRef(id) + "/patch" } | |||
| 53 | 54 | type ResidentState string | |
| 54 | 55 | ||
| 55 | 56 | const ( | |
| 56 | - | ResidentQueued ResidentState = "queued" | |
| 57 | - | ResidentRunning ResidentState = "running" | |
| 58 | - | ResidentIdle ResidentState = "idle" | |
| 59 | - | ResidentCompleted ResidentState = "completed" | |
| 60 | - | ResidentFailed ResidentState = "failed" | |
| 61 | - | ResidentStopped ResidentState = "stopped" | |
| 62 | - | ResidentInterrupted ResidentState = "interrupted" | |
| 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" | |
| 65 | + | ) | |
| 66 | + | ||
| 67 | + | // Journal outcomes and result error codes are persisted compatibility values. | |
| 68 | + | 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) | |
| 63 | 76 | ) | |
| 64 | 77 | ||
| 65 | 78 | // ResidentChildSpec is the non-secret configuration required to reconstruct | |
| @@ -99,23 +112,26 @@ type ResidentChildSpec struct { | |||
| 99 | 112 | } | |
| 100 | 113 | ||
| 101 | 114 | type residentRecord struct { | |
| 102 | - | Version int `json:"version"` | |
| 103 | - | Type string `json:"type"` | |
| 104 | - | Time time.Time `json:"time"` | |
| 105 | - | Spec *ResidentChildSpec `json:"spec,omitempty"` | |
| 106 | - | TurnID string `json:"turn_id,omitempty"` | |
| 107 | - | Prompt string `json:"prompt,omitempty"` | |
| 108 | - | Outcome string `json:"outcome,omitempty"` | |
| 109 | - | Message json.RawMessage `json:"message,omitempty"` | |
| 110 | - | ToolID string `json:"tool_id,omitempty"` | |
| 111 | - | ToolName string `json:"tool_name,omitempty"` | |
| 112 | - | ToolArgs json.RawMessage `json:"tool_args,omitempty"` | |
| 113 | - | ToolResult json.RawMessage `json:"tool_result,omitempty"` | |
| 114 | - | Usage *provider.Usage `json:"usage,omitempty"` | |
| 115 | - | ContextUsed int `json:"context_used,omitempty"` | |
| 116 | - | ContextMax int `json:"context_max,omitempty"` | |
| 117 | - | Subscription bool `json:"subscription,omitempty"` | |
| 118 | - | raw json.RawMessage `json:"-"` | |
| 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:"-"` | |
| 119 | 135 | } | |
| 120 | 136 | ||
| 121 | 137 | // RecordAgentEvent persists finalized provider-neutral transcript events. It | |
| @@ -229,6 +245,7 @@ type ResidentMetadata struct { | |||
| 229 | 245 | State ResidentState `json:"state"` | |
| 230 | 246 | UpdatedAt time.Time `json:"updated_at"` | |
| 231 | 247 | Usage provider.Usage `json:"usage,omitempty"` | |
| 248 | + | BudgetBaseline int64 `json:"budget_baseline,omitempty"` | |
| 232 | 249 | ContextUsed int `json:"context_used,omitempty"` | |
| 233 | 250 | ContextMax int `json:"context_max,omitempty"` | |
| 234 | 251 | Subscription bool `json:"subscription,omitempty"` | |
| @@ -243,6 +260,7 @@ type ResidentResult struct { | |||
| 243 | 260 | TurnID string `json:"turn_id"` | |
| 244 | 261 | State ResidentState `json:"state"` | |
| 245 | 262 | Summary string `json:"summary,omitempty"` | |
| 263 | + | Handoff string `json:"handoff,omitempty"` | |
| 246 | 264 | ErrorCode string `json:"error_code,omitempty"` | |
| 247 | 265 | PatchRef string `json:"patch_ref,omitempty"` | |
| 248 | 266 | ChangedFiles []string `json:"changed_files,omitempty"` | |
| @@ -259,6 +277,7 @@ type ResidentJournal struct { | |||
| 259 | 277 | latestSummary string | |
| 260 | 278 | usageMu sync.RWMutex | |
| 261 | 279 | usage provider.Usage | |
| 280 | + | budgetBaseline int64 | |
| 262 | 281 | usageRecordPersisted bool | |
| 263 | 282 | usageMetadataPersisted bool | |
| 264 | 283 | contextUsed int | |
| @@ -272,10 +291,11 @@ type ResidentJournal struct { | |||
| 272 | 291 | // ResidentUsageSnapshot is the bounded usage projection shared by durable and | |
| 273 | 292 | // live resident state. | |
| 274 | 293 | type ResidentUsageSnapshot struct { | |
| 275 | - | Usage provider.Usage | |
| 276 | - | ContextUsed int | |
| 277 | - | ContextMax int | |
| 278 | - | Subscription bool | |
| 294 | + | Usage provider.Usage | |
| 295 | + | ContextUsed int | |
| 296 | + | ContextMax int | |
| 297 | + | Subscription bool | |
| 298 | + | BudgetBaseline int64 | |
| 279 | 299 | } | |
| 280 | 300 | ||
| 281 | 301 | // ConfigureUsage records resolved model metadata and restores the latest | |
| @@ -287,11 +307,12 @@ func (j *ResidentJournal) ConfigureUsage(contextMax int, subscription bool) Resi | |||
| 287 | 307 | j.usageMu.Lock() | |
| 288 | 308 | if metadata, err := ReadResidentMetadata(filepath.Join(j.dir, residentMetadataName)); err == nil { | |
| 289 | 309 | j.usage = metadata.Usage | |
| 310 | + | j.budgetBaseline = metadata.BudgetBaseline | |
| 290 | 311 | j.contextUsed = metadata.ContextUsed | |
| 291 | 312 | } | |
| 292 | 313 | j.contextMax = contextMax | |
| 293 | 314 | j.subscription = subscription | |
| 294 | - | snapshot := ResidentUsageSnapshot{Usage: j.usage, ContextUsed: j.contextUsed, ContextMax: j.contextMax, Subscription: j.subscription} | |
| 315 | + | snapshot := ResidentUsageSnapshot{Usage: j.usage, ContextUsed: j.contextUsed, ContextMax: j.contextMax, Subscription: j.subscription, BudgetBaseline: j.budgetBaseline} | |
| 295 | 316 | j.usageMu.Unlock() | |
| 296 | 317 | return snapshot | |
| 297 | 318 | } | |
| @@ -302,7 +323,12 @@ func (j *ResidentJournal) usageSnapshot() ResidentUsageSnapshot { | |||
| 302 | 323 | } | |
| 303 | 324 | j.usageMu.RLock() | |
| 304 | 325 | defer j.usageMu.RUnlock() | |
| 305 | - | return ResidentUsageSnapshot{Usage: j.usage, ContextUsed: j.contextUsed, ContextMax: j.contextMax, Subscription: j.subscription} | |
| 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 | |
| 306 | 332 | } | |
| 307 | 333 | ||
| 308 | 334 | func (j *ResidentJournal) usageRecordPersistence(record residentRecord) (durable, metadataPersisted bool) { | |
| @@ -439,14 +465,26 @@ func (j *ResidentJournal) AcceptFollowUp(spec ResidentChildSpec, turnID, prompt | |||
| 439 | 465 | return errors.New("resident journal: closed") | |
| 440 | 466 | } | |
| 441 | 467 | now := time.Now().UTC() | |
| 442 | - | if err := j.appendSync(residentRecord{Version: residentJournalVersion, Type: residentRecordTurnAccepted, Time: now, TurnID: turnID, Prompt: prompt}); err != nil { | |
| 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 { | |
| 443 | 478 | return err | |
| 444 | 479 | } | |
| 480 | + | j.usageMu.Lock() | |
| 481 | + | j.budgetBaseline = baseline | |
| 482 | + | j.usageMu.Unlock() | |
| 445 | 483 | return writeResidentMetadata(j.dir, j.metadata(spec, ResidentQueued, now)) | |
| 446 | 484 | } | |
| 447 | 485 | ||
| 448 | 486 | func (j *ResidentJournal) RecordTurnStarted(spec ResidentChildSpec, turnID string) error { | |
| 449 | - | if err := j.recordTurnBoundary(spec, residentRecordTurnStarted, turnID, ResidentRunning, ""); err != nil { | |
| 487 | + | if err := j.recordTurnBoundary(spec, ResidentRunning, residentRecord{Type: residentRecordTurnStarted, TurnID: turnID}); err != nil { | |
| 450 | 488 | return err | |
| 451 | 489 | } | |
| 452 | 490 | j.summaryMu.Lock() | |
| @@ -462,27 +500,53 @@ func (j *ResidentJournal) RecordTurnFinished(spec ResidentChildSpec, turnID stri | |||
| 462 | 500 | // RecordTurnFinishedWithCapture publishes bounded workspace artifacts before | |
| 463 | 501 | // the terminal projection. The transcript remains the lifecycle authority. | |
| 464 | 502 | func (j *ResidentJournal) RecordTurnFinishedWithCapture(spec ResidentChildSpec, turnID string, turnErr error, capture *WorkspaceCapture) error { | |
| 465 | - | state, outcome := ResidentIdle, "completed" | |
| 503 | + | _, err := j.finishTurn(spec, turnID, turnErr, capture) | |
| 504 | + | return err | |
| 505 | + | } | |
| 506 | + | ||
| 507 | + | // finishTurn returns the exact persisted projection to the live child, avoiding | |
| 508 | + | // a second filesystem read at the terminal notification boundary. | |
| 509 | + | func (j *ResidentJournal) finishTurn(spec ResidentChildSpec, turnID string, turnErr error, capture *WorkspaceCapture) (ResidentResult, error) { | |
| 510 | + | state, outcome := ResidentIdle, residentOutcomeCompleted | |
| 466 | 511 | if turnErr != nil { | |
| 467 | - | state, outcome = ResidentFailed, "failed" | |
| 512 | + | state, outcome = ResidentFailed, residentOutcomeFailed | |
| 513 | + | } | |
| 514 | + | recordType := residentRecordTurnFinished | |
| 515 | + | if errors.Is(turnErr, context.Canceled) { | |
| 516 | + | state, outcome = ResidentInterrupted, residentOutcomeInterrupted | |
| 517 | + | 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 | |
| 468 | 522 | } | |
| 469 | 523 | result := ResidentResult{Version: residentJournalVersion, ID: spec.ID, TurnID: turnID, State: state, Summary: j.latestAssistantSummary(), CreatedAt: time.Now().UTC()} | |
| 470 | 524 | if turnErr != nil { | |
| 471 | - | result.ErrorCode = "turn_failed" | |
| 525 | + | result.ErrorCode = residentErrorTurnFailed | |
| 526 | + | } | |
| 527 | + | if state == ResidentBudgetExhausted { | |
| 528 | + | result.ErrorCode = residentErrorBudgetExhausted | |
| 529 | + | result.Handoff = residentBudgetHandoff(j.dir, spec) | |
| 472 | 530 | } | |
| 473 | 531 | if capture != nil { | |
| 474 | 532 | result.ChangedFiles = append([]string(nil), capture.ChangedFiles...) | |
| 475 | 533 | if len(capture.Patch) > 0 { | |
| 476 | 534 | if err := writeResidentPatch(j.dir, capture.Patch); err != nil { | |
| 477 | - | return fmt.Errorf("resident journal write workspace patch: %w", err) | |
| 535 | + | return ResidentResult{}, fmt.Errorf("resident journal write workspace patch: %w", err) | |
| 478 | 536 | } | |
| 479 | 537 | result.PatchRef = PatchRef(spec.ID) | |
| 480 | 538 | } | |
| 481 | 539 | } | |
| 482 | - | if recordErr := j.recordTurnBoundary(spec, residentRecordTurnFinished, turnID, state, outcome); recordErr != nil { | |
| 483 | - | return recordErr | |
| 540 | + | // Persist artifact metadata with the terminal boundary so result.json can | |
| 541 | + | // be rebuilt without guessing which turn produced the patch on disk. | |
| 542 | + | record := residentRecord{Type: recordType, TurnID: turnID, Outcome: outcome, PatchRef: result.PatchRef, ChangedFiles: result.ChangedFiles} | |
| 543 | + | if recordErr := j.recordTurnBoundary(spec, state, record); recordErr != nil { | |
| 544 | + | return ResidentResult{}, recordErr | |
| 484 | 545 | } | |
| 485 | - | return writeResidentResult(j.dir, result) | |
| 546 | + | if err := writeResidentResult(j.dir, result); err != nil { | |
| 547 | + | return ResidentResult{}, err | |
| 548 | + | } | |
| 549 | + | return result, nil | |
| 486 | 550 | } | |
| 487 | 551 | ||
| 488 | 552 | func (j *ResidentJournal) recordAssistantSummary(message provider.Message) { | |
| @@ -537,11 +601,11 @@ func truncateResidentResultSummary(text string) string { | |||
| 537 | 601 | } | |
| 538 | 602 | ||
| 539 | 603 | func (j *ResidentJournal) RecordTurnInterrupted(spec ResidentChildSpec, turnID string) error { | |
| 540 | - | return j.recordTurnBoundary(spec, residentRecordInterrupted, turnID, ResidentInterrupted, "interrupted") | |
| 604 | + | return j.recordTurnBoundary(spec, ResidentInterrupted, residentRecord{Type: residentRecordInterrupted, TurnID: turnID, Outcome: residentOutcomeInterrupted}) | |
| 541 | 605 | } | |
| 542 | 606 | ||
| 543 | - | func (j *ResidentJournal) recordTurnBoundary(spec ResidentChildSpec, recordType, turnID string, state ResidentState, outcome string) error { | |
| 544 | - | if strings.TrimSpace(turnID) == "" { | |
| 607 | + | func (j *ResidentJournal) recordTurnBoundary(spec ResidentChildSpec, state ResidentState, record residentRecord) error { | |
| 608 | + | if strings.TrimSpace(record.TurnID) == "" { | |
| 545 | 609 | return errors.New("resident journal: missing turn ID") | |
| 546 | 610 | } | |
| 547 | 611 | j.mu.Lock() | |
| @@ -549,11 +613,11 @@ func (j *ResidentJournal) recordTurnBoundary(spec ResidentChildSpec, recordType, | |||
| 549 | 613 | if j.file == nil { | |
| 550 | 614 | return errors.New("resident journal: closed") | |
| 551 | 615 | } | |
| 552 | - | now := time.Now().UTC() | |
| 553 | - | if err := j.appendSync(residentRecord{Version: residentJournalVersion, Type: recordType, Time: now, TurnID: turnID, Outcome: outcome}); err != nil { | |
| 616 | + | record.Version, record.Time = residentJournalVersion, time.Now().UTC() | |
| 617 | + | if err := j.appendSync(record); err != nil { | |
| 554 | 618 | return err | |
| 555 | 619 | } | |
| 556 | - | return writeResidentMetadata(j.dir, j.metadata(spec, state, now)) | |
| 620 | + | return writeResidentMetadata(j.dir, j.metadata(spec, state, record.Time)) | |
| 557 | 621 | } | |
| 558 | 622 | ||
| 559 | 623 | func (j *ResidentJournal) metadata(spec ResidentChildSpec, state ResidentState, updatedAt time.Time) ResidentMetadata { | |
| @@ -561,7 +625,7 @@ func (j *ResidentJournal) metadata(spec ResidentChildSpec, state ResidentState, | |||
| 561 | 625 | return ResidentMetadata{ | |
| 562 | 626 | Version: residentJournalVersion, ID: spec.ID, SessionID: spec.SessionID, | |
| 563 | 627 | RootCacheID: spec.RootCacheID, ParentSessionID: spec.ParentSessionID, State: state, UpdatedAt: updatedAt, | |
| 564 | - | Usage: usage.Usage, ContextUsed: usage.ContextUsed, ContextMax: usage.ContextMax, Subscription: usage.Subscription, | |
| 628 | + | Usage: usage.Usage, ContextUsed: usage.ContextUsed, ContextMax: usage.ContextMax, Subscription: usage.Subscription, BudgetBaseline: usage.BudgetBaseline, | |
| 565 | 629 | } | |
| 566 | 630 | } | |
| 567 | 631 | ||
| @@ -758,7 +822,8 @@ func reconcileOwnedResidentJournal(journal *ResidentJournal) (ResidentMetadata, | |||
| 758 | 822 | } | |
| 759 | 823 | state := ResidentQueued | |
| 760 | 824 | lastStateAt := records[0].Time | |
| 761 | - | lastFinishedTurn, lastFinishedOutcome, lastAssistantSummary := "", "", "" | |
| 825 | + | var lastFinished residentRecord | |
| 826 | + | lastAssistantSummary := "" | |
| 762 | 827 | usage := ResidentUsageSnapshot{} | |
| 763 | 828 | seenTurns := make(map[string]string) | |
| 764 | 829 | // The initial prompt is accepted atomically with child.accepted rather than | |
| @@ -779,6 +844,7 @@ func reconcileOwnedResidentJournal(journal *ResidentJournal) (ResidentMetadata, | |||
| 779 | 844 | return ResidentMetadata{}, errors.New("resident journal: invalid accepted turn record") | |
| 780 | 845 | } | |
| 781 | 846 | seenTurns[record.TurnID] = residentRecordTurnAccepted | |
| 847 | + | usage.BudgetBaseline = record.BudgetBaseline | |
| 782 | 848 | lastStateAt = record.Time | |
| 783 | 849 | case residentRecordTurnStarted: | |
| 784 | 850 | if record.TurnID == "" || (seenTurns[record.TurnID] != residentRecordTurnAccepted && record.TurnID != spec.InitialTurnID) { | |
| @@ -793,10 +859,15 @@ func reconcileOwnedResidentJournal(journal *ResidentJournal) (ResidentMetadata, | |||
| 793 | 859 | return ResidentMetadata{}, errors.New("resident journal: turn finished without start") | |
| 794 | 860 | } | |
| 795 | 861 | seenTurns[record.TurnID] = residentRecordTurnFinished | |
| 796 | - | lastFinishedTurn, lastFinishedOutcome = record.TurnID, record.Outcome | |
| 797 | - | if record.Outcome == "failed" { | |
| 862 | + | lastFinished = record | |
| 863 | + | switch record.Outcome { | |
| 864 | + | case residentOutcomeFailed: | |
| 798 | 865 | state = ResidentFailed | |
| 799 | - | } else { | |
| 866 | + | case residentOutcomeBudgetExhausted: | |
| 867 | + | state = ResidentBudgetExhausted | |
| 868 | + | case residentOutcomeCompletedBudgetExhausted: | |
| 869 | + | state = ResidentCompleted | |
| 870 | + | default: | |
| 800 | 871 | state = ResidentIdle | |
| 801 | 872 | } | |
| 802 | 873 | lastStateAt = record.Time | |
| @@ -843,7 +914,7 @@ func reconcileOwnedResidentJournal(journal *ResidentJournal) (ResidentMetadata, | |||
| 843 | 914 | if record.Usage == nil { | |
| 844 | 915 | return ResidentMetadata{}, errors.New("resident journal: invalid usage record") | |
| 845 | 916 | } | |
| 846 | - | usage = ResidentUsageSnapshot{Usage: *record.Usage, ContextUsed: record.ContextUsed, ContextMax: record.ContextMax, Subscription: record.Subscription} | |
| 917 | + | usage = ResidentUsageSnapshot{Usage: *record.Usage, ContextUsed: record.ContextUsed, ContextMax: record.ContextMax, Subscription: record.Subscription, BudgetBaseline: usage.BudgetBaseline} | |
| 847 | 918 | } | |
| 848 | 919 | } | |
| 849 | 920 | if lastStateAt.IsZero() { | |
| @@ -852,7 +923,7 @@ func reconcileOwnedResidentJournal(journal *ResidentJournal) (ResidentMetadata, | |||
| 852 | 923 | metadata := ResidentMetadata{ | |
| 853 | 924 | Version: residentJournalVersion, ID: spec.ID, SessionID: spec.SessionID, | |
| 854 | 925 | RootCacheID: spec.RootCacheID, ParentSessionID: spec.ParentSessionID, State: state, UpdatedAt: lastStateAt, | |
| 855 | - | Usage: usage.Usage, ContextUsed: usage.ContextUsed, ContextMax: usage.ContextMax, Subscription: usage.Subscription, | |
| 926 | + | Usage: usage.Usage, ContextUsed: usage.ContextUsed, ContextMax: usage.ContextMax, Subscription: usage.Subscription, BudgetBaseline: usage.BudgetBaseline, | |
| 856 | 927 | } | |
| 857 | 928 | needsInterruption := state == ResidentQueued || state == ResidentRunning | |
| 858 | 929 | needsToolRepair := len(toolCalls) != len(toolResults) | |
| @@ -880,7 +951,7 @@ func reconcileOwnedResidentJournal(journal *ResidentJournal) (ResidentMetadata, | |||
| 880 | 951 | if err := writeResidentMetadata(dir, metadata); err != nil { | |
| 881 | 952 | return ResidentMetadata{}, err | |
| 882 | 953 | } | |
| 883 | - | if err := rebuildResidentResult(dir, spec, metadata, lastFinishedTurn, lastFinishedOutcome, lastAssistantSummary); err != nil { | |
| 954 | + | if err := rebuildResidentResult(dir, spec, metadata, lastFinished, lastAssistantSummary); err != nil { | |
| 884 | 955 | return ResidentMetadata{}, err | |
| 885 | 956 | } | |
| 886 | 957 | return metadata, nil | |
| @@ -911,7 +982,7 @@ func reconcileOwnedResidentJournal(journal *ResidentJournal) (ResidentMetadata, | |||
| 911 | 982 | if err := writeResidentMetadata(dir, metadata); err != nil { | |
| 912 | 983 | return ResidentMetadata{}, err | |
| 913 | 984 | } | |
| 914 | - | if err := rebuildResidentResult(dir, spec, metadata, lastFinishedTurn, lastFinishedOutcome, lastAssistantSummary); err != nil { | |
| 985 | + | if err := rebuildResidentResult(dir, spec, metadata, lastFinished, lastAssistantSummary); err != nil { | |
| 915 | 986 | return ResidentMetadata{}, err | |
| 916 | 987 | } | |
| 917 | 988 | return metadata, nil | |
| @@ -1080,7 +1151,8 @@ func (j *ResidentJournal) rewriteTranscript(records []residentRecord) error { | |||
| 1080 | 1151 | return nil | |
| 1081 | 1152 | } | |
| 1082 | 1153 | ||
| 1083 | - | func rebuildResidentResult(dir string, spec ResidentChildSpec, metadata ResidentMetadata, turnID, outcome, summary string) error { | |
| 1154 | + | func rebuildResidentResult(dir string, spec ResidentChildSpec, metadata ResidentMetadata, finished residentRecord, summary string) error { | |
| 1155 | + | turnID := finished.TurnID | |
| 1084 | 1156 | if turnID == "" { | |
| 1085 | 1157 | return nil | |
| 1086 | 1158 | } | |
| @@ -1092,9 +1164,15 @@ func rebuildResidentResult(dir string, spec ResidentChildSpec, metadata Resident | |||
| 1092 | 1164 | return fmt.Errorf("resident journal remove corrupt result projection: %w", removeErr) | |
| 1093 | 1165 | } | |
| 1094 | 1166 | } | |
| 1095 | - | result := ResidentResult{Version: residentJournalVersion, ID: spec.ID, TurnID: turnID, State: metadata.State, Summary: summary, CreatedAt: metadata.UpdatedAt} | |
| 1096 | - | if outcome == "failed" { | |
| 1097 | - | result.ErrorCode = "turn_failed" | |
| 1167 | + | result := ResidentResult{Version: residentJournalVersion, ID: spec.ID, TurnID: turnID, State: metadata.State, Summary: summary, CreatedAt: metadata.UpdatedAt, | |
| 1168 | + | PatchRef: finished.PatchRef, ChangedFiles: append([]string(nil), finished.ChangedFiles...), | |
| 1169 | + | } | |
| 1170 | + | switch finished.Outcome { | |
| 1171 | + | case residentOutcomeFailed: | |
| 1172 | + | result.ErrorCode = residentErrorTurnFailed | |
| 1173 | + | case residentOutcomeBudgetExhausted: | |
| 1174 | + | result.ErrorCode = residentErrorBudgetExhausted | |
| 1175 | + | result.Handoff = residentBudgetHandoff(dir, spec) | |
| 1098 | 1176 | } | |
| 1099 | 1177 | return writeResidentResult(dir, result) | |
| 1100 | 1178 | } | |
| @@ -2,6 +2,7 @@ package subagents | |||||
| 2 | 2 | ||||
| 3 | import ( | 3 | import ( | ||
| 4 | "bufio" | 4 | "bufio" | ||
| 5 | + | "context" | |||
| 5 | "encoding/json" | 6 | "encoding/json" | ||
| 6 | "errors" | 7 | "errors" | ||
| 7 | "fmt" | 8 | "fmt" | ||
| @@ -53,13 +54,25 @@ func PatchRef(id string) string { return AgentRef(id) + "/patch" } | |||||
| 53 | type ResidentState string | 54 | type ResidentState string | ||
| 54 | 55 | ||||
| 55 | const ( | 56 | const ( | ||
| 56 | - | ResidentQueued ResidentState = "queued" | 57 | + | ResidentQueued ResidentState = "queued" |
| 57 | - | ResidentRunning ResidentState = "running" | 58 | + | ResidentRunning ResidentState = "running" |
| 58 | - | ResidentIdle ResidentState = "idle" | 59 | + | ResidentIdle ResidentState = "idle" |
| 59 | - | ResidentCompleted ResidentState = "completed" | 60 | + | ResidentCompleted ResidentState = "completed" |
| 60 | - | ResidentFailed ResidentState = "failed" | 61 | + | ResidentFailed ResidentState = "failed" |
| 61 | - | ResidentStopped ResidentState = "stopped" | 62 | + | ResidentStopped ResidentState = "stopped" |
| 62 | - | ResidentInterrupted ResidentState = "interrupted" | 63 | + | ResidentInterrupted ResidentState = "interrupted" |
| 64 | + | ResidentBudgetExhausted ResidentState = "budget_exhausted" | |||
| 65 | + | ) | |||
| 66 | + | ||||
| 67 | + | // Journal outcomes and result error codes are persisted compatibility values. | |||
| 68 | + | 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) | |||
| 63 | ) | 76 | ) | ||
| 64 | 77 | ||||
| 65 | // ResidentChildSpec is the non-secret configuration required to reconstruct | 78 | // ResidentChildSpec is the non-secret configuration required to reconstruct | ||
| @@ -99,23 +112,26 @@ type ResidentChildSpec struct { | |||||
| 99 | } | 112 | } | ||
| 100 | 113 | ||||
| 101 | type residentRecord struct { | 114 | type residentRecord struct { | ||
| 102 | - | Version int `json:"version"` | 115 | + | Version int `json:"version"` |
| 103 | - | Type string `json:"type"` | 116 | + | Type string `json:"type"` |
| 104 | - | Time time.Time `json:"time"` | 117 | + | Time time.Time `json:"time"` |
| 105 | - | Spec *ResidentChildSpec `json:"spec,omitempty"` | 118 | + | Spec *ResidentChildSpec `json:"spec,omitempty"` |
| 106 | - | TurnID string `json:"turn_id,omitempty"` | 119 | + | TurnID string `json:"turn_id,omitempty"` |
| 107 | - | Prompt string `json:"prompt,omitempty"` | 120 | + | Prompt string `json:"prompt,omitempty"` |
| 108 | - | Outcome string `json:"outcome,omitempty"` | 121 | + | Outcome string `json:"outcome,omitempty"` |
| 109 | - | Message json.RawMessage `json:"message,omitempty"` | 122 | + | PatchRef string `json:"patch_ref,omitempty"` |
| 110 | - | ToolID string `json:"tool_id,omitempty"` | 123 | + | ChangedFiles []string `json:"changed_files,omitempty"` |
| 111 | - | ToolName string `json:"tool_name,omitempty"` | 124 | + | BudgetBaseline int64 `json:"budget_baseline,omitempty"` |
| 112 | - | ToolArgs json.RawMessage `json:"tool_args,omitempty"` | 125 | + | Message json.RawMessage `json:"message,omitempty"` |
| 113 | - | ToolResult json.RawMessage `json:"tool_result,omitempty"` | 126 | + | ToolID string `json:"tool_id,omitempty"` |
| 114 | - | Usage *provider.Usage `json:"usage,omitempty"` | 127 | + | ToolName string `json:"tool_name,omitempty"` |
| 115 | - | ContextUsed int `json:"context_used,omitempty"` | 128 | + | ToolArgs json.RawMessage `json:"tool_args,omitempty"` |
| 116 | - | ContextMax int `json:"context_max,omitempty"` | 129 | + | ToolResult json.RawMessage `json:"tool_result,omitempty"` |
| 117 | - | Subscription bool `json:"subscription,omitempty"` | 130 | + | Usage *provider.Usage `json:"usage,omitempty"` |
| 118 | - | raw json.RawMessage `json:"-"` | 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:"-"` | |||
| 119 | } | 135 | } | ||
| 120 | 136 | ||||
| 121 | // RecordAgentEvent persists finalized provider-neutral transcript events. It | 137 | // RecordAgentEvent persists finalized provider-neutral transcript events. It | ||
| @@ -229,6 +245,7 @@ type ResidentMetadata struct { | |||||
| 229 | State ResidentState `json:"state"` | 245 | State ResidentState `json:"state"` | ||
| 230 | UpdatedAt time.Time `json:"updated_at"` | 246 | UpdatedAt time.Time `json:"updated_at"` | ||
| 231 | Usage provider.Usage `json:"usage,omitempty"` | 247 | Usage provider.Usage `json:"usage,omitempty"` | ||
| 248 | + | BudgetBaseline int64 `json:"budget_baseline,omitempty"` | |||
| 232 | ContextUsed int `json:"context_used,omitempty"` | 249 | ContextUsed int `json:"context_used,omitempty"` | ||
| 233 | ContextMax int `json:"context_max,omitempty"` | 250 | ContextMax int `json:"context_max,omitempty"` | ||
| 234 | Subscription bool `json:"subscription,omitempty"` | 251 | Subscription bool `json:"subscription,omitempty"` | ||
| @@ -243,6 +260,7 @@ type ResidentResult struct { | |||||
| 243 | TurnID string `json:"turn_id"` | 260 | TurnID string `json:"turn_id"` | ||
| 244 | State ResidentState `json:"state"` | 261 | State ResidentState `json:"state"` | ||
| 245 | Summary string `json:"summary,omitempty"` | 262 | Summary string `json:"summary,omitempty"` | ||
| 263 | + | Handoff string `json:"handoff,omitempty"` | |||
| 246 | ErrorCode string `json:"error_code,omitempty"` | 264 | ErrorCode string `json:"error_code,omitempty"` | ||
| 247 | PatchRef string `json:"patch_ref,omitempty"` | 265 | PatchRef string `json:"patch_ref,omitempty"` | ||
| 248 | ChangedFiles []string `json:"changed_files,omitempty"` | 266 | ChangedFiles []string `json:"changed_files,omitempty"` | ||
| @@ -259,6 +277,7 @@ type ResidentJournal struct { | |||||
| 259 | latestSummary string | 277 | latestSummary string | ||
| 260 | usageMu sync.RWMutex | 278 | usageMu sync.RWMutex | ||
| 261 | usage provider.Usage | 279 | usage provider.Usage | ||
| 280 | + | budgetBaseline int64 | |||
| 262 | usageRecordPersisted bool | 281 | usageRecordPersisted bool | ||
| 263 | usageMetadataPersisted bool | 282 | usageMetadataPersisted bool | ||
| 264 | contextUsed int | 283 | contextUsed int | ||
| @@ -272,10 +291,11 @@ type ResidentJournal struct { | |||||
| 272 | // ResidentUsageSnapshot is the bounded usage projection shared by durable and | 291 | // ResidentUsageSnapshot is the bounded usage projection shared by durable and | ||
| 273 | // live resident state. | 292 | // live resident state. | ||
| 274 | type ResidentUsageSnapshot struct { | 293 | type ResidentUsageSnapshot struct { | ||
| 275 | - | Usage provider.Usage | 294 | + | Usage provider.Usage |
| 276 | - | ContextUsed int | 295 | + | ContextUsed int |
| 277 | - | ContextMax int | 296 | + | ContextMax int |
| 278 | - | Subscription bool | 297 | + | Subscription bool |
| 298 | + | BudgetBaseline int64 | |||
| 279 | } | 299 | } | ||
| 280 | 300 | ||||
| 281 | // ConfigureUsage records resolved model metadata and restores the latest | 301 | // ConfigureUsage records resolved model metadata and restores the latest | ||
| @@ -287,11 +307,12 @@ func (j *ResidentJournal) ConfigureUsage(contextMax int, subscription bool) Resi | |||||
| 287 | j.usageMu.Lock() | 307 | j.usageMu.Lock() | ||
| 288 | if metadata, err := ReadResidentMetadata(filepath.Join(j.dir, residentMetadataName)); err == nil { | 308 | if metadata, err := ReadResidentMetadata(filepath.Join(j.dir, residentMetadataName)); err == nil { | ||
| 289 | j.usage = metadata.Usage | 309 | j.usage = metadata.Usage | ||
| 310 | + | j.budgetBaseline = metadata.BudgetBaseline | |||
| 290 | j.contextUsed = metadata.ContextUsed | 311 | j.contextUsed = metadata.ContextUsed | ||
| 291 | } | 312 | } | ||
| 292 | j.contextMax = contextMax | 313 | j.contextMax = contextMax | ||
| 293 | j.subscription = subscription | 314 | j.subscription = subscription | ||
| 294 | - | snapshot := ResidentUsageSnapshot{Usage: j.usage, ContextUsed: j.contextUsed, ContextMax: j.contextMax, Subscription: j.subscription} | 315 | + | snapshot := ResidentUsageSnapshot{Usage: j.usage, ContextUsed: j.contextUsed, ContextMax: j.contextMax, Subscription: j.subscription, BudgetBaseline: j.budgetBaseline} |
| 295 | j.usageMu.Unlock() | 316 | j.usageMu.Unlock() | ||
| 296 | return snapshot | 317 | return snapshot | ||
| 297 | } | 318 | } | ||
| @@ -302,7 +323,12 @@ func (j *ResidentJournal) usageSnapshot() ResidentUsageSnapshot { | |||||
| 302 | } | 323 | } | ||
| 303 | j.usageMu.RLock() | 324 | j.usageMu.RLock() | ||
| 304 | defer j.usageMu.RUnlock() | 325 | defer j.usageMu.RUnlock() | ||
| 305 | - | return ResidentUsageSnapshot{Usage: j.usage, ContextUsed: j.contextUsed, ContextMax: j.contextMax, Subscription: j.subscription} | 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 | |||
| 306 | } | 332 | } | ||
| 307 | 333 | ||||
| 308 | func (j *ResidentJournal) usageRecordPersistence(record residentRecord) (durable, metadataPersisted bool) { | 334 | func (j *ResidentJournal) usageRecordPersistence(record residentRecord) (durable, metadataPersisted bool) { | ||
| @@ -439,14 +465,26 @@ func (j *ResidentJournal) AcceptFollowUp(spec ResidentChildSpec, turnID, prompt | |||||
| 439 | return errors.New("resident journal: closed") | 465 | return errors.New("resident journal: closed") | ||
| 440 | } | 466 | } | ||
| 441 | now := time.Now().UTC() | 467 | now := time.Now().UTC() | ||
| 442 | - | if err := j.appendSync(residentRecord{Version: residentJournalVersion, Type: residentRecordTurnAccepted, Time: now, TurnID: turnID, Prompt: prompt}); err != nil { | 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 { | |||
| 443 | return err | 478 | return err | ||
| 444 | } | 479 | } | ||
| 480 | + | j.usageMu.Lock() | |||
| 481 | + | j.budgetBaseline = baseline | |||
| 482 | + | j.usageMu.Unlock() | |||
| 445 | return writeResidentMetadata(j.dir, j.metadata(spec, ResidentQueued, now)) | 483 | return writeResidentMetadata(j.dir, j.metadata(spec, ResidentQueued, now)) | ||
| 446 | } | 484 | } | ||
| 447 | 485 | ||||
| 448 | func (j *ResidentJournal) RecordTurnStarted(spec ResidentChildSpec, turnID string) error { | 486 | func (j *ResidentJournal) RecordTurnStarted(spec ResidentChildSpec, turnID string) error { | ||
| 449 | - | if err := j.recordTurnBoundary(spec, residentRecordTurnStarted, turnID, ResidentRunning, ""); err != nil { | 487 | + | if err := j.recordTurnBoundary(spec, ResidentRunning, residentRecord{Type: residentRecordTurnStarted, TurnID: turnID}); err != nil { |
| 450 | return err | 488 | return err | ||
| 451 | } | 489 | } | ||
| 452 | j.summaryMu.Lock() | 490 | j.summaryMu.Lock() | ||
| @@ -462,27 +500,53 @@ func (j *ResidentJournal) RecordTurnFinished(spec ResidentChildSpec, turnID stri | |||||
| 462 | // RecordTurnFinishedWithCapture publishes bounded workspace artifacts before | 500 | // RecordTurnFinishedWithCapture publishes bounded workspace artifacts before | ||
| 463 | // the terminal projection. The transcript remains the lifecycle authority. | 501 | // the terminal projection. The transcript remains the lifecycle authority. | ||
| 464 | func (j *ResidentJournal) RecordTurnFinishedWithCapture(spec ResidentChildSpec, turnID string, turnErr error, capture *WorkspaceCapture) error { | 502 | func (j *ResidentJournal) RecordTurnFinishedWithCapture(spec ResidentChildSpec, turnID string, turnErr error, capture *WorkspaceCapture) error { | ||
| 465 | - | state, outcome := ResidentIdle, "completed" | 503 | + | _, err := j.finishTurn(spec, turnID, turnErr, capture) |
| 504 | + | return err | |||
| 505 | + | } | |||
| 506 | + | ||||
| 507 | + | // finishTurn returns the exact persisted projection to the live child, avoiding | |||
| 508 | + | // a second filesystem read at the terminal notification boundary. | |||
| 509 | + | func (j *ResidentJournal) finishTurn(spec ResidentChildSpec, turnID string, turnErr error, capture *WorkspaceCapture) (ResidentResult, error) { | |||
| 510 | + | state, outcome := ResidentIdle, residentOutcomeCompleted | |||
| 466 | if turnErr != nil { | 511 | if turnErr != nil { | ||
| 467 | - | state, outcome = ResidentFailed, "failed" | 512 | + | state, outcome = ResidentFailed, residentOutcomeFailed |
| 513 | + | } | |||
| 514 | + | recordType := residentRecordTurnFinished | |||
| 515 | + | if errors.Is(turnErr, context.Canceled) { | |||
| 516 | + | state, outcome = ResidentInterrupted, residentOutcomeInterrupted | |||
| 517 | + | 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 | |||
| 468 | } | 522 | } | ||
| 469 | result := ResidentResult{Version: residentJournalVersion, ID: spec.ID, TurnID: turnID, State: state, Summary: j.latestAssistantSummary(), CreatedAt: time.Now().UTC()} | 523 | result := ResidentResult{Version: residentJournalVersion, ID: spec.ID, TurnID: turnID, State: state, Summary: j.latestAssistantSummary(), CreatedAt: time.Now().UTC()} | ||
| 470 | if turnErr != nil { | 524 | if turnErr != nil { | ||
| 471 | - | result.ErrorCode = "turn_failed" | 525 | + | result.ErrorCode = residentErrorTurnFailed |
| 526 | + | } | |||
| 527 | + | if state == ResidentBudgetExhausted { | |||
| 528 | + | result.ErrorCode = residentErrorBudgetExhausted | |||
| 529 | + | result.Handoff = residentBudgetHandoff(j.dir, spec) | |||
| 472 | } | 530 | } | ||
| 473 | if capture != nil { | 531 | if capture != nil { | ||
| 474 | result.ChangedFiles = append([]string(nil), capture.ChangedFiles...) | 532 | result.ChangedFiles = append([]string(nil), capture.ChangedFiles...) | ||
| 475 | if len(capture.Patch) > 0 { | 533 | if len(capture.Patch) > 0 { | ||
| 476 | if err := writeResidentPatch(j.dir, capture.Patch); err != nil { | 534 | if err := writeResidentPatch(j.dir, capture.Patch); err != nil { | ||
| 477 | - | return fmt.Errorf("resident journal write workspace patch: %w", err) | 535 | + | return ResidentResult{}, fmt.Errorf("resident journal write workspace patch: %w", err) |
| 478 | } | 536 | } | ||
| 479 | result.PatchRef = PatchRef(spec.ID) | 537 | result.PatchRef = PatchRef(spec.ID) | ||
| 480 | } | 538 | } | ||
| 481 | } | 539 | } | ||
| 482 | - | if recordErr := j.recordTurnBoundary(spec, residentRecordTurnFinished, turnID, state, outcome); recordErr != nil { | 540 | + | // Persist artifact metadata with the terminal boundary so result.json can |
| 483 | - | return recordErr | 541 | + | // be rebuilt without guessing which turn produced the patch on disk. |
| 542 | + | record := residentRecord{Type: recordType, TurnID: turnID, Outcome: outcome, PatchRef: result.PatchRef, ChangedFiles: result.ChangedFiles} | |||
| 543 | + | if recordErr := j.recordTurnBoundary(spec, state, record); recordErr != nil { | |||
| 544 | + | return ResidentResult{}, recordErr | |||
| 484 | } | 545 | } | ||
| 485 | - | return writeResidentResult(j.dir, result) | 546 | + | if err := writeResidentResult(j.dir, result); err != nil { |
| 547 | + | return ResidentResult{}, err | |||
| 548 | + | } | |||
| 549 | + | return result, nil | |||
| 486 | } | 550 | } | ||
| 487 | 551 | ||||
| 488 | func (j *ResidentJournal) recordAssistantSummary(message provider.Message) { | 552 | func (j *ResidentJournal) recordAssistantSummary(message provider.Message) { | ||
| @@ -537,11 +601,11 @@ func truncateResidentResultSummary(text string) string { | |||||
| 537 | } | 601 | } | ||
| 538 | 602 | ||||
| 539 | func (j *ResidentJournal) RecordTurnInterrupted(spec ResidentChildSpec, turnID string) error { | 603 | func (j *ResidentJournal) RecordTurnInterrupted(spec ResidentChildSpec, turnID string) error { | ||
| 540 | - | return j.recordTurnBoundary(spec, residentRecordInterrupted, turnID, ResidentInterrupted, "interrupted") | 604 | + | return j.recordTurnBoundary(spec, ResidentInterrupted, residentRecord{Type: residentRecordInterrupted, TurnID: turnID, Outcome: residentOutcomeInterrupted}) |
| 541 | } | 605 | } | ||
| 542 | 606 | ||||
| 543 | - | func (j *ResidentJournal) recordTurnBoundary(spec ResidentChildSpec, recordType, turnID string, state ResidentState, outcome string) error { | 607 | + | func (j *ResidentJournal) recordTurnBoundary(spec ResidentChildSpec, state ResidentState, record residentRecord) error { |
| 544 | - | if strings.TrimSpace(turnID) == "" { | 608 | + | if strings.TrimSpace(record.TurnID) == "" { |
| 545 | return errors.New("resident journal: missing turn ID") | 609 | return errors.New("resident journal: missing turn ID") | ||
| 546 | } | 610 | } | ||
| 547 | j.mu.Lock() | 611 | j.mu.Lock() | ||
| @@ -549,11 +613,11 @@ func (j *ResidentJournal) recordTurnBoundary(spec ResidentChildSpec, recordType, | |||||
| 549 | if j.file == nil { | 613 | if j.file == nil { | ||
| 550 | return errors.New("resident journal: closed") | 614 | return errors.New("resident journal: closed") | ||
| 551 | } | 615 | } | ||
| 552 | - | now := time.Now().UTC() | 616 | + | record.Version, record.Time = residentJournalVersion, time.Now().UTC() |
| 553 | - | if err := j.appendSync(residentRecord{Version: residentJournalVersion, Type: recordType, Time: now, TurnID: turnID, Outcome: outcome}); err != nil { | 617 | + | if err := j.appendSync(record); err != nil { |
| 554 | return err | 618 | return err | ||
| 555 | } | 619 | } | ||
| 556 | - | return writeResidentMetadata(j.dir, j.metadata(spec, state, now)) | 620 | + | return writeResidentMetadata(j.dir, j.metadata(spec, state, record.Time)) |
| 557 | } | 621 | } | ||
| 558 | 622 | ||||
| 559 | func (j *ResidentJournal) metadata(spec ResidentChildSpec, state ResidentState, updatedAt time.Time) ResidentMetadata { | 623 | func (j *ResidentJournal) metadata(spec ResidentChildSpec, state ResidentState, updatedAt time.Time) ResidentMetadata { | ||
| @@ -561,7 +625,7 @@ func (j *ResidentJournal) metadata(spec ResidentChildSpec, state ResidentState, | |||||
| 561 | return ResidentMetadata{ | 625 | return ResidentMetadata{ | ||
| 562 | Version: residentJournalVersion, ID: spec.ID, SessionID: spec.SessionID, | 626 | Version: residentJournalVersion, ID: spec.ID, SessionID: spec.SessionID, | ||
| 563 | RootCacheID: spec.RootCacheID, ParentSessionID: spec.ParentSessionID, State: state, UpdatedAt: updatedAt, | 627 | RootCacheID: spec.RootCacheID, ParentSessionID: spec.ParentSessionID, State: state, UpdatedAt: updatedAt, | ||
| 564 | - | Usage: usage.Usage, ContextUsed: usage.ContextUsed, ContextMax: usage.ContextMax, Subscription: usage.Subscription, | 628 | + | Usage: usage.Usage, ContextUsed: usage.ContextUsed, ContextMax: usage.ContextMax, Subscription: usage.Subscription, BudgetBaseline: usage.BudgetBaseline, |
| 565 | } | 629 | } | ||
| 566 | } | 630 | } | ||
| 567 | 631 | ||||
| @@ -758,7 +822,8 @@ func reconcileOwnedResidentJournal(journal *ResidentJournal) (ResidentMetadata, | |||||
| 758 | } | 822 | } | ||
| 759 | state := ResidentQueued | 823 | state := ResidentQueued | ||
| 760 | lastStateAt := records[0].Time | 824 | lastStateAt := records[0].Time | ||
| 761 | - | lastFinishedTurn, lastFinishedOutcome, lastAssistantSummary := "", "", "" | 825 | + | var lastFinished residentRecord |
| 826 | + | lastAssistantSummary := "" | |||
| 762 | usage := ResidentUsageSnapshot{} | 827 | usage := ResidentUsageSnapshot{} | ||
| 763 | seenTurns := make(map[string]string) | 828 | seenTurns := make(map[string]string) | ||
| 764 | // The initial prompt is accepted atomically with child.accepted rather than | 829 | // The initial prompt is accepted atomically with child.accepted rather than | ||
| @@ -779,6 +844,7 @@ func reconcileOwnedResidentJournal(journal *ResidentJournal) (ResidentMetadata, | |||||
| 779 | return ResidentMetadata{}, errors.New("resident journal: invalid accepted turn record") | 844 | return ResidentMetadata{}, errors.New("resident journal: invalid accepted turn record") | ||
| 780 | } | 845 | } | ||
| 781 | seenTurns[record.TurnID] = residentRecordTurnAccepted | 846 | seenTurns[record.TurnID] = residentRecordTurnAccepted | ||
| 847 | + | usage.BudgetBaseline = record.BudgetBaseline | |||
| 782 | lastStateAt = record.Time | 848 | lastStateAt = record.Time | ||
| 783 | case residentRecordTurnStarted: | 849 | case residentRecordTurnStarted: | ||
| 784 | if record.TurnID == "" || (seenTurns[record.TurnID] != residentRecordTurnAccepted && record.TurnID != spec.InitialTurnID) { | 850 | if record.TurnID == "" || (seenTurns[record.TurnID] != residentRecordTurnAccepted && record.TurnID != spec.InitialTurnID) { | ||
| @@ -793,10 +859,15 @@ func reconcileOwnedResidentJournal(journal *ResidentJournal) (ResidentMetadata, | |||||
| 793 | return ResidentMetadata{}, errors.New("resident journal: turn finished without start") | 859 | return ResidentMetadata{}, errors.New("resident journal: turn finished without start") | ||
| 794 | } | 860 | } | ||
| 795 | seenTurns[record.TurnID] = residentRecordTurnFinished | 861 | seenTurns[record.TurnID] = residentRecordTurnFinished | ||
| 796 | - | lastFinishedTurn, lastFinishedOutcome = record.TurnID, record.Outcome | 862 | + | lastFinished = record |
| 797 | - | if record.Outcome == "failed" { | 863 | + | switch record.Outcome { |
| 864 | + | case residentOutcomeFailed: | |||
| 798 | state = ResidentFailed | 865 | state = ResidentFailed | ||
| 799 | - | } else { | 866 | + | case residentOutcomeBudgetExhausted: |
| 867 | + | state = ResidentBudgetExhausted | |||
| 868 | + | case residentOutcomeCompletedBudgetExhausted: | |||
| 869 | + | state = ResidentCompleted | |||
| 870 | + | default: | |||
| 800 | state = ResidentIdle | 871 | state = ResidentIdle | ||
| 801 | } | 872 | } | ||
| 802 | lastStateAt = record.Time | 873 | lastStateAt = record.Time | ||
| @@ -843,7 +914,7 @@ func reconcileOwnedResidentJournal(journal *ResidentJournal) (ResidentMetadata, | |||||
| 843 | if record.Usage == nil { | 914 | if record.Usage == nil { | ||
| 844 | return ResidentMetadata{}, errors.New("resident journal: invalid usage record") | 915 | return ResidentMetadata{}, errors.New("resident journal: invalid usage record") | ||
| 845 | } | 916 | } | ||
| 846 | - | usage = ResidentUsageSnapshot{Usage: *record.Usage, ContextUsed: record.ContextUsed, ContextMax: record.ContextMax, Subscription: record.Subscription} | 917 | + | usage = ResidentUsageSnapshot{Usage: *record.Usage, ContextUsed: record.ContextUsed, ContextMax: record.ContextMax, Subscription: record.Subscription, BudgetBaseline: usage.BudgetBaseline} |
| 847 | } | 918 | } | ||
| 848 | } | 919 | } | ||
| 849 | if lastStateAt.IsZero() { | 920 | if lastStateAt.IsZero() { | ||
| @@ -852,7 +923,7 @@ func reconcileOwnedResidentJournal(journal *ResidentJournal) (ResidentMetadata, | |||||
| 852 | metadata := ResidentMetadata{ | 923 | metadata := ResidentMetadata{ | ||
| 853 | Version: residentJournalVersion, ID: spec.ID, SessionID: spec.SessionID, | 924 | Version: residentJournalVersion, ID: spec.ID, SessionID: spec.SessionID, | ||
| 854 | RootCacheID: spec.RootCacheID, ParentSessionID: spec.ParentSessionID, State: state, UpdatedAt: lastStateAt, | 925 | RootCacheID: spec.RootCacheID, ParentSessionID: spec.ParentSessionID, State: state, UpdatedAt: lastStateAt, | ||
| 855 | - | Usage: usage.Usage, ContextUsed: usage.ContextUsed, ContextMax: usage.ContextMax, Subscription: usage.Subscription, | 926 | + | Usage: usage.Usage, ContextUsed: usage.ContextUsed, ContextMax: usage.ContextMax, Subscription: usage.Subscription, BudgetBaseline: usage.BudgetBaseline, |
| 856 | } | 927 | } | ||
| 857 | needsInterruption := state == ResidentQueued || state == ResidentRunning | 928 | needsInterruption := state == ResidentQueued || state == ResidentRunning | ||
| 858 | needsToolRepair := len(toolCalls) != len(toolResults) | 929 | needsToolRepair := len(toolCalls) != len(toolResults) | ||
| @@ -880,7 +951,7 @@ func reconcileOwnedResidentJournal(journal *ResidentJournal) (ResidentMetadata, | |||||
| 880 | if err := writeResidentMetadata(dir, metadata); err != nil { | 951 | if err := writeResidentMetadata(dir, metadata); err != nil { | ||
| 881 | return ResidentMetadata{}, err | 952 | return ResidentMetadata{}, err | ||
| 882 | } | 953 | } | ||
| 883 | - | if err := rebuildResidentResult(dir, spec, metadata, lastFinishedTurn, lastFinishedOutcome, lastAssistantSummary); err != nil { | 954 | + | if err := rebuildResidentResult(dir, spec, metadata, lastFinished, lastAssistantSummary); err != nil { |
| 884 | return ResidentMetadata{}, err | 955 | return ResidentMetadata{}, err | ||
| 885 | } | 956 | } | ||
| 886 | return metadata, nil | 957 | return metadata, nil | ||
| @@ -911,7 +982,7 @@ func reconcileOwnedResidentJournal(journal *ResidentJournal) (ResidentMetadata, | |||||
| 911 | if err := writeResidentMetadata(dir, metadata); err != nil { | 982 | if err := writeResidentMetadata(dir, metadata); err != nil { | ||
| 912 | return ResidentMetadata{}, err | 983 | return ResidentMetadata{}, err | ||
| 913 | } | 984 | } | ||
| 914 | - | if err := rebuildResidentResult(dir, spec, metadata, lastFinishedTurn, lastFinishedOutcome, lastAssistantSummary); err != nil { | 985 | + | if err := rebuildResidentResult(dir, spec, metadata, lastFinished, lastAssistantSummary); err != nil { |
| 915 | return ResidentMetadata{}, err | 986 | return ResidentMetadata{}, err | ||
| 916 | } | 987 | } | ||
| 917 | return metadata, nil | 988 | return metadata, nil | ||
| @@ -1080,7 +1151,8 @@ func (j *ResidentJournal) rewriteTranscript(records []residentRecord) error { | |||||
| 1080 | return nil | 1151 | return nil | ||
| 1081 | } | 1152 | } | ||
| 1082 | 1153 | ||||
| 1083 | - | func rebuildResidentResult(dir string, spec ResidentChildSpec, metadata ResidentMetadata, turnID, outcome, summary string) error { | 1154 | + | func rebuildResidentResult(dir string, spec ResidentChildSpec, metadata ResidentMetadata, finished residentRecord, summary string) error { |
| 1155 | + | turnID := finished.TurnID | |||
| 1084 | if turnID == "" { | 1156 | if turnID == "" { | ||
| 1085 | return nil | 1157 | return nil | ||
| 1086 | } | 1158 | } | ||
| @@ -1092,9 +1164,15 @@ func rebuildResidentResult(dir string, spec ResidentChildSpec, metadata Resident | |||||
| 1092 | return fmt.Errorf("resident journal remove corrupt result projection: %w", removeErr) | 1164 | return fmt.Errorf("resident journal remove corrupt result projection: %w", removeErr) | ||
| 1093 | } | 1165 | } | ||
| 1094 | } | 1166 | } | ||
| 1095 | - | result := ResidentResult{Version: residentJournalVersion, ID: spec.ID, TurnID: turnID, State: metadata.State, Summary: summary, CreatedAt: metadata.UpdatedAt} | 1167 | + | result := ResidentResult{Version: residentJournalVersion, ID: spec.ID, TurnID: turnID, State: metadata.State, Summary: summary, CreatedAt: metadata.UpdatedAt, |
| 1096 | - | if outcome == "failed" { | 1168 | + | PatchRef: finished.PatchRef, ChangedFiles: append([]string(nil), finished.ChangedFiles...), |
| 1097 | - | result.ErrorCode = "turn_failed" | 1169 | + | } |
| 1170 | + | switch finished.Outcome { | |||
| 1171 | + | case residentOutcomeFailed: | |||
| 1172 | + | result.ErrorCode = residentErrorTurnFailed | |||
| 1173 | + | case residentOutcomeBudgetExhausted: | |||
| 1174 | + | result.ErrorCode = residentErrorBudgetExhausted | |||
| 1175 | + | result.Handoff = residentBudgetHandoff(dir, spec) | |||
| 1098 | } | 1176 | } | ||
| 1099 | return writeResidentResult(dir, result) | 1177 | return writeResidentResult(dir, result) | ||
| 1100 | } | 1178 | } | ||
M packages/agent/subagents/resident_manager.go +10 -12
| @@ -230,7 +230,7 @@ func residentSnapshot(child *ResidentChild) ResidentSnapshot { | |||
| 230 | 230 | return ResidentSnapshot{} | |
| 231 | 231 | } | |
| 232 | 232 | live := child.Live() | |
| 233 | - | budget, budgetSource := residentBudgetSnapshot(live.Usage, child.spec.BudgetLimit, child.spec.BudgetSource) | |
| 233 | + | budget, budgetSource := residentBudgetSnapshot(live.Usage, child.spec.BudgetLimit, child.spec.BudgetSource, child.journal.BudgetBaseline()) | |
| 234 | 234 | return ResidentSnapshot{ID: child.spec.ID, State: child.State(), Profile: child.spec.Profile, | |
| 235 | 235 | Provider: child.spec.Provider, Model: child.spec.Model, | |
| 236 | 236 | WorkspaceMode: child.spec.WorkspaceMode, Required: child.spec.Required, | |
| @@ -240,8 +240,8 @@ func residentSnapshot(child *ResidentChild) ResidentSnapshot { | |||
| 240 | 240 | Budget: budget, BudgetSource: budgetSource} | |
| 241 | 241 | } | |
| 242 | 242 | ||
| 243 | - | func residentBudgetSnapshot(usage provider.Usage, limit int64, source string) (BudgetSnapshot, string) { | |
| 244 | - | return BudgetSnapshotFor(usage, limit), source | |
| 243 | + | func residentBudgetSnapshot(usage provider.Usage, limit int64, source string, baseline int64) (BudgetSnapshot, string) { | |
| 244 | + | return budgetSnapshotSince(usage, limit, baseline), source | |
| 245 | 245 | } | |
| 246 | 246 | ||
| 247 | 247 | func NewResidentManager(root string, factory ResidentFactory) *ResidentManager { | |
| @@ -470,10 +470,6 @@ func (m *ResidentManager) Resume(ctx context.Context, childID, prompt string) er | |||
| 470 | 470 | if child == nil && snapshot.OwnedElsewhere { | |
| 471 | 471 | recovered = true | |
| 472 | 472 | } | |
| 473 | - | if child == nil && recovered && snapshot.Budget.State == BudgetExceeded { | |
| 474 | - | m.mu.Unlock() | |
| 475 | - | return errors.New("resident manager: child rollout budget is exhausted; spawn a replacement with a larger override") | |
| 476 | - | } | |
| 477 | 473 | if child == nil && recovered { | |
| 478 | 474 | if _, pending := m.pending[childID]; pending { | |
| 479 | 475 | m.mu.Unlock() | |
| @@ -482,8 +478,8 @@ func (m *ResidentManager) Resume(ctx context.Context, childID, prompt string) er | |||
| 482 | 478 | m.pending[childID] = struct{}{} | |
| 483 | 479 | } | |
| 484 | 480 | m.mu.Unlock() | |
| 485 | - | if child != nil && residentSnapshot(child).Budget.State == BudgetExceeded { | |
| 486 | - | return errors.New("resident manager: child rollout budget is exhausted; spawn a replacement with a larger override") | |
| 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") | |
| 487 | 483 | } | |
| 488 | 484 | if child == nil && recovered { | |
| 489 | 485 | defer func() { m.mu.Lock(); delete(m.pending, childID); m.mu.Unlock() }() | |
| @@ -494,10 +490,12 @@ func (m *ResidentManager) Resume(ctx context.Context, childID, prompt string) er | |||
| 494 | 490 | } | |
| 495 | 491 | return err | |
| 496 | 492 | } | |
| 497 | - | if _, err := reconcileOwnedResidentJournal(journal); err != nil { | |
| 493 | + | metadata, err := reconcileOwnedResidentJournal(journal) | |
| 494 | + | if err != nil { | |
| 498 | 495 | _ = journal.Close() | |
| 499 | 496 | return err | |
| 500 | 497 | } | |
| 498 | + | journal.ConfigureUsage(metadata.ContextMax, metadata.Subscription) | |
| 501 | 499 | records, err := ReadResidentJournal(filepath.Join(journal.Dir(), residentTranscriptName)) | |
| 502 | 500 | if err != nil || len(records) == 0 || records[0].Spec == nil { | |
| 503 | 501 | _ = journal.Close() | |
| @@ -876,7 +874,7 @@ func (m *ResidentManager) Reconcile() []error { | |||
| 876 | 874 | errs = append(errs, fmt.Errorf("resident child %s: %w", childID, reconcileErr)) | |
| 877 | 875 | continue | |
| 878 | 876 | } | |
| 879 | - | budget, budgetSource := residentBudgetSnapshot(metadata.Usage, spec.BudgetLimit, spec.BudgetSource) | |
| 877 | + | budget, budgetSource := residentBudgetSnapshot(metadata.Usage, spec.BudgetLimit, spec.BudgetSource, metadata.BudgetBaseline) | |
| 880 | 878 | m.mu.Lock() | |
| 881 | 879 | if _, live := m.children[childID]; !live { | |
| 882 | 880 | m.recovered[childID] = ResidentSnapshot{ | |
| @@ -899,7 +897,7 @@ func (m *ResidentManager) UnmetRequired() []ResidentSnapshot { | |||
| 899 | 897 | all := m.Snapshot() | |
| 900 | 898 | result := all[:0] | |
| 901 | 899 | for _, snapshot := range all { | |
| 902 | - | if snapshot.Required && snapshot.State != ResidentIdle { | |
| 900 | + | if snapshot.Required && snapshot.State != ResidentIdle && snapshot.State != ResidentCompleted { | |
| 903 | 901 | result = append(result, snapshot) | |
| 904 | 902 | } | |
| 905 | 903 | } | |
| @@ -230,7 +230,7 @@ func residentSnapshot(child *ResidentChild) ResidentSnapshot { | |||||
| 230 | return ResidentSnapshot{} | 230 | return ResidentSnapshot{} | ||
| 231 | } | 231 | } | ||
| 232 | live := child.Live() | 232 | live := child.Live() | ||
| 233 | - | budget, budgetSource := residentBudgetSnapshot(live.Usage, child.spec.BudgetLimit, child.spec.BudgetSource) | 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, | 234 | return ResidentSnapshot{ID: child.spec.ID, State: child.State(), Profile: child.spec.Profile, | ||
| 235 | Provider: child.spec.Provider, Model: child.spec.Model, | 235 | Provider: child.spec.Provider, Model: child.spec.Model, | ||
| 236 | WorkspaceMode: child.spec.WorkspaceMode, Required: child.spec.Required, | 236 | WorkspaceMode: child.spec.WorkspaceMode, Required: child.spec.Required, | ||
| @@ -240,8 +240,8 @@ func residentSnapshot(child *ResidentChild) ResidentSnapshot { | |||||
| 240 | Budget: budget, BudgetSource: budgetSource} | 240 | Budget: budget, BudgetSource: budgetSource} | ||
| 241 | } | 241 | } | ||
| 242 | 242 | ||||
| 243 | - | func residentBudgetSnapshot(usage provider.Usage, limit int64, source string) (BudgetSnapshot, string) { | 243 | + | func residentBudgetSnapshot(usage provider.Usage, limit int64, source string, baseline int64) (BudgetSnapshot, string) { |
| 244 | - | return BudgetSnapshotFor(usage, limit), source | 244 | + | return budgetSnapshotSince(usage, limit, baseline), source |
| 245 | } | 245 | } | ||
| 246 | 246 | ||||
| 247 | func NewResidentManager(root string, factory ResidentFactory) *ResidentManager { | 247 | func NewResidentManager(root string, factory ResidentFactory) *ResidentManager { | ||
| @@ -470,10 +470,6 @@ func (m *ResidentManager) Resume(ctx context.Context, childID, prompt string) er | |||||
| 470 | if child == nil && snapshot.OwnedElsewhere { | 470 | if child == nil && snapshot.OwnedElsewhere { | ||
| 471 | recovered = true | 471 | recovered = true | ||
| 472 | } | 472 | } | ||
| 473 | - | if child == nil && recovered && snapshot.Budget.State == BudgetExceeded { | |||
| 474 | - | m.mu.Unlock() | |||
| 475 | - | return errors.New("resident manager: child rollout budget is exhausted; spawn a replacement with a larger override") | |||
| 476 | - | } | |||
| 477 | if child == nil && recovered { | 473 | if child == nil && recovered { | ||
| 478 | if _, pending := m.pending[childID]; pending { | 474 | if _, pending := m.pending[childID]; pending { | ||
| 479 | m.mu.Unlock() | 475 | m.mu.Unlock() | ||
| @@ -482,8 +478,8 @@ func (m *ResidentManager) Resume(ctx context.Context, childID, prompt string) er | |||||
| 482 | m.pending[childID] = struct{}{} | 478 | m.pending[childID] = struct{}{} | ||
| 483 | } | 479 | } | ||
| 484 | m.mu.Unlock() | 480 | m.mu.Unlock() | ||
| 485 | - | if child != nil && residentSnapshot(child).Budget.State == BudgetExceeded { | 481 | + | if child != nil && residentSnapshot(child).Budget.State == BudgetExceeded && (child.State() == ResidentRunning || child.State() == ResidentQueued) { |
| 486 | - | return errors.New("resident manager: child rollout budget is exhausted; spawn a replacement with a larger override") | 482 | + | return errors.New("resident manager: wait for the budget-exhausted turn's terminal notification before resuming") |
| 487 | } | 483 | } | ||
| 488 | if child == nil && recovered { | 484 | if child == nil && recovered { | ||
| 489 | defer func() { m.mu.Lock(); delete(m.pending, childID); m.mu.Unlock() }() | 485 | defer func() { m.mu.Lock(); delete(m.pending, childID); m.mu.Unlock() }() | ||
| @@ -494,10 +490,12 @@ func (m *ResidentManager) Resume(ctx context.Context, childID, prompt string) er | |||||
| 494 | } | 490 | } | ||
| 495 | return err | 491 | return err | ||
| 496 | } | 492 | } | ||
| 497 | - | if _, err := reconcileOwnedResidentJournal(journal); err != nil { | 493 | + | metadata, err := reconcileOwnedResidentJournal(journal) |
| 494 | + | if err != nil { | |||
| 498 | _ = journal.Close() | 495 | _ = journal.Close() | ||
| 499 | return err | 496 | return err | ||
| 500 | } | 497 | } | ||
| 498 | + | journal.ConfigureUsage(metadata.ContextMax, metadata.Subscription) | |||
| 501 | records, err := ReadResidentJournal(filepath.Join(journal.Dir(), residentTranscriptName)) | 499 | records, err := ReadResidentJournal(filepath.Join(journal.Dir(), residentTranscriptName)) | ||
| 502 | if err != nil || len(records) == 0 || records[0].Spec == nil { | 500 | if err != nil || len(records) == 0 || records[0].Spec == nil { | ||
| 503 | _ = journal.Close() | 501 | _ = journal.Close() | ||
| @@ -876,7 +874,7 @@ func (m *ResidentManager) Reconcile() []error { | |||||
| 876 | errs = append(errs, fmt.Errorf("resident child %s: %w", childID, reconcileErr)) | 874 | errs = append(errs, fmt.Errorf("resident child %s: %w", childID, reconcileErr)) | ||
| 877 | continue | 875 | continue | ||
| 878 | } | 876 | } | ||
| 879 | - | budget, budgetSource := residentBudgetSnapshot(metadata.Usage, spec.BudgetLimit, spec.BudgetSource) | 877 | + | budget, budgetSource := residentBudgetSnapshot(metadata.Usage, spec.BudgetLimit, spec.BudgetSource, metadata.BudgetBaseline) |
| 880 | m.mu.Lock() | 878 | m.mu.Lock() | ||
| 881 | if _, live := m.children[childID]; !live { | 879 | if _, live := m.children[childID]; !live { | ||
| 882 | m.recovered[childID] = ResidentSnapshot{ | 880 | m.recovered[childID] = ResidentSnapshot{ | ||
| @@ -899,7 +897,7 @@ func (m *ResidentManager) UnmetRequired() []ResidentSnapshot { | |||||
| 899 | all := m.Snapshot() | 897 | all := m.Snapshot() | ||
| 900 | result := all[:0] | 898 | result := all[:0] | ||
| 901 | for _, snapshot := range all { | 899 | for _, snapshot := range all { | ||
| 902 | - | if snapshot.Required && snapshot.State != ResidentIdle { | 900 | + | if snapshot.Required && snapshot.State != ResidentIdle && snapshot.State != ResidentCompleted { |
| 903 | result = append(result, snapshot) | 901 | result = append(result, snapshot) | ||
| 904 | } | 902 | } | ||
| 905 | } | 903 | } | ||
M packages/agent/subagents/resident_manager_test.go +3 -3
| @@ -55,7 +55,7 @@ func TestResidentManagerSpawnsJournaledInProcessChild(t *testing.T) { | |||
| 55 | 55 | } | |
| 56 | 56 | } | |
| 57 | 57 | ||
| 58 | - | func TestResidentManagerRejectsResumeAfterBudgetExhaustion(t *testing.T) { | |
| 58 | + | func TestResidentManagerCompletedAnswerAtBudgetLimitCanResume(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}}) | |
| @@ -73,10 +73,10 @@ func TestResidentManagerRejectsResumeAfterBudgetExhaustion(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 != ResidentIdle || snapshot.Budget.State != BudgetExceeded { | |
| 76 | + | if !ok || snapshot.State != ResidentCompleted || snapshot.Budget.State != BudgetExceeded { | |
| 77 | 77 | t.Fatalf("terminal snapshot = %#v, found=%t", snapshot, ok) | |
| 78 | 78 | } | |
| 79 | - | if err := manager.Resume(context.Background(), spec.ID, "continue"); err == nil || !strings.Contains(err.Error(), "budget is exhausted") { | |
| 79 | + | if err := manager.Resume(context.Background(), spec.ID, "continue"); err != nil { | |
| 80 | 80 | t.Fatalf("resume error = %v", err) | |
| 81 | 81 | } | |
| 82 | 82 | } | |
| @@ -55,7 +55,7 @@ func TestResidentManagerSpawnsJournaledInProcessChild(t *testing.T) { | |||||
| 55 | } | 55 | } | ||
| 56 | } | 56 | } | ||
| 57 | 57 | ||||
| 58 | - | func TestResidentManagerRejectsResumeAfterBudgetExhaustion(t *testing.T) { | 58 | + | func TestResidentManagerCompletedAnswerAtBudgetLimitCanResume(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}}) | ||
| @@ -73,10 +73,10 @@ func TestResidentManagerRejectsResumeAfterBudgetExhaustion(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 != ResidentIdle || snapshot.Budget.State != BudgetExceeded { | 76 | + | if !ok || snapshot.State != ResidentCompleted || snapshot.Budget.State != BudgetExceeded { |
| 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 || !strings.Contains(err.Error(), "budget is exhausted") { | 79 | + | if err := manager.Resume(context.Background(), spec.ID, "continue"); err != nil { |
| 80 | t.Fatalf("resume error = %v", err) | 80 | t.Fatalf("resume error = %v", err) | ||
| 81 | } | 81 | } | ||
| 82 | } | 82 | } | ||
M packages/agent/tools/resident_tools_test.go +85 -19
| @@ -3,6 +3,7 @@ package tools | |||
| 3 | 3 | import ( | |
| 4 | 4 | "context" | |
| 5 | 5 | "encoding/json" | |
| 6 | + | "errors" | |
| 6 | 7 | "strings" | |
| 7 | 8 | "sync" | |
| 8 | 9 | "testing" | |
| @@ -13,6 +14,54 @@ import ( | |||
| 13 | 14 | "github.com/bnema/zut/packages/provider" | |
| 14 | 15 | ) | |
| 15 | 16 | ||
| 17 | + | func TestResidentStatusRetrievesBudgetHandoffWithoutExecution(t *testing.T) { | |
| 18 | + | manager := subagents.NewResidentManager(t.TempDir(), func(_ subagents.ResidentChildSpec, journal *subagents.ResidentJournal) (subagents.ResidentTurnRunner, error) { | |
| 19 | + | 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 | + | return err | |
| 22 | + | } | |
| 23 | + | return subagents.ErrBudgetExceeded | |
| 24 | + | }, nil | |
| 25 | + | }) | |
| 26 | + | 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 | + | completed, cancel := manager.WatchCompletion(spec.ID, spec.InitialTurnID) | |
| 29 | + | defer cancel() | |
| 30 | + | if _, err := manager.Spawn(t.Context(), spec, "investigate"); err != nil { | |
| 31 | + | t.Fatal(err) | |
| 32 | + | } | |
| 33 | + | select { | |
| 34 | + | case <-completed: | |
| 35 | + | case <-time.After(time.Second): | |
| 36 | + | t.Fatal("no completion") | |
| 37 | + | } | |
| 38 | + | status := &SubagentStatusTool{ResidentManager: manager, Enabled: func() bool { return true }} | |
| 39 | + | for _, include := range []bool{false, true} { | |
| 40 | + | raw := json.RawMessage(`{"agent_id":"budget-status"}`) | |
| 41 | + | if include { | |
| 42 | + | raw = json.RawMessage(`{"agent_id":"budget-status","include_result":true}`) | |
| 43 | + | } | |
| 44 | + | result, err := status.Execute(t.Context(), raw, nil) | |
| 45 | + | if err != nil || result.IsError { | |
| 46 | + | t.Fatalf("status: %#v, %v", result, err) | |
| 47 | + | } | |
| 48 | + | response := result.Details.(subagentStatusResponse) | |
| 49 | + | if response.Agent.State != subagents.ResidentBudgetExhausted || (response.Result != nil) != include { | |
| 50 | + | t.Fatalf("response = %#v", response) | |
| 51 | + | } | |
| 52 | + | if include && (!strings.Contains(response.Result.Handoff, "partial finding") || response.Result.ErrorCode != "budget_exhausted") { | |
| 53 | + | t.Fatalf("result = %#v", response.Result) | |
| 54 | + | } | |
| 55 | + | } | |
| 56 | + | result, err := status.Execute(t.Context(), json.RawMessage(`{"include_result":true}`), nil) | |
| 57 | + | if err != nil || !result.IsError { | |
| 58 | + | t.Fatalf("missing child ID must fail: %#v, %v", result, err) | |
| 59 | + | } | |
| 60 | + | if len(manager.UnmetRequired()) != 1 { | |
| 61 | + | t.Fatal("reading a handoff satisfied required work") | |
| 62 | + | } | |
| 63 | + | } | |
| 64 | + | ||
| 16 | 65 | func TestResidentToolsUseManagerOnly(t *testing.T) { | |
| 17 | 66 | runs := make(chan string, 2) | |
| 18 | 67 | manager := subagents.NewResidentManager(t.TempDir(), func(subagents.ResidentChildSpec, *subagents.ResidentJournal) (subagents.ResidentTurnRunner, error) { | |
| @@ -79,25 +128,42 @@ func TestSubagentSpawnGuidanceRequiresIndependentOwnership(t *testing.T) { | |||
| 79 | 128 | } | |
| 80 | 129 | ||
| 81 | 130 | func TestResidentSpawnWaitReturnsInitialCompletion(t *testing.T) { | |
| 82 | - | manager := subagents.NewResidentManager(t.TempDir(), func(subagents.ResidentChildSpec, *subagents.ResidentJournal) (subagents.ResidentTurnRunner, error) { | |
| 83 | - | return func(context.Context, string) error { return nil }, nil | |
| 84 | - | }) | |
| 85 | - | t.Cleanup(func() { _ = manager.Close(context.Background()) }) | |
| 86 | - | spawn := &SubagentSpawnTool{ | |
| 87 | - | ResidentManager: manager, | |
| 88 | - | Enabled: func() bool { return true }, | |
| 89 | - | DefaultProvider: func() string { return "openai" }, | |
| 90 | - | DefaultModel: func() string { return "gpt-5" }, | |
| 91 | - | BuildResidentSpec: func(_ context.Context, request ResidentSpawnRequest) (subagents.ResidentChildSpec, error) { | |
| 92 | - | return subagents.ResidentChildSpec{ID: "wait-for-completion", SessionID: "child-session", Provider: request.Provider, Model: request.Model}, nil | |
| 93 | - | }, | |
| 94 | - | } | |
| 95 | - | result, err := spawn.Execute(context.Background(), json.RawMessage(`{"task":"finish now","wait":1}`), nil) | |
| 96 | - | if err != nil || result.IsError { | |
| 97 | - | t.Fatalf("Execute = (%#v, %v)", result, err) | |
| 98 | - | } | |
| 99 | - | if got := toolResultText(t, result); !strings.Contains(got, "state: completed") { | |
| 100 | - | t.Fatalf("wait result = %q, want completed state", got) | |
| 131 | + | for _, tc := range []struct { | |
| 132 | + | name string | |
| 133 | + | err error | |
| 134 | + | status string | |
| 135 | + | }{ | |
| 136 | + | {"success", nil, "completed"}, | |
| 137 | + | {"failure", errors.New("worker failed"), "failed"}, | |
| 138 | + | {"budget", subagents.ErrBudgetExceeded, "budget_exhausted"}, | |
| 139 | + | {"cancellation", context.Canceled, "interrupted"}, | |
| 140 | + | {"cancellation and budget", errors.Join(context.Canceled, subagents.ErrBudgetExceeded), "interrupted"}, | |
| 141 | + | } { | |
| 142 | + | t.Run(tc.name, func(t *testing.T) { | |
| 143 | + | manager := subagents.NewResidentManager(t.TempDir(), func(subagents.ResidentChildSpec, *subagents.ResidentJournal) (subagents.ResidentTurnRunner, error) { | |
| 144 | + | return func(context.Context, string) error { return tc.err }, nil | |
| 145 | + | }) | |
| 146 | + | t.Cleanup(func() { _ = manager.Close(context.Background()) }) | |
| 147 | + | spawn := &SubagentSpawnTool{ | |
| 148 | + | ResidentManager: manager, | |
| 149 | + | Enabled: func() bool { return true }, | |
| 150 | + | DefaultProvider: func() string { return "openai" }, | |
| 151 | + | DefaultModel: func() string { return "gpt-5" }, | |
| 152 | + | BuildResidentSpec: func(_ context.Context, request ResidentSpawnRequest) (subagents.ResidentChildSpec, error) { | |
| 153 | + | return subagents.ResidentChildSpec{ID: "wait-for-completion", SessionID: "child-session", Provider: request.Provider, Model: request.Model, Required: request.Required}, nil | |
| 154 | + | }, | |
| 155 | + | } | |
| 156 | + | result, err := spawn.Execute(t.Context(), json.RawMessage(`{"task":"finish now","wait":1,"required":true}`), nil) | |
| 157 | + | if err != nil || result.IsError { | |
| 158 | + | t.Fatalf("Execute = (%#v, %v)", result, err) | |
| 159 | + | } | |
| 160 | + | if got := toolResultText(t, result); !strings.Contains(got, "\nstate: "+tc.status+"\n") || !strings.Contains(got, "\nrequired: "+tc.status+"\n") { | |
| 161 | + | t.Fatalf("wait result = %q, want %s state", got, tc.status) | |
| 162 | + | } | |
| 163 | + | if unmet := len(manager.UnmetRequired()); (unmet != 0) != (tc.err != nil) { | |
| 164 | + | t.Fatalf("unmet required = %d, error = %v", unmet, tc.err) | |
| 165 | + | } | |
| 166 | + | }) | |
| 101 | 167 | } | |
| 102 | 168 | } | |
| 103 | 169 | ||
| @@ -3,6 +3,7 @@ package tools | |||||
| 3 | import ( | 3 | import ( | ||
| 4 | "context" | 4 | "context" | ||
| 5 | "encoding/json" | 5 | "encoding/json" | ||
| 6 | + | "errors" | |||
| 6 | "strings" | 7 | "strings" | ||
| 7 | "sync" | 8 | "sync" | ||
| 8 | "testing" | 9 | "testing" | ||
| @@ -13,6 +14,54 @@ import ( | |||||
| 13 | "github.com/bnema/zut/packages/provider" | 14 | "github.com/bnema/zut/packages/provider" | ||
| 14 | ) | 15 | ) | ||
| 15 | 16 | ||||
| 17 | + | func TestResidentStatusRetrievesBudgetHandoffWithoutExecution(t *testing.T) { | |||
| 18 | + | manager := subagents.NewResidentManager(t.TempDir(), func(_ subagents.ResidentChildSpec, journal *subagents.ResidentJournal) (subagents.ResidentTurnRunner, error) { | |||
| 19 | + | 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 | + | return err | |||
| 22 | + | } | |||
| 23 | + | return subagents.ErrBudgetExceeded | |||
| 24 | + | }, nil | |||
| 25 | + | }) | |||
| 26 | + | 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 | + | completed, cancel := manager.WatchCompletion(spec.ID, spec.InitialTurnID) | |||
| 29 | + | defer cancel() | |||
| 30 | + | if _, err := manager.Spawn(t.Context(), spec, "investigate"); err != nil { | |||
| 31 | + | t.Fatal(err) | |||
| 32 | + | } | |||
| 33 | + | select { | |||
| 34 | + | case <-completed: | |||
| 35 | + | case <-time.After(time.Second): | |||
| 36 | + | t.Fatal("no completion") | |||
| 37 | + | } | |||
| 38 | + | status := &SubagentStatusTool{ResidentManager: manager, Enabled: func() bool { return true }} | |||
| 39 | + | for _, include := range []bool{false, true} { | |||
| 40 | + | raw := json.RawMessage(`{"agent_id":"budget-status"}`) | |||
| 41 | + | if include { | |||
| 42 | + | raw = json.RawMessage(`{"agent_id":"budget-status","include_result":true}`) | |||
| 43 | + | } | |||
| 44 | + | result, err := status.Execute(t.Context(), raw, nil) | |||
| 45 | + | if err != nil || result.IsError { | |||
| 46 | + | t.Fatalf("status: %#v, %v", result, err) | |||
| 47 | + | } | |||
| 48 | + | response := result.Details.(subagentStatusResponse) | |||
| 49 | + | if response.Agent.State != subagents.ResidentBudgetExhausted || (response.Result != nil) != include { | |||
| 50 | + | t.Fatalf("response = %#v", response) | |||
| 51 | + | } | |||
| 52 | + | if include && (!strings.Contains(response.Result.Handoff, "partial finding") || response.Result.ErrorCode != "budget_exhausted") { | |||
| 53 | + | t.Fatalf("result = %#v", response.Result) | |||
| 54 | + | } | |||
| 55 | + | } | |||
| 56 | + | result, err := status.Execute(t.Context(), json.RawMessage(`{"include_result":true}`), nil) | |||
| 57 | + | if err != nil || !result.IsError { | |||
| 58 | + | t.Fatalf("missing child ID must fail: %#v, %v", result, err) | |||
| 59 | + | } | |||
| 60 | + | if len(manager.UnmetRequired()) != 1 { | |||
| 61 | + | t.Fatal("reading a handoff satisfied required work") | |||
| 62 | + | } | |||
| 63 | + | } | |||
| 64 | + | ||||
| 16 | func TestResidentToolsUseManagerOnly(t *testing.T) { | 65 | func TestResidentToolsUseManagerOnly(t *testing.T) { | ||
| 17 | runs := make(chan string, 2) | 66 | runs := make(chan string, 2) | ||
| 18 | manager := subagents.NewResidentManager(t.TempDir(), func(subagents.ResidentChildSpec, *subagents.ResidentJournal) (subagents.ResidentTurnRunner, error) { | 67 | manager := subagents.NewResidentManager(t.TempDir(), func(subagents.ResidentChildSpec, *subagents.ResidentJournal) (subagents.ResidentTurnRunner, error) { | ||
| @@ -79,25 +128,42 @@ func TestSubagentSpawnGuidanceRequiresIndependentOwnership(t *testing.T) { | |||||
| 79 | } | 128 | } | ||
| 80 | 129 | ||||
| 81 | func TestResidentSpawnWaitReturnsInitialCompletion(t *testing.T) { | 130 | func TestResidentSpawnWaitReturnsInitialCompletion(t *testing.T) { | ||
| 82 | - | manager := subagents.NewResidentManager(t.TempDir(), func(subagents.ResidentChildSpec, *subagents.ResidentJournal) (subagents.ResidentTurnRunner, error) { | 131 | + | for _, tc := range []struct { |
| 83 | - | return func(context.Context, string) error { return nil }, nil | 132 | + | name string |
| 84 | - | }) | 133 | + | err error |
| 85 | - | t.Cleanup(func() { _ = manager.Close(context.Background()) }) | 134 | + | status string |
| 86 | - | spawn := &SubagentSpawnTool{ | 135 | + | }{ |
| 87 | - | ResidentManager: manager, | 136 | + | {"success", nil, "completed"}, |
| 88 | - | Enabled: func() bool { return true }, | 137 | + | {"failure", errors.New("worker failed"), "failed"}, |
| 89 | - | DefaultProvider: func() string { return "openai" }, | 138 | + | {"budget", subagents.ErrBudgetExceeded, "budget_exhausted"}, |
| 90 | - | DefaultModel: func() string { return "gpt-5" }, | 139 | + | {"cancellation", context.Canceled, "interrupted"}, |
| 91 | - | BuildResidentSpec: func(_ context.Context, request ResidentSpawnRequest) (subagents.ResidentChildSpec, error) { | 140 | + | {"cancellation and budget", errors.Join(context.Canceled, subagents.ErrBudgetExceeded), "interrupted"}, |
| 92 | - | return subagents.ResidentChildSpec{ID: "wait-for-completion", SessionID: "child-session", Provider: request.Provider, Model: request.Model}, nil | 141 | + | } { |
| 93 | - | }, | 142 | + | t.Run(tc.name, func(t *testing.T) { |
| 94 | - | } | 143 | + | manager := subagents.NewResidentManager(t.TempDir(), func(subagents.ResidentChildSpec, *subagents.ResidentJournal) (subagents.ResidentTurnRunner, error) { |
| 95 | - | result, err := spawn.Execute(context.Background(), json.RawMessage(`{"task":"finish now","wait":1}`), nil) | 144 | + | return func(context.Context, string) error { return tc.err }, nil |
| 96 | - | if err != nil || result.IsError { | 145 | + | }) |
| 97 | - | t.Fatalf("Execute = (%#v, %v)", result, err) | 146 | + | t.Cleanup(func() { _ = manager.Close(context.Background()) }) |
| 98 | - | } | 147 | + | spawn := &SubagentSpawnTool{ |
| 99 | - | if got := toolResultText(t, result); !strings.Contains(got, "state: completed") { | 148 | + | ResidentManager: manager, |
| 100 | - | t.Fatalf("wait result = %q, want completed state", got) | 149 | + | Enabled: func() bool { return true }, |
| 150 | + | DefaultProvider: func() string { return "openai" }, | |||
| 151 | + | DefaultModel: func() string { return "gpt-5" }, | |||
| 152 | + | BuildResidentSpec: func(_ context.Context, request ResidentSpawnRequest) (subagents.ResidentChildSpec, error) { | |||
| 153 | + | return subagents.ResidentChildSpec{ID: "wait-for-completion", SessionID: "child-session", Provider: request.Provider, Model: request.Model, Required: request.Required}, nil | |||
| 154 | + | }, | |||
| 155 | + | } | |||
| 156 | + | result, err := spawn.Execute(t.Context(), json.RawMessage(`{"task":"finish now","wait":1,"required":true}`), nil) | |||
| 157 | + | if err != nil || result.IsError { | |||
| 158 | + | t.Fatalf("Execute = (%#v, %v)", result, err) | |||
| 159 | + | } | |||
| 160 | + | if got := toolResultText(t, result); !strings.Contains(got, "\nstate: "+tc.status+"\n") || !strings.Contains(got, "\nrequired: "+tc.status+"\n") { | |||
| 161 | + | t.Fatalf("wait result = %q, want %s state", got, tc.status) | |||
| 162 | + | } | |||
| 163 | + | if unmet := len(manager.UnmetRequired()); (unmet != 0) != (tc.err != nil) { | |||
| 164 | + | t.Fatalf("unmet required = %d, error = %v", unmet, tc.err) | |||
| 165 | + | } | |||
| 166 | + | }) | |||
| 101 | } | 167 | } | ||
| 102 | } | 168 | } | ||
| 103 | 169 | ||||
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." | |
| 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." | |
| 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." | 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." |
| 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 +3 -5
| @@ -266,16 +266,14 @@ func (t *SubagentSpawnTool) Execute(ctx context.Context, raw json.RawMessage, _ | |||
| 266 | 266 | } else if _, err := t.ResidentManager.Spawn(ctx, spec, task); err != nil { | |
| 267 | 267 | return core.ToolResult{}, fmt.Errorf("%s: %w", prefix, err) | |
| 268 | 268 | } | |
| 269 | - | state := "queued" | |
| 269 | + | state := string(subagents.ResidentQueued) | |
| 270 | 270 | if a.Wait != nil { | |
| 271 | 271 | if waitTimedOut { | |
| 272 | 272 | if current, ok := t.ResidentManager.State(spec.ID); ok { | |
| 273 | 273 | state = string(current) | |
| 274 | 274 | } | |
| 275 | - | } else if completion.Err != nil { | |
| 276 | - | state = "failed" | |
| 277 | 275 | } else { | |
| 278 | - | state = "completed" | |
| 276 | + | state = completion.Completion().Status | |
| 279 | 277 | } | |
| 280 | 278 | } | |
| 281 | 279 | var sb strings.Builder | |
| @@ -300,7 +298,7 @@ func (t *SubagentSpawnTool) Execute(ctx context.Context, raw json.RawMessage, _ | |||
| 300 | 298 | } else if completion.Err != nil { | |
| 301 | 299 | fmt.Fprintf(&sb, "error: %s\n", completion.Err) | |
| 302 | 300 | if completion.Summary != "" { | |
| 303 | - | fmt.Fprintf(&sb, "final: %s\n", completion.Summary) | |
| 301 | + | fmt.Fprintf(&sb, "partial: %s\n", completion.Summary) | |
| 304 | 302 | } | |
| 305 | 303 | } else { | |
| 306 | 304 | if completion.Summary != "" { | |
| @@ -266,16 +266,14 @@ func (t *SubagentSpawnTool) Execute(ctx context.Context, raw json.RawMessage, _ | |||||
| 266 | } else if _, err := t.ResidentManager.Spawn(ctx, spec, task); err != nil { | 266 | } else if _, err := t.ResidentManager.Spawn(ctx, spec, task); err != nil { | ||
| 267 | return core.ToolResult{}, fmt.Errorf("%s: %w", prefix, err) | 267 | return core.ToolResult{}, fmt.Errorf("%s: %w", prefix, err) | ||
| 268 | } | 268 | } | ||
| 269 | - | state := "queued" | 269 | + | state := string(subagents.ResidentQueued) |
| 270 | if a.Wait != nil { | 270 | if a.Wait != nil { | ||
| 271 | if waitTimedOut { | 271 | if waitTimedOut { | ||
| 272 | if current, ok := t.ResidentManager.State(spec.ID); ok { | 272 | if current, ok := t.ResidentManager.State(spec.ID); ok { | ||
| 273 | state = string(current) | 273 | state = string(current) | ||
| 274 | } | 274 | } | ||
| 275 | - | } else if completion.Err != nil { | |||
| 276 | - | state = "failed" | |||
| 277 | } else { | 275 | } else { | ||
| 278 | - | state = "completed" | 276 | + | state = completion.Completion().Status |
| 279 | } | 277 | } | ||
| 280 | } | 278 | } | ||
| 281 | var sb strings.Builder | 279 | var sb strings.Builder | ||
| @@ -300,7 +298,7 @@ func (t *SubagentSpawnTool) Execute(ctx context.Context, raw json.RawMessage, _ | |||||
| 300 | } else if completion.Err != nil { | 298 | } else if completion.Err != nil { | ||
| 301 | fmt.Fprintf(&sb, "error: %s\n", completion.Err) | 299 | fmt.Fprintf(&sb, "error: %s\n", completion.Err) | ||
| 302 | if completion.Summary != "" { | 300 | if completion.Summary != "" { | ||
| 303 | - | fmt.Fprintf(&sb, "final: %s\n", completion.Summary) | 301 | + | fmt.Fprintf(&sb, "partial: %s\n", completion.Summary) |
| 304 | } | 302 | } | ||
| 305 | } else { | 303 | } else { | ||
| 306 | if completion.Summary != "" { | 304 | if completion.Summary != "" { | ||
M packages/agent/tools/subagent_status.go +36 -8
| @@ -4,6 +4,7 @@ import ( | |||
| 4 | 4 | "context" | |
| 5 | 5 | "encoding/json" | |
| 6 | 6 | "fmt" | |
| 7 | + | "os" | |
| 7 | 8 | "strings" | |
| 8 | 9 | ||
| 9 | 10 | "github.com/bnema/zut/packages/agent/subagents" | |
| @@ -14,21 +15,22 @@ import ( | |||
| 14 | 15 | // SubagentStatusTool reports live state for background sub-agents without | |
| 15 | 16 | // waiting for a child to finish. An omitted agent_id lists resident children; | |
| 16 | 17 | // an agent_id queries one child. | |
| 17 | - | // The result deliberately contains metadata only: it does not expose | |
| 18 | - | // transcripts, result output, credentials, provider settings, or filesystem | |
| 19 | - | // paths. | |
| 18 | + | // By default the result contains metadata only. Explicit include_result reads | |
| 19 | + | // the bounded durable outcome/handoff for one child without model execution. | |
| 20 | 20 | type SubagentStatusTool struct { | |
| 21 | 21 | ResidentManager *subagents.ResidentManager | |
| 22 | 22 | Enabled func() bool | |
| 23 | 23 | } | |
| 24 | 24 | ||
| 25 | 25 | type subagentStatusArgs struct { | |
| 26 | - | AgentID string `json:"agent_id,omitempty"` | |
| 26 | + | AgentID string `json:"agent_id,omitempty"` | |
| 27 | + | IncludeResult bool `json:"include_result,omitempty"` | |
| 27 | 28 | } | |
| 28 | 29 | ||
| 29 | 30 | type subagentStatusResponse struct { | |
| 30 | - | Agent *subagentStatusEntry `json:"agent,omitempty"` | |
| 31 | - | Agents []subagentStatusEntry `json:"agents"` | |
| 31 | + | Agent *subagentStatusEntry `json:"agent,omitempty"` | |
| 32 | + | Agents []subagentStatusEntry `json:"agents"` | |
| 33 | + | Result *subagents.ResidentResult `json:"result,omitempty"` | |
| 32 | 34 | } | |
| 33 | 35 | ||
| 34 | 36 | type subagentStatusEntry struct { | |
| @@ -50,6 +52,10 @@ const subagentStatusSchema = `{ | |||
| 50 | 52 | "agent_id": { | |
| 51 | 53 | "type": "string", | |
| 52 | 54 | "description": "Optional child id or unique id prefix. Omit it to list all resident sub-agents." | |
| 55 | + | }, | |
| 56 | + | "include_result": { | |
| 57 | + | "type": "boolean", | |
| 58 | + | "description": "With agent_id, retrieve the saved terminal result or partial budget handoff without running the child." | |
| 53 | 59 | } | |
| 54 | 60 | } | |
| 55 | 61 | }` | |
| @@ -57,7 +63,7 @@ const subagentStatusSchema = `{ | |||
| 57 | 63 | func (t *SubagentStatusTool) Name() string { return SubagentStatusToolName } | |
| 58 | 64 | ||
| 59 | 65 | func (t *SubagentStatusTool) Description() string { | |
| 60 | - | return "Query live status for one background sub-agent or list all visible workers without waiting for completion." | |
| 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." | |
| 61 | 67 | } | |
| 62 | 68 | ||
| 63 | 69 | func (t *SubagentStatusTool) Schema() json.RawMessage { | |
| @@ -87,6 +93,9 @@ func (t *SubagentStatusTool) Execute(ctx context.Context, raw json.RawMessage, _ | |||
| 87 | 93 | snapshots := t.ResidentManager.Snapshot() | |
| 88 | 94 | id := strings.TrimSpace(args.AgentID) | |
| 89 | 95 | if id == "" { | |
| 96 | + | if args.IncludeResult { | |
| 97 | + | return protocolToolError(prefix + ": include_result requires agent_id") | |
| 98 | + | } | |
| 90 | 99 | entries := make([]subagentStatusEntry, 0, len(snapshots)) | |
| 91 | 100 | for _, snapshot := range snapshots { | |
| 92 | 101 | entries = append(entries, publicResidentStatus(snapshot)) | |
| @@ -98,7 +107,26 @@ func (t *SubagentStatusTool) Execute(ctx context.Context, raw json.RawMessage, _ | |||
| 98 | 107 | return protocolToolError(fmt.Sprintf("%s: no such agent %q", prefix, id)) | |
| 99 | 108 | } | |
| 100 | 109 | entry := publicResidentStatus(snapshot) | |
| 101 | - | return renderSubagentStatus(subagentStatusResponse{Agent: &entry}) | |
| 110 | + | response := subagentStatusResponse{Agent: &entry} | |
| 111 | + | if args.IncludeResult { | |
| 112 | + | result, err := t.ResidentManager.Result(snapshot.ID) | |
| 113 | + | if err != nil { | |
| 114 | + | message := "saved result is unavailable" | |
| 115 | + | switch { | |
| 116 | + | case snapshot.OwnedElsewhere: | |
| 117 | + | message += ": child is owned by another zut process" | |
| 118 | + | case os.IsNotExist(err): | |
| 119 | + | message += ": no saved result exists yet" | |
| 120 | + | case os.IsPermission(err): | |
| 121 | + | message += ": permission denied" | |
| 122 | + | default: | |
| 123 | + | message += ": could not read or decode the saved result" | |
| 124 | + | } | |
| 125 | + | return protocolToolError(prefix + ": " + message) | |
| 126 | + | } | |
| 127 | + | response.Result = &result | |
| 128 | + | } | |
| 129 | + | return renderSubagentStatus(response) | |
| 102 | 130 | } | |
| 103 | 131 | ||
| 104 | 132 | func findResidentStatusSnapshot(snapshots []subagents.ResidentSnapshot, id string) (subagents.ResidentSnapshot, bool) { | |
| @@ -4,6 +4,7 @@ import ( | |||||
| 4 | "context" | 4 | "context" | ||
| 5 | "encoding/json" | 5 | "encoding/json" | ||
| 6 | "fmt" | 6 | "fmt" | ||
| 7 | + | "os" | |||
| 7 | "strings" | 8 | "strings" | ||
| 8 | 9 | ||||
| 9 | "github.com/bnema/zut/packages/agent/subagents" | 10 | "github.com/bnema/zut/packages/agent/subagents" | ||
| @@ -14,21 +15,22 @@ import ( | |||||
| 14 | // SubagentStatusTool reports live state for background sub-agents without | 15 | // SubagentStatusTool reports live state for background sub-agents without | ||
| 15 | // waiting for a child to finish. An omitted agent_id lists resident children; | 16 | // waiting for a child to finish. An omitted agent_id lists resident children; | ||
| 16 | // an agent_id queries one child. | 17 | // an agent_id queries one child. | ||
| 17 | - | // The result deliberately contains metadata only: it does not expose | 18 | + | // By default the result contains metadata only. Explicit include_result reads |
| 18 | - | // transcripts, result output, credentials, provider settings, or filesystem | 19 | + | // the bounded durable outcome/handoff for one child without model execution. |
| 19 | - | // paths. | |||
| 20 | type SubagentStatusTool struct { | 20 | type SubagentStatusTool struct { | ||
| 21 | ResidentManager *subagents.ResidentManager | 21 | ResidentManager *subagents.ResidentManager | ||
| 22 | Enabled func() bool | 22 | Enabled func() bool | ||
| 23 | } | 23 | } | ||
| 24 | 24 | ||||
| 25 | type subagentStatusArgs struct { | 25 | type subagentStatusArgs struct { | ||
| 26 | - | AgentID string `json:"agent_id,omitempty"` | 26 | + | AgentID string `json:"agent_id,omitempty"` |
| 27 | + | IncludeResult bool `json:"include_result,omitempty"` | |||
| 27 | } | 28 | } | ||
| 28 | 29 | ||||
| 29 | type subagentStatusResponse struct { | 30 | type subagentStatusResponse struct { | ||
| 30 | - | Agent *subagentStatusEntry `json:"agent,omitempty"` | 31 | + | Agent *subagentStatusEntry `json:"agent,omitempty"` |
| 31 | - | Agents []subagentStatusEntry `json:"agents"` | 32 | + | Agents []subagentStatusEntry `json:"agents"` |
| 33 | + | Result *subagents.ResidentResult `json:"result,omitempty"` | |||
| 32 | } | 34 | } | ||
| 33 | 35 | ||||
| 34 | type subagentStatusEntry struct { | 36 | type subagentStatusEntry struct { | ||
| @@ -50,6 +52,10 @@ const subagentStatusSchema = `{ | |||||
| 50 | "agent_id": { | 52 | "agent_id": { | ||
| 51 | "type": "string", | 53 | "type": "string", | ||
| 52 | "description": "Optional child id or unique id prefix. Omit it to list all resident sub-agents." | 54 | "description": "Optional child id or unique id prefix. Omit it to list all resident sub-agents." | ||
| 55 | + | }, | |||
| 56 | + | "include_result": { | |||
| 57 | + | "type": "boolean", | |||
| 58 | + | "description": "With agent_id, retrieve the saved terminal result or partial budget handoff without running the child." | |||
| 53 | } | 59 | } | ||
| 54 | } | 60 | } | ||
| 55 | }` | 61 | }` | ||
| @@ -57,7 +63,7 @@ const subagentStatusSchema = `{ | |||||
| 57 | func (t *SubagentStatusTool) Name() string { return SubagentStatusToolName } | 63 | func (t *SubagentStatusTool) Name() string { return SubagentStatusToolName } | ||
| 58 | 64 | ||||
| 59 | func (t *SubagentStatusTool) Description() string { | 65 | func (t *SubagentStatusTool) Description() string { | ||
| 60 | - | return "Query live status for one background sub-agent or list all visible workers without waiting for completion." | 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." |
| 61 | } | 67 | } | ||
| 62 | 68 | ||||
| 63 | func (t *SubagentStatusTool) Schema() json.RawMessage { | 69 | func (t *SubagentStatusTool) Schema() json.RawMessage { | ||
| @@ -87,6 +93,9 @@ func (t *SubagentStatusTool) Execute(ctx context.Context, raw json.RawMessage, _ | |||||
| 87 | snapshots := t.ResidentManager.Snapshot() | 93 | snapshots := t.ResidentManager.Snapshot() | ||
| 88 | id := strings.TrimSpace(args.AgentID) | 94 | id := strings.TrimSpace(args.AgentID) | ||
| 89 | if id == "" { | 95 | if id == "" { | ||
| 96 | + | if args.IncludeResult { | |||
| 97 | + | return protocolToolError(prefix + ": include_result requires agent_id") | |||
| 98 | + | } | |||
| 90 | entries := make([]subagentStatusEntry, 0, len(snapshots)) | 99 | entries := make([]subagentStatusEntry, 0, len(snapshots)) | ||
| 91 | for _, snapshot := range snapshots { | 100 | for _, snapshot := range snapshots { | ||
| 92 | entries = append(entries, publicResidentStatus(snapshot)) | 101 | entries = append(entries, publicResidentStatus(snapshot)) | ||
| @@ -98,7 +107,26 @@ func (t *SubagentStatusTool) Execute(ctx context.Context, raw json.RawMessage, _ | |||||
| 98 | return protocolToolError(fmt.Sprintf("%s: no such agent %q", prefix, id)) | 107 | return protocolToolError(fmt.Sprintf("%s: no such agent %q", prefix, id)) | ||
| 99 | } | 108 | } | ||
| 100 | entry := publicResidentStatus(snapshot) | 109 | entry := publicResidentStatus(snapshot) | ||
| 101 | - | return renderSubagentStatus(subagentStatusResponse{Agent: &entry}) | 110 | + | response := subagentStatusResponse{Agent: &entry} |
| 111 | + | if args.IncludeResult { | |||
| 112 | + | result, err := t.ResidentManager.Result(snapshot.ID) | |||
| 113 | + | if err != nil { | |||
| 114 | + | message := "saved result is unavailable" | |||
| 115 | + | switch { | |||
| 116 | + | case snapshot.OwnedElsewhere: | |||
| 117 | + | message += ": child is owned by another zut process" | |||
| 118 | + | case os.IsNotExist(err): | |||
| 119 | + | message += ": no saved result exists yet" | |||
| 120 | + | case os.IsPermission(err): | |||
| 121 | + | message += ": permission denied" | |||
| 122 | + | default: | |||
| 123 | + | message += ": could not read or decode the saved result" | |||
| 124 | + | } | |||
| 125 | + | return protocolToolError(prefix + ": " + message) | |||
| 126 | + | } | |||
| 127 | + | response.Result = &result | |||
| 128 | + | } | |||
| 129 | + | return renderSubagentStatus(response) | |||
| 102 | } | 130 | } | ||
| 103 | 131 | ||||
| 104 | func findResidentStatusSnapshot(snapshots []subagents.ResidentSnapshot, id string) (subagents.ResidentSnapshot, bool) { | 132 | func findResidentStatusSnapshot(snapshots []subagents.ResidentSnapshot, id string) (subagents.ResidentSnapshot, bool) { | ||
A packages/agent/tools/subagent_status_test.go +60 -0
| @@ -0,0 +1,60 @@ | |||
| 1 | + | package tools | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "context" | |
| 5 | + | "encoding/json" | |
| 6 | + | "os" | |
| 7 | + | "path/filepath" | |
| 8 | + | "strings" | |
| 9 | + | "testing" | |
| 10 | + | ||
| 11 | + | "github.com/bnema/zut/packages/agent/subagents" | |
| 12 | + | ) | |
| 13 | + | ||
| 14 | + | func TestResidentStatusResultReadErrors(t *testing.T) { | |
| 15 | + | for _, scenario := range []struct { | |
| 16 | + | name string | |
| 17 | + | want string | |
| 18 | + | }{ | |
| 19 | + | {"missing", "no saved result exists yet"}, | |
| 20 | + | {"foreign", "owned by another zut process"}, | |
| 21 | + | {"corrupt", "could not read or decode the saved result"}, | |
| 22 | + | } { | |
| 23 | + | t.Run(scenario.name, func(t *testing.T) { | |
| 24 | + | root := t.TempDir() | |
| 25 | + | manager := subagents.NewResidentManager(root, func(subagents.ResidentChildSpec, *subagents.ResidentJournal) (subagents.ResidentTurnRunner, error) { | |
| 26 | + | return func(ctx context.Context, _ string) error { | |
| 27 | + | <-ctx.Done() | |
| 28 | + | return ctx.Err() | |
| 29 | + | }, nil | |
| 30 | + | }) | |
| 31 | + | t.Cleanup(func() { _ = manager.Close(context.Background()) }) | |
| 32 | + | spec := subagents.ResidentChildSpec{ID: "status-error", InitialTurnID: "initial", SessionID: "session", Provider: "openai", Model: "test"} | |
| 33 | + | if _, err := manager.Spawn(t.Context(), spec, "investigate"); err != nil { | |
| 34 | + | t.Fatal(err) | |
| 35 | + | } | |
| 36 | + | statusManager := manager | |
| 37 | + | if scenario.name == "foreign" { | |
| 38 | + | statusManager = subagents.NewResidentManager(root, nil) | |
| 39 | + | t.Cleanup(func() { _ = statusManager.Close(context.Background()) }) | |
| 40 | + | if errs := statusManager.Reconcile(); len(errs) != 0 { | |
| 41 | + | t.Fatal(errs) | |
| 42 | + | } | |
| 43 | + | } | |
| 44 | + | if scenario.name == "corrupt" { | |
| 45 | + | if err := os.WriteFile(filepath.Join(root, spec.ID, "result.json"), []byte("private malformed payload"), 0o600); err != nil { | |
| 46 | + | t.Fatal(err) | |
| 47 | + | } | |
| 48 | + | } | |
| 49 | + | tool := &SubagentStatusTool{ResidentManager: statusManager, Enabled: func() bool { return true }} | |
| 50 | + | result, err := tool.Execute(t.Context(), json.RawMessage(`{"agent_id":"status-error","include_result":true}`), nil) | |
| 51 | + | if err != nil || !result.IsError { | |
| 52 | + | t.Fatalf("result = %#v, error = %v", result, err) | |
| 53 | + | } | |
| 54 | + | text := toolResultText(t, result) | |
| 55 | + | if !strings.Contains(text, scenario.want) || strings.Contains(text, root) || strings.Contains(text, "private") { | |
| 56 | + | t.Fatalf("unexpected error text: %q", text) | |
| 57 | + | } | |
| 58 | + | }) | |
| 59 | + | } | |
| 60 | + | } | |
| @@ -0,0 +1,60 @@ | |||||
| 1 | + | package tools | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "context" | |||
| 5 | + | "encoding/json" | |||
| 6 | + | "os" | |||
| 7 | + | "path/filepath" | |||
| 8 | + | "strings" | |||
| 9 | + | "testing" | |||
| 10 | + | ||||
| 11 | + | "github.com/bnema/zut/packages/agent/subagents" | |||
| 12 | + | ) | |||
| 13 | + | ||||
| 14 | + | func TestResidentStatusResultReadErrors(t *testing.T) { | |||
| 15 | + | for _, scenario := range []struct { | |||
| 16 | + | name string | |||
| 17 | + | want string | |||
| 18 | + | }{ | |||
| 19 | + | {"missing", "no saved result exists yet"}, | |||
| 20 | + | {"foreign", "owned by another zut process"}, | |||
| 21 | + | {"corrupt", "could not read or decode the saved result"}, | |||
| 22 | + | } { | |||
| 23 | + | t.Run(scenario.name, func(t *testing.T) { | |||
| 24 | + | root := t.TempDir() | |||
| 25 | + | manager := subagents.NewResidentManager(root, func(subagents.ResidentChildSpec, *subagents.ResidentJournal) (subagents.ResidentTurnRunner, error) { | |||
| 26 | + | return func(ctx context.Context, _ string) error { | |||
| 27 | + | <-ctx.Done() | |||
| 28 | + | return ctx.Err() | |||
| 29 | + | }, nil | |||
| 30 | + | }) | |||
| 31 | + | t.Cleanup(func() { _ = manager.Close(context.Background()) }) | |||
| 32 | + | spec := subagents.ResidentChildSpec{ID: "status-error", InitialTurnID: "initial", SessionID: "session", Provider: "openai", Model: "test"} | |||
| 33 | + | if _, err := manager.Spawn(t.Context(), spec, "investigate"); err != nil { | |||
| 34 | + | t.Fatal(err) | |||
| 35 | + | } | |||
| 36 | + | statusManager := manager | |||
| 37 | + | if scenario.name == "foreign" { | |||
| 38 | + | statusManager = subagents.NewResidentManager(root, nil) | |||
| 39 | + | t.Cleanup(func() { _ = statusManager.Close(context.Background()) }) | |||
| 40 | + | if errs := statusManager.Reconcile(); len(errs) != 0 { | |||
| 41 | + | t.Fatal(errs) | |||
| 42 | + | } | |||
| 43 | + | } | |||
| 44 | + | if scenario.name == "corrupt" { | |||
| 45 | + | if err := os.WriteFile(filepath.Join(root, spec.ID, "result.json"), []byte("private malformed payload"), 0o600); err != nil { | |||
| 46 | + | t.Fatal(err) | |||
| 47 | + | } | |||
| 48 | + | } | |||
| 49 | + | tool := &SubagentStatusTool{ResidentManager: statusManager, Enabled: func() bool { return true }} | |||
| 50 | + | result, err := tool.Execute(t.Context(), json.RawMessage(`{"agent_id":"status-error","include_result":true}`), nil) | |||
| 51 | + | if err != nil || !result.IsError { | |||
| 52 | + | t.Fatalf("result = %#v, error = %v", result, err) | |||
| 53 | + | } | |||
| 54 | + | text := toolResultText(t, result) | |||
| 55 | + | if !strings.Contains(text, scenario.want) || strings.Contains(text, root) || strings.Contains(text, "private") { | |||
| 56 | + | t.Fatalf("unexpected error text: %q", text) | |||
| 57 | + | } | |||
| 58 | + | }) | |||
| 59 | + | } | |||
| 60 | + | } | |||