detached d92a60f

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 UnverifiedB5690EEEBB952194 The 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
424424
425425 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.
426426
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.
428428
429429 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`.
430430
@@ -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>/
5353 owner.lock # host-ownership coordination; do not modify or remove
5454 .transcript-backup-* # retained private pre-repair transcript, when repaired
5555 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
5757 patch.diff # optional worktree capture
5858 ```
5959
@@ -98,13 +98,40 @@ reads count at 25%. It is separate from active context-window usage and survives
9898 follow-up turns and compaction. The TUI shows the child's current budget usage,
9999 but does not alter its instructions, tools, or output cap before exhaustion.
100100 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.
104129
105130 `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.
108135
109136 ## Delegation ownership
110137
@@ -142,15 +169,20 @@ The model-facing tools retain their logical names:
142169 task until it reaches a terminal failure, cancellation, or interruption. It
143170 returns a logical `subagent://<id>` reference.
144171 - `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.
146177 - `subagent_stop` stops one live child.
147178 - `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.
149181
150182 Child execution started by `subagent_spawn` is asynchronous unless it receives
151183 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
154186 files, or terminal UI inspection as a completion signal.
155187 Successful completions include the final visible assistant summary, capped at
156188 256 KiB; open the child session for the complete durable transcript.
@@ -175,10 +207,23 @@ private local session data.
175207 ## Worktrees and references
176208
177209 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.
182227
183228 Logical references avoid exposing runtime paths:
184229
@@ -53,7 +53,7 @@ subagents/<child-id>/
53 owner.lock # host-ownership coordination; do not modify or remove53 owner.lock # host-ownership coordination; do not modify or remove
54 .transcript-backup-* # retained private pre-repair transcript, when repaired54 .transcript-backup-* # retained private pre-repair transcript, when repaired
55 metadata.json # rebuildable bounded state55 metadata.json # rebuildable bounded state
56- result.json # latest state plus a bounded final assistant summary56+ result.json # latest outcome, final summary or partial handoff, artifacts
57 patch.diff # optional worktree capture57 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 terminal100 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. An101+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 more102+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 an131+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 ownership136 ## 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. It169 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 current171 - `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 receives182 Child execution started by `subagent_spawn` is asynchronous unless it receives
151 an explicit `wait` value. For an unwaited spawn, completion arrives through the183 an explicit `wait` value. For an unwaited spawn, completion arrives through the
152-host’s typed completion update; `subagent_status` only returns the current184+host’s typed completion update; `subagent_status` returns immediately and does
153-state immediately. Do not use sleep loops, repeated status calls, journal185+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 at187 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 references207 ## 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 on210+zut prepares a detached Git worktree and captures a patch and changed-file list
179-successful completion, and never applies that patch automatically. Failed or211+on successful completion or budget exhaustion. Budget-exhausted turns retain
180-interrupted worktrees are retained for inspection; an idle successful worktree212+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
22
33 import (
44 "context"
5- "errors"
65 "fmt"
76 "os"
87 "path/filepath"
@@ -33,14 +32,7 @@ func (i *Interactive) ReportResidentSubagent(completion subagents.ResidentComple
3332 if i == nil {
3433 return
3534 }
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()) {
4436 return
4537 }
4638 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 return33 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 return36 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) {
100100 }
101101 }
102102
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+
103113 func TestResidentSubagentsDialogRefreshAllowsNilReceiver(t *testing.T) {
104114 var dialog *residentSubagentsDialog
105115 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 *residentSubagentsDialog114 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) {
8989
9090 func residentResultStatus(id string, result subagents.ResidentResult) string {
9191 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 != "" {
9397 status += ": " + summary
9498 }
9599 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 += ": " + summary97 status += ": " + summary
94 }98 }
95 return status99 return status
M packages/agent/orchestration_modes.go +1 -9
@@ -209,15 +209,7 @@ func newOrchestratedRuntime(_ context.Context, args Args, r Resolved, cfg Config
209209 Policy: subagentPolicyFromConfig(cfg.Subagents),
210210 WebSearchPolicy: webSearchPolicyForRegistry(r.WebSearchPolicy, r.ToolRegistry),
211211 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())
221213 },
222214 OnResidentSpawned: func(spec subagents.ResidentChildSpec, turnID, _ string) { tracker.TrackResident(spec.ID, turnID) },
223215 })
@@ -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 (
66 "fmt"
77 "strings"
88 "sync"
9+ "sync/atomic"
910
1011 "github.com/bnema/zut/packages/agent/subagents"
1112 "github.com/bnema/zut/packages/agent/tools"
@@ -82,10 +83,15 @@ func newResidentChildRunner(args Args, spec subagents.ResidentChildSpec, journal
8283 }
8384 turnCtx, cancel := context.WithCancel(ctx)
8485 defer cancel()
86+ budget.SetBaseline(journal.BudgetBaseline())
8587 startedExhausted := budget.Snapshot().State == subagents.BudgetExceeded
88+ var budgetStopped atomic.Bool
8689 var journalErr error
8790 var journalMu sync.Mutex
8891 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+ }
8995 if usage, ok := event.(core.EvUsage); ok {
9096 budget.Observe(usage.Cumulative)
9197 }
@@ -104,7 +110,7 @@ func newResidentChildRunner(args Args, spec subagents.ResidentChildSpec, journal
104110 return fmt.Errorf("persist resident child transcript: %w", journalErr)
105111 }
106112 if budget.Snapshot().State == subagents.BudgetExceeded {
107- if !startedExhausted && err == nil {
113+ if !startedExhausted && !budgetStopped.Load() && err == nil {
108114 return nil
109115 }
110116 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.BudgetExceeded87 startedExhausted := budget.Snapshot().State == subagents.BudgetExceeded
88+ var budgetStopped atomic.Bool
86 var journalErr error89 var journalErr error
87 var journalMu sync.Mutex90 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 nil114 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 @@
11 package agent
22
33 import (
4+ "context"
45 "encoding/json"
56 "errors"
67 "fmt"
@@ -9,6 +10,7 @@ import (
910 "strings"
1011 "sync/atomic"
1112 "testing"
13+ "time"
1214
1315 "github.com/bnema/zut/packages/agent/subagents"
1416 "github.com/bnema/zut/packages/core"
@@ -146,6 +148,91 @@ func TestResidentChildRunnerTerminatesAtBudgetLimit(t *testing.T) {
146148 }
147149 }
148150
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+
149236 func newBudgetedResidentTestRunner(t *testing.T, baseURL string, limit int64) (subagents.ResidentTurnRunner, *subagents.ResidentJournal) {
150237 t.Helper()
151238 spec := subagents.ResidentChildSpec{
@@ -1,6 +1,7 @@
1 package agent1 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 {
5454 if limit <= 0 {
5555 return BudgetSnapshot{}
5656 }
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)
5865 percent := int(math.Floor(float64(used) * 100 / float64(limit)))
5966 return BudgetSnapshot{Used: used, Limit: limit, Percent: percent, State: budgetState(used, limit)}
6067 }
@@ -73,11 +80,13 @@ func budgetState(used, limit int64) BudgetState {
7380 type RolloutBudget struct {
7481 mu sync.Mutex
7582 limit int64
83+ baseline int64
84+ usage provider.Usage
7685 snapshot BudgetSnapshot
7786 }
7887
7988 func NewRolloutBudget(limit int64, usage provider.Usage) *RolloutBudget {
80- budget := &RolloutBudget{limit: limit}
89+ budget := &RolloutBudget{limit: limit, usage: usage}
8190 budget.snapshot = BudgetSnapshotFor(usage, limit)
8291 return budget
8392 }
@@ -87,12 +96,25 @@ func (b *RolloutBudget) Observe(usage provider.Usage) BudgetSnapshot {
8796 return BudgetSnapshot{}
8897 }
8998 b.mu.Lock()
90- b.snapshot = BudgetSnapshotFor(usage, b.limit)
99+ b.usage = usage
100+ b.snapshot = budgetSnapshotSince(usage, b.limit, b.baseline)
91101 snapshot := b.snapshot
92102 b.mu.Unlock()
93103 return snapshot
94104 }
95105
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+
96118 func (b *RolloutBudget) Snapshot() BudgetSnapshot {
97119 if b == nil {
98120 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.Mutex81 mu sync.Mutex
75 limit int6482 limit int64
83+ baseline int64
84+ usage provider.Usage
76 snapshot BudgetSnapshot85 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 budget91 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.snapshot101 snapshot := b.snapshot
92 b.mu.Unlock()102 b.mu.Unlock()
93 return snapshot103 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
22
33 import (
44 "context"
5+ "errors"
56 "fmt"
67 "strings"
78 "sync"
@@ -17,6 +18,21 @@ type Completion struct {
1718 Summary string
1819 }
1920
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+
2036 // CompletionTracker coordinates resident child completions with the parent
2137 // turn. It has no process or worker-event dependency.
2238 type CompletionTracker struct {
@@ -138,7 +154,11 @@ func FormatCompletionUpdate(batch []Completion, instruction string) string {
138154 fmt.Fprintf(&b, " — %s", completion.Task)
139155 }
140156 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)
142162 }
143163 b.WriteByte('\n')
144164 }
@@ -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 string18 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 parent36 // 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
22
33 import (
44 "context"
5+ "errors"
56 "strings"
67 "testing"
78 )
89
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+
941 func TestFormatCompletionUpdateIncludesFinalSummary(t *testing.T) {
1042 got := FormatCompletionUpdate([]Completion{{AgentID: "child", Status: "completed", Task: "review", Summary: "found the regression"}}, "")
1143 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 {
308308 }
309309 c.once.Do(func() {
310310 c.mu.Lock()
311- c.cleanupWorkspace = c.state == ResidentIdle
311+ c.cleanupWorkspace = c.state == ResidentIdle || c.state == ResidentCompleted
312312 c.mu.Unlock()
313313 c.cancel()
314314 })
@@ -412,35 +412,38 @@ func (c *ResidentChild) run() {
412412 }
413413 terminalErr := result.err
414414 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 {
416416 captured, err := c.workspace.Capture(c.ctx)
417417 if err != nil {
418- terminalErr = fmt.Errorf("capture resident worktree: %w", err)
418+ terminalErr = errors.Join(terminalErr, fmt.Errorf("capture resident worktree: %w", err))
419419 } else {
420420 capture = &captured
421421 }
422422 }
423+ state := ResidentIdle
424+ if terminalErr != nil {
425+ state = ResidentFailed
426+ if errors.Is(terminalErr, ErrBudgetExceeded) {
427+ state = ResidentBudgetExhausted
428+ }
429+ }
430+ summary := ""
423431 persistenceFailed := false
424432 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 {
426435 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+ }
428442 }
429443 }
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)
437446 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- }
444447 c.onCompletion(ResidentCompletion{ChildID: c.spec.ID, TurnID: result.turnID, Task: active.prompt, Err: terminalErr, Summary: summary})
445448 if persistenceFailed {
446449 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 == ResidentIdle311+ 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.err413 terminalErr := result.err
414 var capture *WorkspaceCapture414 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 = &captured420 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 := false431 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 = true436+ 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
22
33 import (
44 "bufio"
5+ "context"
56 "encoding/json"
67 "errors"
78 "fmt"
@@ -53,13 +54,25 @@ func PatchRef(id string) string { return AgentRef(id) + "/patch" }
5354 type ResidentState string
5455
5556 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)
6376 )
6477
6578 // ResidentChildSpec is the non-secret configuration required to reconstruct
@@ -99,23 +112,26 @@ type ResidentChildSpec struct {
99112 }
100113
101114 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:"-"`
119135 }
120136
121137 // RecordAgentEvent persists finalized provider-neutral transcript events. It
@@ -229,6 +245,7 @@ type ResidentMetadata struct {
229245 State ResidentState `json:"state"`
230246 UpdatedAt time.Time `json:"updated_at"`
231247 Usage provider.Usage `json:"usage,omitempty"`
248+ BudgetBaseline int64 `json:"budget_baseline,omitempty"`
232249 ContextUsed int `json:"context_used,omitempty"`
233250 ContextMax int `json:"context_max,omitempty"`
234251 Subscription bool `json:"subscription,omitempty"`
@@ -243,6 +260,7 @@ type ResidentResult struct {
243260 TurnID string `json:"turn_id"`
244261 State ResidentState `json:"state"`
245262 Summary string `json:"summary,omitempty"`
263+ Handoff string `json:"handoff,omitempty"`
246264 ErrorCode string `json:"error_code,omitempty"`
247265 PatchRef string `json:"patch_ref,omitempty"`
248266 ChangedFiles []string `json:"changed_files,omitempty"`
@@ -259,6 +277,7 @@ type ResidentJournal struct {
259277 latestSummary string
260278 usageMu sync.RWMutex
261279 usage provider.Usage
280+ budgetBaseline int64
262281 usageRecordPersisted bool
263282 usageMetadataPersisted bool
264283 contextUsed int
@@ -272,10 +291,11 @@ type ResidentJournal struct {
272291 // ResidentUsageSnapshot is the bounded usage projection shared by durable and
273292 // live resident state.
274293 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
279299 }
280300
281301 // ConfigureUsage records resolved model metadata and restores the latest
@@ -287,11 +307,12 @@ func (j *ResidentJournal) ConfigureUsage(contextMax int, subscription bool) Resi
287307 j.usageMu.Lock()
288308 if metadata, err := ReadResidentMetadata(filepath.Join(j.dir, residentMetadataName)); err == nil {
289309 j.usage = metadata.Usage
310+ j.budgetBaseline = metadata.BudgetBaseline
290311 j.contextUsed = metadata.ContextUsed
291312 }
292313 j.contextMax = contextMax
293314 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}
295316 j.usageMu.Unlock()
296317 return snapshot
297318 }
@@ -302,7 +323,12 @@ func (j *ResidentJournal) usageSnapshot() ResidentUsageSnapshot {
302323 }
303324 j.usageMu.RLock()
304325 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
306332 }
307333
308334 func (j *ResidentJournal) usageRecordPersistence(record residentRecord) (durable, metadataPersisted bool) {
@@ -439,14 +465,26 @@ func (j *ResidentJournal) AcceptFollowUp(spec ResidentChildSpec, turnID, prompt
439465 return errors.New("resident journal: closed")
440466 }
441467 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 {
443478 return err
444479 }
480+ j.usageMu.Lock()
481+ j.budgetBaseline = baseline
482+ j.usageMu.Unlock()
445483 return writeResidentMetadata(j.dir, j.metadata(spec, ResidentQueued, now))
446484 }
447485
448486 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 {
450488 return err
451489 }
452490 j.summaryMu.Lock()
@@ -462,27 +500,53 @@ func (j *ResidentJournal) RecordTurnFinished(spec ResidentChildSpec, turnID stri
462500 // RecordTurnFinishedWithCapture publishes bounded workspace artifacts before
463501 // the terminal projection. The transcript remains the lifecycle authority.
464502 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
466511 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
468522 }
469523 result := ResidentResult{Version: residentJournalVersion, ID: spec.ID, TurnID: turnID, State: state, Summary: j.latestAssistantSummary(), CreatedAt: time.Now().UTC()}
470524 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)
472530 }
473531 if capture != nil {
474532 result.ChangedFiles = append([]string(nil), capture.ChangedFiles...)
475533 if len(capture.Patch) > 0 {
476534 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)
478536 }
479537 result.PatchRef = PatchRef(spec.ID)
480538 }
481539 }
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
484545 }
485- return writeResidentResult(j.dir, result)
546+ if err := writeResidentResult(j.dir, result); err != nil {
547+ return ResidentResult{}, err
548+ }
549+ return result, nil
486550 }
487551
488552 func (j *ResidentJournal) recordAssistantSummary(message provider.Message) {
@@ -537,11 +601,11 @@ func truncateResidentResultSummary(text string) string {
537601 }
538602
539603 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})
541605 }
542606
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) == "" {
545609 return errors.New("resident journal: missing turn ID")
546610 }
547611 j.mu.Lock()
@@ -549,11 +613,11 @@ func (j *ResidentJournal) recordTurnBoundary(spec ResidentChildSpec, recordType,
549613 if j.file == nil {
550614 return errors.New("resident journal: closed")
551615 }
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 {
554618 return err
555619 }
556- return writeResidentMetadata(j.dir, j.metadata(spec, state, now))
620+ return writeResidentMetadata(j.dir, j.metadata(spec, state, record.Time))
557621 }
558622
559623 func (j *ResidentJournal) metadata(spec ResidentChildSpec, state ResidentState, updatedAt time.Time) ResidentMetadata {
@@ -561,7 +625,7 @@ func (j *ResidentJournal) metadata(spec ResidentChildSpec, state ResidentState,
561625 return ResidentMetadata{
562626 Version: residentJournalVersion, ID: spec.ID, SessionID: spec.SessionID,
563627 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,
565629 }
566630 }
567631
@@ -758,7 +822,8 @@ func reconcileOwnedResidentJournal(journal *ResidentJournal) (ResidentMetadata,
758822 }
759823 state := ResidentQueued
760824 lastStateAt := records[0].Time
761- lastFinishedTurn, lastFinishedOutcome, lastAssistantSummary := "", "", ""
825+ var lastFinished residentRecord
826+ lastAssistantSummary := ""
762827 usage := ResidentUsageSnapshot{}
763828 seenTurns := make(map[string]string)
764829 // The initial prompt is accepted atomically with child.accepted rather than
@@ -779,6 +844,7 @@ func reconcileOwnedResidentJournal(journal *ResidentJournal) (ResidentMetadata,
779844 return ResidentMetadata{}, errors.New("resident journal: invalid accepted turn record")
780845 }
781846 seenTurns[record.TurnID] = residentRecordTurnAccepted
847+ usage.BudgetBaseline = record.BudgetBaseline
782848 lastStateAt = record.Time
783849 case residentRecordTurnStarted:
784850 if record.TurnID == "" || (seenTurns[record.TurnID] != residentRecordTurnAccepted && record.TurnID != spec.InitialTurnID) {
@@ -793,10 +859,15 @@ func reconcileOwnedResidentJournal(journal *ResidentJournal) (ResidentMetadata,
793859 return ResidentMetadata{}, errors.New("resident journal: turn finished without start")
794860 }
795861 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:
798865 state = ResidentFailed
799- } else {
866+ case residentOutcomeBudgetExhausted:
867+ state = ResidentBudgetExhausted
868+ case residentOutcomeCompletedBudgetExhausted:
869+ state = ResidentCompleted
870+ default:
800871 state = ResidentIdle
801872 }
802873 lastStateAt = record.Time
@@ -843,7 +914,7 @@ func reconcileOwnedResidentJournal(journal *ResidentJournal) (ResidentMetadata,
843914 if record.Usage == nil {
844915 return ResidentMetadata{}, errors.New("resident journal: invalid usage record")
845916 }
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}
847918 }
848919 }
849920 if lastStateAt.IsZero() {
@@ -852,7 +923,7 @@ func reconcileOwnedResidentJournal(journal *ResidentJournal) (ResidentMetadata,
852923 metadata := ResidentMetadata{
853924 Version: residentJournalVersion, ID: spec.ID, SessionID: spec.SessionID,
854925 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,
856927 }
857928 needsInterruption := state == ResidentQueued || state == ResidentRunning
858929 needsToolRepair := len(toolCalls) != len(toolResults)
@@ -880,7 +951,7 @@ func reconcileOwnedResidentJournal(journal *ResidentJournal) (ResidentMetadata,
880951 if err := writeResidentMetadata(dir, metadata); err != nil {
881952 return ResidentMetadata{}, err
882953 }
883- if err := rebuildResidentResult(dir, spec, metadata, lastFinishedTurn, lastFinishedOutcome, lastAssistantSummary); err != nil {
954+ if err := rebuildResidentResult(dir, spec, metadata, lastFinished, lastAssistantSummary); err != nil {
884955 return ResidentMetadata{}, err
885956 }
886957 return metadata, nil
@@ -911,7 +982,7 @@ func reconcileOwnedResidentJournal(journal *ResidentJournal) (ResidentMetadata,
911982 if err := writeResidentMetadata(dir, metadata); err != nil {
912983 return ResidentMetadata{}, err
913984 }
914- if err := rebuildResidentResult(dir, spec, metadata, lastFinishedTurn, lastFinishedOutcome, lastAssistantSummary); err != nil {
985+ if err := rebuildResidentResult(dir, spec, metadata, lastFinished, lastAssistantSummary); err != nil {
915986 return ResidentMetadata{}, err
916987 }
917988 return metadata, nil
@@ -1080,7 +1151,8 @@ func (j *ResidentJournal) rewriteTranscript(records []residentRecord) error {
10801151 return nil
10811152 }
10821153
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
10841156 if turnID == "" {
10851157 return nil
10861158 }
@@ -1092,9 +1164,15 @@ func rebuildResidentResult(dir string, spec ResidentChildSpec, metadata Resident
10921164 return fmt.Errorf("resident journal remove corrupt result projection: %w", removeErr)
10931165 }
10941166 }
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)
10981176 }
10991177 return writeResidentResult(dir, result)
11001178 }
@@ -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 string54 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 reconstruct78 // 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. It137 // 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 string277 latestSummary string
260 usageMu sync.RWMutex278 usageMu sync.RWMutex
261 usage provider.Usage279 usage provider.Usage
280+ budgetBaseline int64
262 usageRecordPersisted bool281 usageRecordPersisted bool
263 usageMetadataPersisted bool282 usageMetadataPersisted bool
264 contextUsed int283 contextUsed int
@@ -272,10 +291,11 @@ type ResidentJournal struct {
272 // ResidentUsageSnapshot is the bounded usage projection shared by durable and291 // 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.Usage294+ Usage provider.Usage
276- ContextUsed int295+ ContextUsed int
277- ContextMax int296+ ContextMax int
278- Subscription bool297+ Subscription bool
298+ BudgetBaseline int64
279 }299 }
280 300
281 // ConfigureUsage records resolved model metadata and restores the latest301 // 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.Usage309 j.usage = metadata.Usage
310+ j.budgetBaseline = metadata.BudgetBaseline
290 j.contextUsed = metadata.ContextUsed311 j.contextUsed = metadata.ContextUsed
291 }312 }
292 j.contextMax = contextMax313 j.contextMax = contextMax
293 j.subscription = subscription314 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 snapshot317 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 err478 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 err488 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 before500 // 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 recordErr541+ // 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 err618 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 := ResidentQueued823 state := ResidentQueued
760 lastStateAt := records[0].Time824 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 than829 // 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] = residentRecordTurnAccepted846 seenTurns[record.TurnID] = residentRecordTurnAccepted
847+ usage.BudgetBaseline = record.BudgetBaseline
782 lastStateAt = record.Time848 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] = residentRecordTurnFinished861 seenTurns[record.TurnID] = residentRecordTurnFinished
796- lastFinishedTurn, lastFinishedOutcome = record.TurnID, record.Outcome862+ lastFinished = record
797- if record.Outcome == "failed" {863+ switch record.Outcome {
864+ case residentOutcomeFailed:
798 state = ResidentFailed865 state = ResidentFailed
799- } else {866+ case residentOutcomeBudgetExhausted:
867+ state = ResidentBudgetExhausted
868+ case residentOutcomeCompletedBudgetExhausted:
869+ state = ResidentCompleted
870+ default:
800 state = ResidentIdle871 state = ResidentIdle
801 }872 }
802 lastStateAt = record.Time873 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 == ResidentRunning928 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{}, err952 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{}, err955 return ResidentMetadata{}, err
885 }956 }
886 return metadata, nil957 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{}, err983 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{}, err986 return ResidentMetadata{}, err
916 }987 }
917 return metadata, nil988 return metadata, nil
@@ -1080,7 +1151,8 @@ func (j *ResidentJournal) rewriteTranscript(records []residentRecord) error {
1080 return nil1151 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 nil1157 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 {
230230 return ResidentSnapshot{}
231231 }
232232 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())
234234 return ResidentSnapshot{ID: child.spec.ID, State: child.State(), Profile: child.spec.Profile,
235235 Provider: child.spec.Provider, Model: child.spec.Model,
236236 WorkspaceMode: child.spec.WorkspaceMode, Required: child.spec.Required,
@@ -240,8 +240,8 @@ func residentSnapshot(child *ResidentChild) ResidentSnapshot {
240240 Budget: budget, BudgetSource: budgetSource}
241241 }
242242
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
245245 }
246246
247247 func NewResidentManager(root string, factory ResidentFactory) *ResidentManager {
@@ -470,10 +470,6 @@ func (m *ResidentManager) Resume(ctx context.Context, childID, prompt string) er
470470 if child == nil && snapshot.OwnedElsewhere {
471471 recovered = true
472472 }
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- }
477473 if child == nil && recovered {
478474 if _, pending := m.pending[childID]; pending {
479475 m.mu.Unlock()
@@ -482,8 +478,8 @@ func (m *ResidentManager) Resume(ctx context.Context, childID, prompt string) er
482478 m.pending[childID] = struct{}{}
483479 }
484480 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")
487483 }
488484 if child == nil && recovered {
489485 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
494490 }
495491 return err
496492 }
497- if _, err := reconcileOwnedResidentJournal(journal); err != nil {
493+ metadata, err := reconcileOwnedResidentJournal(journal)
494+ if err != nil {
498495 _ = journal.Close()
499496 return err
500497 }
498+ journal.ConfigureUsage(metadata.ContextMax, metadata.Subscription)
501499 records, err := ReadResidentJournal(filepath.Join(journal.Dir(), residentTranscriptName))
502500 if err != nil || len(records) == 0 || records[0].Spec == nil {
503501 _ = journal.Close()
@@ -876,7 +874,7 @@ func (m *ResidentManager) Reconcile() []error {
876874 errs = append(errs, fmt.Errorf("resident child %s: %w", childID, reconcileErr))
877875 continue
878876 }
879- budget, budgetSource := residentBudgetSnapshot(metadata.Usage, spec.BudgetLimit, spec.BudgetSource)
877+ budget, budgetSource := residentBudgetSnapshot(metadata.Usage, spec.BudgetLimit, spec.BudgetSource, metadata.BudgetBaseline)
880878 m.mu.Lock()
881879 if _, live := m.children[childID]; !live {
882880 m.recovered[childID] = ResidentSnapshot{
@@ -899,7 +897,7 @@ func (m *ResidentManager) UnmetRequired() []ResidentSnapshot {
899897 all := m.Snapshot()
900898 result := all[:0]
901899 for _, snapshot := range all {
902- if snapshot.Required && snapshot.State != ResidentIdle {
900+ if snapshot.Required && snapshot.State != ResidentIdle && snapshot.State != ResidentCompleted {
903901 result = append(result, snapshot)
904902 }
905903 }
@@ -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), source244+ 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 = true471 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 err491 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 err496 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 continue875 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) {
5555 }
5656 }
5757
58-func TestResidentManagerRejectsResumeAfterBudgetExhaustion(t *testing.T) {
58+func TestResidentManagerCompletedAnswerAtBudgetLimitCanResume(t *testing.T) {
5959 manager := NewResidentManager(t.TempDir(), func(_ ResidentChildSpec, journal *ResidentJournal) (ResidentTurnRunner, error) {
6060 return func(context.Context, string) error {
6161 return journal.RecordAgentEvent(core.EvUsage{Cumulative: provider.Usage{InputTokens: 100}})
@@ -73,10 +73,10 @@ func TestResidentManagerRejectsResumeAfterBudgetExhaustion(t *testing.T) {
7373 t.Fatalf("initial completion error = %v", result.Err)
7474 }
7575 snapshot, ok := manager.SnapshotFor(spec.ID)
76- if !ok || snapshot.State != ResidentIdle || snapshot.Budget.State != BudgetExceeded {
76+ if !ok || snapshot.State != ResidentCompleted || snapshot.Budget.State != BudgetExceeded {
7777 t.Fatalf("terminal snapshot = %#v, found=%t", snapshot, ok)
7878 }
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 {
8080 t.Fatalf("resume error = %v", err)
8181 }
8282 }
@@ -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
33 import (
44 "context"
55 "encoding/json"
6+ "errors"
67 "strings"
78 "sync"
89 "testing"
@@ -13,6 +14,54 @@ import (
1314 "github.com/bnema/zut/packages/provider"
1415 )
1516
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+
1665 func TestResidentToolsUseManagerOnly(t *testing.T) {
1766 runs := make(chan string, 2)
1867 manager := subagents.NewResidentManager(t.TempDir(), func(subagents.ResidentChildSpec, *subagents.ResidentJournal) (subagents.ResidentTurnRunner, error) {
@@ -79,25 +128,42 @@ func TestSubagentSpawnGuidanceRequiresIndependentOwnership(t *testing.T) {
79128 }
80129
81130 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+ })
101167 }
102168 }
103169
@@ -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 }, nil132+ 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}, nil141+ } {
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 = `{
4141 func (t *SubagentResumeTool) Name() string { return SubagentResumeToolName }
4242
4343 func (t *SubagentResumeTool) Description() string {
44- return "Continue a resident sub-agent with a follow-up prompt and retained session."
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."
4545 }
4646
4747 func (t *SubagentResumeTool) Schema() json.RawMessage {
@@ -41,7 +41,7 @@ const subagentResumeSchema = `{
41 func (t *SubagentResumeTool) Name() string { return SubagentResumeToolName }41 func (t *SubagentResumeTool) Name() string { return SubagentResumeToolName }
42 42
43 func (t *SubagentResumeTool) Description() string {43 func (t *SubagentResumeTool) Description() string {
44- return "Continue a resident sub-agent with a follow-up prompt and retained session."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, _
266266 } else if _, err := t.ResidentManager.Spawn(ctx, spec, task); err != nil {
267267 return core.ToolResult{}, fmt.Errorf("%s: %w", prefix, err)
268268 }
269- state := "queued"
269+ state := string(subagents.ResidentQueued)
270270 if a.Wait != nil {
271271 if waitTimedOut {
272272 if current, ok := t.ResidentManager.State(spec.ID); ok {
273273 state = string(current)
274274 }
275- } else if completion.Err != nil {
276- state = "failed"
277275 } else {
278- state = "completed"
276+ state = completion.Completion().Status
279277 }
280278 }
281279 var sb strings.Builder
@@ -300,7 +298,7 @@ func (t *SubagentSpawnTool) Execute(ctx context.Context, raw json.RawMessage, _
300298 } else if completion.Err != nil {
301299 fmt.Fprintf(&sb, "error: %s\n", completion.Err)
302300 if completion.Summary != "" {
303- fmt.Fprintf(&sb, "final: %s\n", completion.Summary)
301+ fmt.Fprintf(&sb, "partial: %s\n", completion.Summary)
304302 }
305303 } else {
306304 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.Builder279 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 (
44 "context"
55 "encoding/json"
66 "fmt"
7+ "os"
78 "strings"
89
910 "github.com/bnema/zut/packages/agent/subagents"
@@ -14,21 +15,22 @@ import (
1415 // SubagentStatusTool reports live state for background sub-agents without
1516 // waiting for a child to finish. An omitted agent_id lists resident children;
1617 // 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.
2020 type SubagentStatusTool struct {
2121 ResidentManager *subagents.ResidentManager
2222 Enabled func() bool
2323 }
2424
2525 type subagentStatusArgs struct {
26- AgentID string `json:"agent_id,omitempty"`
26+ AgentID string `json:"agent_id,omitempty"`
27+ IncludeResult bool `json:"include_result,omitempty"`
2728 }
2829
2930 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"`
3234 }
3335
3436 type subagentStatusEntry struct {
@@ -50,6 +52,10 @@ const subagentStatusSchema = `{
5052 "agent_id": {
5153 "type": "string",
5254 "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."
5359 }
5460 }
5561 }`
@@ -57,7 +63,7 @@ const subagentStatusSchema = `{
5763 func (t *SubagentStatusTool) Name() string { return SubagentStatusToolName }
5864
5965 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."
6167 }
6268
6369 func (t *SubagentStatusTool) Schema() json.RawMessage {
@@ -87,6 +93,9 @@ func (t *SubagentStatusTool) Execute(ctx context.Context, raw json.RawMessage, _
8793 snapshots := t.ResidentManager.Snapshot()
8894 id := strings.TrimSpace(args.AgentID)
8995 if id == "" {
96+ if args.IncludeResult {
97+ return protocolToolError(prefix + ": include_result requires agent_id")
98+ }
9099 entries := make([]subagentStatusEntry, 0, len(snapshots))
91100 for _, snapshot := range snapshots {
92101 entries = append(entries, publicResidentStatus(snapshot))
@@ -98,7 +107,26 @@ func (t *SubagentStatusTool) Execute(ctx context.Context, raw json.RawMessage, _
98107 return protocolToolError(fmt.Sprintf("%s: no such agent %q", prefix, id))
99108 }
100109 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)
102130 }
103131
104132 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 without15 // 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 expose18+// By default the result contains metadata only. Explicit include_result reads
18-// transcripts, result output, credentials, provider settings, or filesystem19+// the bounded durable outcome/handoff for one child without model execution.
19-// paths.
20 type SubagentStatusTool struct {20 type SubagentStatusTool struct {
21 ResidentManager *subagents.ResidentManager21 ResidentManager *subagents.ResidentManager
22 Enabled func() bool22 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+}

Keyboard shortcuts

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

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

Branches 1

Tags 476

↑↓ moveenter openesc close