detached 01d4ebe

fix(subagents): apply supervisor review findings

commit
01d4ebe85ba4128dd5c24500ada389f9496dd727
author
brice · · 2026-08-05 03:28 UTC
committer
brice · · 2026-08-05 04:28 UTC
parent
880bc67
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
37 files · +1713 -374
M README.md +1 -1
@@ -352,7 +352,7 @@ Background subagents that run alongside your main session. Each one is a separat
352352
353353 > **Choose the workspace deliberately.** Use `isolation:"worktree"` for parallel coding so children cannot edit the host checkout. Use shared mode for read-only review or explicitly coordinated work. Worktree isolation is an accident-prevention guardrail, not a security sandbox.
354354
355-```
355+```text
356356 /subagents # open the dashboard
357357 /subagents new <task> # spawn an agent (shared mode)
358358 /subagents new --agent reviewer <task> # use a named markdown profile
@@ -352,7 +352,7 @@ Background subagents that run alongside your main session. Each one is a separat
352 352
353 > **Choose the workspace deliberately.** Use `isolation:"worktree"` for parallel coding so children cannot edit the host checkout. Use shared mode for read-only review or explicitly coordinated work. Worktree isolation is an accident-prevention guardrail, not a security sandbox.353 > **Choose the workspace deliberately.** Use `isolation:"worktree"` for parallel coding so children cannot edit the host checkout. Use shared mode for read-only review or explicitly coordinated work. Worktree isolation is an accident-prevention guardrail, not a security sandbox.
354 354
355-```355+```text
356 /subagents # open the dashboard356 /subagents # open the dashboard
357 /subagents new <task> # spawn an agent (shared mode)357 /subagents new <task> # spawn an agent (shared mode)
358 /subagents new --agent reviewer <task> # use a named markdown profile358 /subagents new --agent reviewer <task> # use a named markdown profile
A packages/agent/modes/auto_subagents_policy_test.go +178 -0
@@ -0,0 +1,178 @@
1+package modes
2+
3+import (
4+ "context"
5+ "strings"
6+ "testing"
7+ "time"
8+
9+ "github.com/patriceckhart/zot/packages/agent/subagents"
10+ "github.com/patriceckhart/zot/packages/core"
11+ "github.com/patriceckhart/zot/packages/provider"
12+)
13+
14+func TestAutoSubagentsSettingsDisableUnavailablePolicy(t *testing.T) {
15+ trueValue := true
16+ falseValue := false
17+
18+ tests := []struct {
19+ name string
20+ supervisor *subagents.Supervisor
21+ allowed *bool
22+ wantHint string
23+ }{
24+ {
25+ name: "supervisor unavailable",
26+ allowed: &trueValue,
27+ wantHint: "subagent supervisor not available in this mode",
28+ },
29+ {
30+ name: "launch policy excludes tool",
31+ supervisor: subagents.New(subagents.Config{Root: t.TempDir(), RepoRoot: t.TempDir()}),
32+ allowed: &falseValue,
33+ wantHint: "launch-time tool policy excludes subagent_spawn",
34+ },
35+ }
36+ for _, tt := range tests {
37+ t.Run(tt.name, func(t *testing.T) {
38+ if tt.supervisor != nil {
39+ t.Cleanup(tt.supervisor.StopAll)
40+ }
41+ iv := &Interactive{
42+ cfg: InteractiveConfig{
43+ AutoSubagentsEnabled: &trueValue,
44+ AutoSubagentsToolAllowed: tt.allowed,
45+ Supervisor: tt.supervisor,
46+ },
47+ settingsDialog: newSettingsDialog(),
48+ }
49+ iv.openSettingsDialog()
50+
51+ var item settingsItem
52+ for _, candidate := range iv.settingsDialog.items {
53+ if candidate.key == "auto_subagents_enabled" {
54+ item = candidate
55+ break
56+ }
57+ }
58+ if !item.disabled {
59+ t.Fatal("auto-subagents setting is not disabled")
60+ }
61+ if item.value {
62+ t.Fatal("disabled auto-subagents setting is shown as enabled")
63+ }
64+ if !strings.Contains(item.hint, tt.wantHint) {
65+ t.Fatalf("hint = %q; want %q", item.hint, tt.wantHint)
66+ }
67+ })
68+ }
69+}
70+
71+func TestAutoSubagentsToolRegistrationHonorsLaunchPolicy(t *testing.T) {
72+ allowed := false
73+ enabled := false
74+ supervisor := subagents.New(subagents.Config{Root: t.TempDir(), RepoRoot: t.TempDir()})
75+ t.Cleanup(supervisor.StopAll)
76+ iv := &Interactive{
77+ agent: &core.Agent{Tools: core.Registry{}},
78+ cfg: InteractiveConfig{
79+ AutoSubagentsEnabled: &enabled,
80+ AutoSubagentsToolAllowed: &allowed,
81+ Supervisor: supervisor,
82+ },
83+ dirty: make(chan struct{}, 1),
84+ }
85+
86+ iv.applyAutoSubagentsTool(true)
87+ if _, ok := iv.agent.Tools["subagent_spawn"]; ok {
88+ t.Fatal("subagent_spawn registered despite launch-time policy")
89+ }
90+ iv.applySettingToggle("auto_subagents_enabled", true)
91+ iv.mu.Lock()
92+ statusErr := iv.statusErr
93+ value := iv.cfg.AutoSubagentsEnabled != nil && *iv.cfg.AutoSubagentsEnabled
94+ iv.mu.Unlock()
95+ if value {
96+ t.Fatal("auto-subagents toggle enabled despite launch-time policy")
97+ }
98+ if !strings.Contains(statusErr, "launch-time tool policy") {
99+ t.Fatalf("toggle error = %q; want launch-time policy hint", statusErr)
100+ }
101+}
102+
103+type serializedAutoSubagentsClient struct {
104+ started chan struct{}
105+ request chan provider.Request
106+ release chan struct{}
107+ finished chan struct{}
108+}
109+
110+func (c *serializedAutoSubagentsClient) Name() string { return "serialized-auto-subagents-test" }
111+
112+func (c *serializedAutoSubagentsClient) Stream(ctx context.Context, req provider.Request) (<-chan provider.Event, error) {
113+ c.request <- req
114+ c.started <- struct{}{}
115+ out := make(chan provider.Event, 1)
116+ go func() {
117+ defer close(out)
118+ select {
119+ case <-c.release:
120+ out <- provider.EventDone{Stop: provider.StopEnd}
121+ case <-ctx.Done():
122+ out <- provider.EventDone{Stop: provider.StopAborted, Err: ctx.Err()}
123+ }
124+ close(c.finished)
125+ }()
126+ return out, nil
127+}
128+
129+func TestAutoSubagentsPromptConfigurationIsSynchronized(t *testing.T) {
130+ client := &serializedAutoSubagentsClient{
131+ started: make(chan struct{}, 1),
132+ request: make(chan provider.Request, 1),
133+ release: make(chan struct{}),
134+ finished: make(chan struct{}),
135+ }
136+ agent := core.NewAgent(client, "test-model", "base system", nil)
137+ supervisor := subagents.New(subagents.Config{Root: t.TempDir(), RepoRoot: t.TempDir()})
138+ t.Cleanup(supervisor.StopAll)
139+ allowed := true
140+ iv := NewInteractive(InteractiveConfig{
141+ Agent: agent,
142+ Supervisor: supervisor,
143+ AutoSubagentsToolAllowed: &allowed,
144+ AutoSubagentsSystemAddendum: "delegation guidance",
145+ })
146+
147+ iv.startTurn(context.Background(), "hello")
148+ select {
149+ case <-client.started:
150+ case <-time.After(time.Second):
151+ t.Fatal("prompt did not reach provider")
152+ }
153+
154+ request := <-client.request
155+ if request.System != "base system" {
156+ t.Fatalf("provider request system = %q; want base system snapshot", request.System)
157+ }
158+ applied := make(chan struct{})
159+ go func() {
160+ iv.applyAutoSubagentsSystemPrompt(true)
161+ close(applied)
162+ }()
163+ select {
164+ case <-applied:
165+ case <-time.After(time.Second):
166+ t.Fatal("system-prompt update did not apply while provider was running")
167+ }
168+ if system, _ := agent.PromptConfig(); !strings.Contains(system, "delegation guidance") {
169+ t.Fatalf("system prompt missing applied guidance: %q", system)
170+ }
171+
172+ close(client.release)
173+ select {
174+ case <-client.finished:
175+ case <-time.After(time.Second):
176+ t.Fatal("prompt did not finish after provider release")
177+ }
178+}
@@ -0,0 +1,178 @@
1+package modes
2+
3+import (
4+ "context"
5+ "strings"
6+ "testing"
7+ "time"
8+
9+ "github.com/patriceckhart/zot/packages/agent/subagents"
10+ "github.com/patriceckhart/zot/packages/core"
11+ "github.com/patriceckhart/zot/packages/provider"
12+)
13+
14+func TestAutoSubagentsSettingsDisableUnavailablePolicy(t *testing.T) {
15+ trueValue := true
16+ falseValue := false
17+
18+ tests := []struct {
19+ name string
20+ supervisor *subagents.Supervisor
21+ allowed *bool
22+ wantHint string
23+ }{
24+ {
25+ name: "supervisor unavailable",
26+ allowed: &trueValue,
27+ wantHint: "subagent supervisor not available in this mode",
28+ },
29+ {
30+ name: "launch policy excludes tool",
31+ supervisor: subagents.New(subagents.Config{Root: t.TempDir(), RepoRoot: t.TempDir()}),
32+ allowed: &falseValue,
33+ wantHint: "launch-time tool policy excludes subagent_spawn",
34+ },
35+ }
36+ for _, tt := range tests {
37+ t.Run(tt.name, func(t *testing.T) {
38+ if tt.supervisor != nil {
39+ t.Cleanup(tt.supervisor.StopAll)
40+ }
41+ iv := &Interactive{
42+ cfg: InteractiveConfig{
43+ AutoSubagentsEnabled: &trueValue,
44+ AutoSubagentsToolAllowed: tt.allowed,
45+ Supervisor: tt.supervisor,
46+ },
47+ settingsDialog: newSettingsDialog(),
48+ }
49+ iv.openSettingsDialog()
50+
51+ var item settingsItem
52+ for _, candidate := range iv.settingsDialog.items {
53+ if candidate.key == "auto_subagents_enabled" {
54+ item = candidate
55+ break
56+ }
57+ }
58+ if !item.disabled {
59+ t.Fatal("auto-subagents setting is not disabled")
60+ }
61+ if item.value {
62+ t.Fatal("disabled auto-subagents setting is shown as enabled")
63+ }
64+ if !strings.Contains(item.hint, tt.wantHint) {
65+ t.Fatalf("hint = %q; want %q", item.hint, tt.wantHint)
66+ }
67+ })
68+ }
69+}
70+
71+func TestAutoSubagentsToolRegistrationHonorsLaunchPolicy(t *testing.T) {
72+ allowed := false
73+ enabled := false
74+ supervisor := subagents.New(subagents.Config{Root: t.TempDir(), RepoRoot: t.TempDir()})
75+ t.Cleanup(supervisor.StopAll)
76+ iv := &Interactive{
77+ agent: &core.Agent{Tools: core.Registry{}},
78+ cfg: InteractiveConfig{
79+ AutoSubagentsEnabled: &enabled,
80+ AutoSubagentsToolAllowed: &allowed,
81+ Supervisor: supervisor,
82+ },
83+ dirty: make(chan struct{}, 1),
84+ }
85+
86+ iv.applyAutoSubagentsTool(true)
87+ if _, ok := iv.agent.Tools["subagent_spawn"]; ok {
88+ t.Fatal("subagent_spawn registered despite launch-time policy")
89+ }
90+ iv.applySettingToggle("auto_subagents_enabled", true)
91+ iv.mu.Lock()
92+ statusErr := iv.statusErr
93+ value := iv.cfg.AutoSubagentsEnabled != nil && *iv.cfg.AutoSubagentsEnabled
94+ iv.mu.Unlock()
95+ if value {
96+ t.Fatal("auto-subagents toggle enabled despite launch-time policy")
97+ }
98+ if !strings.Contains(statusErr, "launch-time tool policy") {
99+ t.Fatalf("toggle error = %q; want launch-time policy hint", statusErr)
100+ }
101+}
102+
103+type serializedAutoSubagentsClient struct {
104+ started chan struct{}
105+ request chan provider.Request
106+ release chan struct{}
107+ finished chan struct{}
108+}
109+
110+func (c *serializedAutoSubagentsClient) Name() string { return "serialized-auto-subagents-test" }
111+
112+func (c *serializedAutoSubagentsClient) Stream(ctx context.Context, req provider.Request) (<-chan provider.Event, error) {
113+ c.request <- req
114+ c.started <- struct{}{}
115+ out := make(chan provider.Event, 1)
116+ go func() {
117+ defer close(out)
118+ select {
119+ case <-c.release:
120+ out <- provider.EventDone{Stop: provider.StopEnd}
121+ case <-ctx.Done():
122+ out <- provider.EventDone{Stop: provider.StopAborted, Err: ctx.Err()}
123+ }
124+ close(c.finished)
125+ }()
126+ return out, nil
127+}
128+
129+func TestAutoSubagentsPromptConfigurationIsSynchronized(t *testing.T) {
130+ client := &serializedAutoSubagentsClient{
131+ started: make(chan struct{}, 1),
132+ request: make(chan provider.Request, 1),
133+ release: make(chan struct{}),
134+ finished: make(chan struct{}),
135+ }
136+ agent := core.NewAgent(client, "test-model", "base system", nil)
137+ supervisor := subagents.New(subagents.Config{Root: t.TempDir(), RepoRoot: t.TempDir()})
138+ t.Cleanup(supervisor.StopAll)
139+ allowed := true
140+ iv := NewInteractive(InteractiveConfig{
141+ Agent: agent,
142+ Supervisor: supervisor,
143+ AutoSubagentsToolAllowed: &allowed,
144+ AutoSubagentsSystemAddendum: "delegation guidance",
145+ })
146+
147+ iv.startTurn(context.Background(), "hello")
148+ select {
149+ case <-client.started:
150+ case <-time.After(time.Second):
151+ t.Fatal("prompt did not reach provider")
152+ }
153+
154+ request := <-client.request
155+ if request.System != "base system" {
156+ t.Fatalf("provider request system = %q; want base system snapshot", request.System)
157+ }
158+ applied := make(chan struct{})
159+ go func() {
160+ iv.applyAutoSubagentsSystemPrompt(true)
161+ close(applied)
162+ }()
163+ select {
164+ case <-applied:
165+ case <-time.After(time.Second):
166+ t.Fatal("system-prompt update did not apply while provider was running")
167+ }
168+ if system, _ := agent.PromptConfig(); !strings.Contains(system, "delegation guidance") {
169+ t.Fatalf("system prompt missing applied guidance: %q", system)
170+ }
171+
172+ close(client.release)
173+ select {
174+ case <-client.finished:
175+ case <-time.After(time.Second):
176+ t.Fatal("prompt did not finish after provider release")
177+ }
178+}
M packages/agent/modes/interactive.go +80 -20
@@ -458,6 +458,9 @@ type Interactive struct {
458458
459459 mu sync.Mutex
460460 agent *core.Agent
461+ // agentMu serializes live registry replacement with side-dialog
462+ // snapshots, whose copied-agent constructor reads the registry field.
463+ agentMu sync.Mutex
461464 // managedAutoSubagentsAddenda records exact prompt blocks appended by
462465 // auto-subagents. Disable only removes these owned occurrences, leaving
463466 // identical text that came from the user's base prompt untouched.
@@ -782,7 +785,7 @@ func NewInteractive(cfg InteractiveConfig) *Interactive {
782785 baseURL, _, err := cfg.LlamaCPPConfig()
783786 i.llamaConfigured = err == nil && baseURL != ""
784787 }
785- if cfg.AutoSubagentsEnabled != nil && *cfg.AutoSubagentsEnabled {
788+ if cfg.AutoSubagentsEnabled != nil && *cfg.AutoSubagentsEnabled && cfg.Supervisor != nil && (cfg.AutoSubagentsToolAllowed == nil || *cfg.AutoSubagentsToolAllowed) {
786789 i.managedAutoSubagentsAddenda = autoSubagentsAddenda(cfg)
787790 }
788791 if cfg.Agent != nil {
@@ -3504,7 +3507,7 @@ func (i *Interactive) applyChangedCWD(ag *core.Agent, provider, model, cwd strin
35043507 i.agent = ag
35053508 i.cfg.CWD = cwd
35063509 i.cfg.SubagentsSystemAddendum = subagentsAddendum
3507- if i.autoSubagentsEnabled() {
3510+ if i.autoSubagentsEnabledLocked() {
35083511 i.managedAutoSubagentsAddenda = autoSubagentsAddenda(i.cfg)
35093512 } else {
35103513 i.managedAutoSubagentsAddenda = nil
@@ -3855,11 +3858,10 @@ func (i *Interactive) openSettingsDialog() {
38553858 if i.cfg.AutoSubagentsEnabled != nil {
38563859 autoSubagents = *i.cfg.AutoSubagentsEnabled
38573860 }
3858- autoSubagentsDisabled := i.cfg.Supervisor == nil
3859- autoSubagentsHint := ""
3861+ autoSubagentsDisabled := !i.autoSubagentsAvailable()
3862+ autoSubagentsHint := i.autoSubagentsUnavailableHint()
38603863 if autoSubagentsDisabled {
38613864 autoSubagents = false
3862- autoSubagentsHint = "subagent supervisor not available in this mode"
38633865 }
38643866
38653867 fastMode := i.cfg.FastMode != nil && *i.cfg.FastMode
@@ -4393,8 +4395,17 @@ func (i *Interactive) applySettingToggle(key string, value bool) {
43934395 i.statusErr = ""
43944396 i.mu.Unlock()
43954397 case "auto_subagents_enabled":
4398+ if value && !i.autoSubagentsAvailable() {
4399+ i.mu.Lock()
4400+ i.statusOK = ""
4401+ i.statusErr = "auto-subagents unavailable: " + i.autoSubagentsUnavailableHint()
4402+ i.mu.Unlock()
4403+ return
4404+ }
43964405 val := value
4406+ i.mu.Lock()
43974407 i.cfg.AutoSubagentsEnabled = &val
4408+ i.mu.Unlock()
43984409 if i.cfg.SettingsStore != nil {
43994410 if err := i.cfg.SettingsStore.SetAutoSubagents(value); err != nil {
44004411 i.mu.Lock()
@@ -5415,7 +5426,7 @@ func (i *Interactive) doLogout(target string) {
54155426 // it so prompts can't go out with the stale client, and hint at
54165427 // /login. Close any LSP processes owned by its tools first.
54175428 if i.agent != nil {
5418- _ = tools.CloseLSPManagers(i.agent.Tools)
5429+ _ = tools.CloseLSPManagers(i.agent.ToolsSnapshot())
54195430 }
54205431 i.agent = nil
54215432 i.statusOK = "logged out of " + strings.Join(providers, ", ") + ". type /login to sign back in."
@@ -5625,14 +5636,29 @@ func (i *Interactive) cancelAndWaitForIdle() {
56255636 // submitted as the first question, so '/btw does X work?' fires the
56265637 // model call immediately instead of just opening an empty dialog.
56275638 func (i *Interactive) openBtwDialog(args []string) {
5628- if i.agent == nil {
5639+ i.agentMu.Lock()
5640+ defer i.agentMu.Unlock()
5641+ i.mu.Lock()
5642+ ag := i.agent
5643+ system := ""
5644+ if ag != nil {
5645+ system, _ = ag.PromptConfig()
5646+ }
5647+ theme := i.cfg.Theme
5648+ model := i.cfg.Model
5649+ cwd := i.cfg.CWD
5650+ compact := i.compactModeEnabled()
5651+ flatTools := i.cfg.FlatTools
5652+ lineInput := tui.NormalizeInputStyle(i.cfg.TUIInputStyle) == tui.InputStyleLines
5653+ i.mu.Unlock()
5654+ if ag == nil {
56295655 i.mu.Lock()
56305656 i.statusErr = "not logged in. type /login first."
56315657 i.mu.Unlock()
56325658 return
56335659 }
56345660 seed := strings.TrimSpace(strings.Join(args, " "))
5635- i.btwDialog.Open(i.cfg.Theme, i.agent, i.agent.System, i.cfg.Model, i.cfg.CWD, seed, i.compactModeEnabled(), i.cfg.FlatTools, tui.NormalizeInputStyle(i.cfg.TUIInputStyle) == tui.InputStyleLines, i.invalidate)
5661+ i.btwDialog.Open(theme, ag, system, model, cwd, seed, compact, flatTools, lineInput, i.invalidate)
56365662 i.invalidate()
56375663 }
56385664
@@ -6009,7 +6035,7 @@ func (i *Interactive) swapModelUnserialized(prov, model string, builder func(str
60096035 ag.SeedCost(carryCost)
60106036
60116037 if i.agent != nil && i.agent != ag {
6012- _ = tools.CloseLSPManagers(i.agent.Tools)
6038+ _ = tools.CloseLSPManagers(i.agent.ToolsSnapshot())
60136039 }
60146040 i.mu.Lock()
60156041 i.agent = ag
@@ -6061,7 +6087,7 @@ func (i *Interactive) handleAuthEvent(ev auth.Event) {
60616087 i.statusOK = "logged in to " + ev.Provider + " via " + ev.Method
60626088 i.mu.Unlock()
60636089 if oldAgent != nil {
6064- _ = tools.CloseLSPManagers(oldAgent.Tools)
6090+ _ = tools.CloseLSPManagers(oldAgent.ToolsSnapshot())
60656091 }
60666092 i.applyAutoSubagentsTool(i.autoSubagentsEnabled())
60676093 i.applyTelegramTools(i.telegramBridge != nil && i.telegramBridge.Active())
@@ -7374,12 +7400,14 @@ func removeLastAutoSubagentsAddendum(system, addendum string) (string, bool) {
73747400 // The profile manifest and delegation guidance are managed together so
73757401 // toggling auto-subagents never leaves the model with names but no tool.
73767402 func (i *Interactive) applyAutoSubagentsSystemPrompt(active bool) {
7403+ i.mu.Lock()
7404+ defer i.mu.Unlock()
73777405 if i.agent == nil {
73787406 return
73797407 }
73807408 addenda := autoSubagentsAddenda(i.cfg)
73817409 if active {
7382- sys := i.agent.System
7410+ sys, _ := i.agent.PromptConfig()
73837411 for _, addendum := range addenda {
73847412 if containsAutoSubagentsAddendum(i.managedAutoSubagentsAddenda, addendum) {
73857413 continue
@@ -7390,11 +7418,11 @@ func (i *Interactive) applyAutoSubagentsSystemPrompt(active bool) {
73907418 sys += addendum
73917419 i.managedAutoSubagentsAddenda = append(i.managedAutoSubagentsAddenda, addendum)
73927420 }
7393- i.agent.System = sys
7421+ i.agent.SetSystemPrompt(sys)
73947422 return
73957423 }
73967424
7397- sys := i.agent.System
7425+ sys, _ := i.agent.PromptConfig()
73987426 changed := false
73997427 for idx := len(i.managedAutoSubagentsAddenda) - 1; idx >= 0; idx-- {
74007428 var removed bool
@@ -7403,7 +7431,7 @@ func (i *Interactive) applyAutoSubagentsSystemPrompt(active bool) {
74037431 }
74047432 i.managedAutoSubagentsAddenda = nil
74057433 if changed {
7406- i.agent.System = strings.TrimRight(sys, "\n") + "\n"
7434+ i.agent.SetSystemPrompt(strings.TrimRight(sys, "\n") + "\n")
74077435 }
74087436 }
74097437
@@ -7412,15 +7440,44 @@ func (i *Interactive) applyAutoSubagentsSystemPrompt(active bool) {
74127440 // when /settings -> auto-subagents is enabled. Mirrors applyTelegramTools'
74137441 // snapshot+mutate pattern so extension tools and /reload-ext additions
74147442 // survive a toggle.
7443+func (i *Interactive) autoSubagentsAvailable() bool {
7444+ return i.cfg.Supervisor != nil && (i.cfg.AutoSubagentsToolAllowed == nil || *i.cfg.AutoSubagentsToolAllowed)
7445+}
7446+
7447+func (i *Interactive) autoSubagentsUnavailableHint() string {
7448+ var hints []string
7449+ if i.cfg.Supervisor == nil {
7450+ hints = append(hints, "subagent supervisor not available in this mode")
7451+ }
7452+ if !i.autoSubagentsToolAllowed() {
7453+ hints = append(hints, "launch-time tool policy excludes subagent_spawn")
7454+ }
7455+ return strings.Join(hints, "; ")
7456+}
7457+
7458+func (i *Interactive) autoSubagentsToolAllowed() bool {
7459+ return i.cfg.AutoSubagentsToolAllowed == nil || *i.cfg.AutoSubagentsToolAllowed
7460+}
7461+
74157462 func (i *Interactive) autoSubagentsEnabled() bool {
7416- return i.cfg.AutoSubagentsEnabled != nil && *i.cfg.AutoSubagentsEnabled
7463+ i.mu.Lock()
7464+ defer i.mu.Unlock()
7465+ return i.autoSubagentsEnabledLocked()
7466+}
7467+
7468+func (i *Interactive) autoSubagentsEnabledLocked() bool {
7469+ return i.cfg.AutoSubagentsEnabled != nil && *i.cfg.AutoSubagentsEnabled && i.autoSubagentsAvailable()
74177470 }
74187471
74197472 func (i *Interactive) applyAutoSubagentsTool(active bool) {
7473+ i.agentMu.Lock()
7474+ defer i.agentMu.Unlock()
7475+ i.mu.Lock()
7476+ defer i.mu.Unlock()
74207477 if i.agent == nil {
74217478 return
74227479 }
7423- current := i.agent.Tools
7480+ current := i.agent.ToolsSnapshot()
74247481 next := core.Registry{}
74257482 for name, t := range current {
74267483 if name == "subagent_spawn" {
@@ -7428,11 +7485,10 @@ func (i *Interactive) applyAutoSubagentsTool(active bool) {
74287485 }
74297486 next[name] = t
74307487 }
7431- toolAllowed := i.cfg.AutoSubagentsToolAllowed == nil || *i.cfg.AutoSubagentsToolAllowed
7432- if active && toolAllowed && i.cfg.Supervisor != nil {
7488+ if active && i.autoSubagentsAvailable() {
74337489 canonical := &tools.SubagentSpawnTool{
74347490 Supervisor: i.cfg.Supervisor,
7435- Enabled: func() bool { return true },
7491+ Enabled: func() bool { return i.autoSubagentsEnabled() },
74367492 DefaultModel: func() string { return i.cfg.Model },
74377493 DefaultProvider: func() string { return i.cfg.Provider },
74387494 DefaultReasoning: func() string { return i.cfg.Reasoning },
@@ -7452,10 +7508,14 @@ func (i *Interactive) applyAutoSubagentsTool(active bool) {
74527508 // later /telegram disconnect (we only add or strip the two telegram
74537509 // entries, never the rest).
74547510 func (i *Interactive) applyTelegramTools(active bool) {
7511+ i.agentMu.Lock()
7512+ defer i.agentMu.Unlock()
7513+ i.mu.Lock()
7514+ defer i.mu.Unlock()
74557515 if i.agent == nil {
74567516 return
74577517 }
7458- current := i.agent.Tools
7518+ current := i.agent.ToolsSnapshot()
74597519 next := core.Registry{}
74607520 for name, t := range current {
74617521 if name == "telegram_send_image" || name == "telegram_send_file" {
@@ -458,6 +458,9 @@ type Interactive struct {
458 458
459 mu sync.Mutex459 mu sync.Mutex
460 agent *core.Agent460 agent *core.Agent
461+ // agentMu serializes live registry replacement with side-dialog
462+ // snapshots, whose copied-agent constructor reads the registry field.
463+ agentMu sync.Mutex
461 // managedAutoSubagentsAddenda records exact prompt blocks appended by464 // managedAutoSubagentsAddenda records exact prompt blocks appended by
462 // auto-subagents. Disable only removes these owned occurrences, leaving465 // auto-subagents. Disable only removes these owned occurrences, leaving
463 // identical text that came from the user's base prompt untouched.466 // identical text that came from the user's base prompt untouched.
@@ -782,7 +785,7 @@ func NewInteractive(cfg InteractiveConfig) *Interactive {
782 baseURL, _, err := cfg.LlamaCPPConfig()785 baseURL, _, err := cfg.LlamaCPPConfig()
783 i.llamaConfigured = err == nil && baseURL != ""786 i.llamaConfigured = err == nil && baseURL != ""
784 }787 }
785- if cfg.AutoSubagentsEnabled != nil && *cfg.AutoSubagentsEnabled {788+ if cfg.AutoSubagentsEnabled != nil && *cfg.AutoSubagentsEnabled && cfg.Supervisor != nil && (cfg.AutoSubagentsToolAllowed == nil || *cfg.AutoSubagentsToolAllowed) {
786 i.managedAutoSubagentsAddenda = autoSubagentsAddenda(cfg)789 i.managedAutoSubagentsAddenda = autoSubagentsAddenda(cfg)
787 }790 }
788 if cfg.Agent != nil {791 if cfg.Agent != nil {
@@ -3504,7 +3507,7 @@ func (i *Interactive) applyChangedCWD(ag *core.Agent, provider, model, cwd strin
3504 i.agent = ag3507 i.agent = ag
3505 i.cfg.CWD = cwd3508 i.cfg.CWD = cwd
3506 i.cfg.SubagentsSystemAddendum = subagentsAddendum3509 i.cfg.SubagentsSystemAddendum = subagentsAddendum
3507- if i.autoSubagentsEnabled() {3510+ if i.autoSubagentsEnabledLocked() {
3508 i.managedAutoSubagentsAddenda = autoSubagentsAddenda(i.cfg)3511 i.managedAutoSubagentsAddenda = autoSubagentsAddenda(i.cfg)
3509 } else {3512 } else {
3510 i.managedAutoSubagentsAddenda = nil3513 i.managedAutoSubagentsAddenda = nil
@@ -3855,11 +3858,10 @@ func (i *Interactive) openSettingsDialog() {
3855 if i.cfg.AutoSubagentsEnabled != nil {3858 if i.cfg.AutoSubagentsEnabled != nil {
3856 autoSubagents = *i.cfg.AutoSubagentsEnabled3859 autoSubagents = *i.cfg.AutoSubagentsEnabled
3857 }3860 }
3858- autoSubagentsDisabled := i.cfg.Supervisor == nil3861+ autoSubagentsDisabled := !i.autoSubagentsAvailable()
3859- autoSubagentsHint := ""3862+ autoSubagentsHint := i.autoSubagentsUnavailableHint()
3860 if autoSubagentsDisabled {3863 if autoSubagentsDisabled {
3861 autoSubagents = false3864 autoSubagents = false
3862- autoSubagentsHint = "subagent supervisor not available in this mode"
3863 }3865 }
3864 3866
3865 fastMode := i.cfg.FastMode != nil && *i.cfg.FastMode3867 fastMode := i.cfg.FastMode != nil && *i.cfg.FastMode
@@ -4393,8 +4395,17 @@ func (i *Interactive) applySettingToggle(key string, value bool) {
4393 i.statusErr = ""4395 i.statusErr = ""
4394 i.mu.Unlock()4396 i.mu.Unlock()
4395 case "auto_subagents_enabled":4397 case "auto_subagents_enabled":
4398+ if value && !i.autoSubagentsAvailable() {
4399+ i.mu.Lock()
4400+ i.statusOK = ""
4401+ i.statusErr = "auto-subagents unavailable: " + i.autoSubagentsUnavailableHint()
4402+ i.mu.Unlock()
4403+ return
4404+ }
4396 val := value4405 val := value
4406+ i.mu.Lock()
4397 i.cfg.AutoSubagentsEnabled = &val4407 i.cfg.AutoSubagentsEnabled = &val
4408+ i.mu.Unlock()
4398 if i.cfg.SettingsStore != nil {4409 if i.cfg.SettingsStore != nil {
4399 if err := i.cfg.SettingsStore.SetAutoSubagents(value); err != nil {4410 if err := i.cfg.SettingsStore.SetAutoSubagents(value); err != nil {
4400 i.mu.Lock()4411 i.mu.Lock()
@@ -5415,7 +5426,7 @@ func (i *Interactive) doLogout(target string) {
5415 // it so prompts can't go out with the stale client, and hint at5426 // it so prompts can't go out with the stale client, and hint at
5416 // /login. Close any LSP processes owned by its tools first.5427 // /login. Close any LSP processes owned by its tools first.
5417 if i.agent != nil {5428 if i.agent != nil {
5418- _ = tools.CloseLSPManagers(i.agent.Tools)5429+ _ = tools.CloseLSPManagers(i.agent.ToolsSnapshot())
5419 }5430 }
5420 i.agent = nil5431 i.agent = nil
5421 i.statusOK = "logged out of " + strings.Join(providers, ", ") + ". type /login to sign back in."5432 i.statusOK = "logged out of " + strings.Join(providers, ", ") + ". type /login to sign back in."
@@ -5625,14 +5636,29 @@ func (i *Interactive) cancelAndWaitForIdle() {
5625 // submitted as the first question, so '/btw does X work?' fires the5636 // submitted as the first question, so '/btw does X work?' fires the
5626 // model call immediately instead of just opening an empty dialog.5637 // model call immediately instead of just opening an empty dialog.
5627 func (i *Interactive) openBtwDialog(args []string) {5638 func (i *Interactive) openBtwDialog(args []string) {
5628- if i.agent == nil {5639+ i.agentMu.Lock()
5640+ defer i.agentMu.Unlock()
5641+ i.mu.Lock()
5642+ ag := i.agent
5643+ system := ""
5644+ if ag != nil {
5645+ system, _ = ag.PromptConfig()
5646+ }
5647+ theme := i.cfg.Theme
5648+ model := i.cfg.Model
5649+ cwd := i.cfg.CWD
5650+ compact := i.compactModeEnabled()
5651+ flatTools := i.cfg.FlatTools
5652+ lineInput := tui.NormalizeInputStyle(i.cfg.TUIInputStyle) == tui.InputStyleLines
5653+ i.mu.Unlock()
5654+ if ag == nil {
5629 i.mu.Lock()5655 i.mu.Lock()
5630 i.statusErr = "not logged in. type /login first."5656 i.statusErr = "not logged in. type /login first."
5631 i.mu.Unlock()5657 i.mu.Unlock()
5632 return5658 return
5633 }5659 }
5634 seed := strings.TrimSpace(strings.Join(args, " "))5660 seed := strings.TrimSpace(strings.Join(args, " "))
5635- i.btwDialog.Open(i.cfg.Theme, i.agent, i.agent.System, i.cfg.Model, i.cfg.CWD, seed, i.compactModeEnabled(), i.cfg.FlatTools, tui.NormalizeInputStyle(i.cfg.TUIInputStyle) == tui.InputStyleLines, i.invalidate)5661+ i.btwDialog.Open(theme, ag, system, model, cwd, seed, compact, flatTools, lineInput, i.invalidate)
5636 i.invalidate()5662 i.invalidate()
5637 }5663 }
5638 5664
@@ -6009,7 +6035,7 @@ func (i *Interactive) swapModelUnserialized(prov, model string, builder func(str
6009 ag.SeedCost(carryCost)6035 ag.SeedCost(carryCost)
6010 6036
6011 if i.agent != nil && i.agent != ag {6037 if i.agent != nil && i.agent != ag {
6012- _ = tools.CloseLSPManagers(i.agent.Tools)6038+ _ = tools.CloseLSPManagers(i.agent.ToolsSnapshot())
6013 }6039 }
6014 i.mu.Lock()6040 i.mu.Lock()
6015 i.agent = ag6041 i.agent = ag
@@ -6061,7 +6087,7 @@ func (i *Interactive) handleAuthEvent(ev auth.Event) {
6061 i.statusOK = "logged in to " + ev.Provider + " via " + ev.Method6087 i.statusOK = "logged in to " + ev.Provider + " via " + ev.Method
6062 i.mu.Unlock()6088 i.mu.Unlock()
6063 if oldAgent != nil {6089 if oldAgent != nil {
6064- _ = tools.CloseLSPManagers(oldAgent.Tools)6090+ _ = tools.CloseLSPManagers(oldAgent.ToolsSnapshot())
6065 }6091 }
6066 i.applyAutoSubagentsTool(i.autoSubagentsEnabled())6092 i.applyAutoSubagentsTool(i.autoSubagentsEnabled())
6067 i.applyTelegramTools(i.telegramBridge != nil && i.telegramBridge.Active())6093 i.applyTelegramTools(i.telegramBridge != nil && i.telegramBridge.Active())
@@ -7374,12 +7400,14 @@ func removeLastAutoSubagentsAddendum(system, addendum string) (string, bool) {
7374 // The profile manifest and delegation guidance are managed together so7400 // The profile manifest and delegation guidance are managed together so
7375 // toggling auto-subagents never leaves the model with names but no tool.7401 // toggling auto-subagents never leaves the model with names but no tool.
7376 func (i *Interactive) applyAutoSubagentsSystemPrompt(active bool) {7402 func (i *Interactive) applyAutoSubagentsSystemPrompt(active bool) {
7403+ i.mu.Lock()
7404+ defer i.mu.Unlock()
7377 if i.agent == nil {7405 if i.agent == nil {
7378 return7406 return
7379 }7407 }
7380 addenda := autoSubagentsAddenda(i.cfg)7408 addenda := autoSubagentsAddenda(i.cfg)
7381 if active {7409 if active {
7382- sys := i.agent.System7410+ sys, _ := i.agent.PromptConfig()
7383 for _, addendum := range addenda {7411 for _, addendum := range addenda {
7384 if containsAutoSubagentsAddendum(i.managedAutoSubagentsAddenda, addendum) {7412 if containsAutoSubagentsAddendum(i.managedAutoSubagentsAddenda, addendum) {
7385 continue7413 continue
@@ -7390,11 +7418,11 @@ func (i *Interactive) applyAutoSubagentsSystemPrompt(active bool) {
7390 sys += addendum7418 sys += addendum
7391 i.managedAutoSubagentsAddenda = append(i.managedAutoSubagentsAddenda, addendum)7419 i.managedAutoSubagentsAddenda = append(i.managedAutoSubagentsAddenda, addendum)
7392 }7420 }
7393- i.agent.System = sys7421+ i.agent.SetSystemPrompt(sys)
7394 return7422 return
7395 }7423 }
7396 7424
7397- sys := i.agent.System7425+ sys, _ := i.agent.PromptConfig()
7398 changed := false7426 changed := false
7399 for idx := len(i.managedAutoSubagentsAddenda) - 1; idx >= 0; idx-- {7427 for idx := len(i.managedAutoSubagentsAddenda) - 1; idx >= 0; idx-- {
7400 var removed bool7428 var removed bool
@@ -7403,7 +7431,7 @@ func (i *Interactive) applyAutoSubagentsSystemPrompt(active bool) {
7403 }7431 }
7404 i.managedAutoSubagentsAddenda = nil7432 i.managedAutoSubagentsAddenda = nil
7405 if changed {7433 if changed {
7406- i.agent.System = strings.TrimRight(sys, "\n") + "\n"7434+ i.agent.SetSystemPrompt(strings.TrimRight(sys, "\n") + "\n")
7407 }7435 }
7408 }7436 }
7409 7437
@@ -7412,15 +7440,44 @@ func (i *Interactive) applyAutoSubagentsSystemPrompt(active bool) {
7412 // when /settings -> auto-subagents is enabled. Mirrors applyTelegramTools'7440 // when /settings -> auto-subagents is enabled. Mirrors applyTelegramTools'
7413 // snapshot+mutate pattern so extension tools and /reload-ext additions7441 // snapshot+mutate pattern so extension tools and /reload-ext additions
7414 // survive a toggle.7442 // survive a toggle.
7443+func (i *Interactive) autoSubagentsAvailable() bool {
7444+ return i.cfg.Supervisor != nil && (i.cfg.AutoSubagentsToolAllowed == nil || *i.cfg.AutoSubagentsToolAllowed)
7445+}
7446+
7447+func (i *Interactive) autoSubagentsUnavailableHint() string {
7448+ var hints []string
7449+ if i.cfg.Supervisor == nil {
7450+ hints = append(hints, "subagent supervisor not available in this mode")
7451+ }
7452+ if !i.autoSubagentsToolAllowed() {
7453+ hints = append(hints, "launch-time tool policy excludes subagent_spawn")
7454+ }
7455+ return strings.Join(hints, "; ")
7456+}
7457+
7458+func (i *Interactive) autoSubagentsToolAllowed() bool {
7459+ return i.cfg.AutoSubagentsToolAllowed == nil || *i.cfg.AutoSubagentsToolAllowed
7460+}
7461+
7415 func (i *Interactive) autoSubagentsEnabled() bool {7462 func (i *Interactive) autoSubagentsEnabled() bool {
7416- return i.cfg.AutoSubagentsEnabled != nil && *i.cfg.AutoSubagentsEnabled7463+ i.mu.Lock()
7464+ defer i.mu.Unlock()
7465+ return i.autoSubagentsEnabledLocked()
7466+}
7467+
7468+func (i *Interactive) autoSubagentsEnabledLocked() bool {
7469+ return i.cfg.AutoSubagentsEnabled != nil && *i.cfg.AutoSubagentsEnabled && i.autoSubagentsAvailable()
7417 }7470 }
7418 7471
7419 func (i *Interactive) applyAutoSubagentsTool(active bool) {7472 func (i *Interactive) applyAutoSubagentsTool(active bool) {
7473+ i.agentMu.Lock()
7474+ defer i.agentMu.Unlock()
7475+ i.mu.Lock()
7476+ defer i.mu.Unlock()
7420 if i.agent == nil {7477 if i.agent == nil {
7421 return7478 return
7422 }7479 }
7423- current := i.agent.Tools7480+ current := i.agent.ToolsSnapshot()
7424 next := core.Registry{}7481 next := core.Registry{}
7425 for name, t := range current {7482 for name, t := range current {
7426 if name == "subagent_spawn" {7483 if name == "subagent_spawn" {
@@ -7428,11 +7485,10 @@ func (i *Interactive) applyAutoSubagentsTool(active bool) {
7428 }7485 }
7429 next[name] = t7486 next[name] = t
7430 }7487 }
7431- toolAllowed := i.cfg.AutoSubagentsToolAllowed == nil || *i.cfg.AutoSubagentsToolAllowed7488+ if active && i.autoSubagentsAvailable() {
7432- if active && toolAllowed && i.cfg.Supervisor != nil {
7433 canonical := &tools.SubagentSpawnTool{7489 canonical := &tools.SubagentSpawnTool{
7434 Supervisor: i.cfg.Supervisor,7490 Supervisor: i.cfg.Supervisor,
7435- Enabled: func() bool { return true },7491+ Enabled: func() bool { return i.autoSubagentsEnabled() },
7436 DefaultModel: func() string { return i.cfg.Model },7492 DefaultModel: func() string { return i.cfg.Model },
7437 DefaultProvider: func() string { return i.cfg.Provider },7493 DefaultProvider: func() string { return i.cfg.Provider },
7438 DefaultReasoning: func() string { return i.cfg.Reasoning },7494 DefaultReasoning: func() string { return i.cfg.Reasoning },
@@ -7452,10 +7508,14 @@ func (i *Interactive) applyAutoSubagentsTool(active bool) {
7452 // later /telegram disconnect (we only add or strip the two telegram7508 // later /telegram disconnect (we only add or strip the two telegram
7453 // entries, never the rest).7509 // entries, never the rest).
7454 func (i *Interactive) applyTelegramTools(active bool) {7510 func (i *Interactive) applyTelegramTools(active bool) {
7511+ i.agentMu.Lock()
7512+ defer i.agentMu.Unlock()
7513+ i.mu.Lock()
7514+ defer i.mu.Unlock()
7455 if i.agent == nil {7515 if i.agent == nil {
7456 return7516 return
7457 }7517 }
7458- current := i.agent.Tools7518+ current := i.agent.ToolsSnapshot()
7459 next := core.Registry{}7519 next := core.Registry{}
7460 for name, t := range current {7520 for name, t := range current {
7461 if name == "telegram_send_image" || name == "telegram_send_file" {7521 if name == "telegram_send_image" || name == "telegram_send_file" {
M packages/agent/modes/subagents_dialog.go +7 -7
@@ -115,12 +115,6 @@ type subagentsDialog struct {
115115
116116 func newSubagentsDialog() *subagentsDialog { return &subagentsDialog{} }
117117
118-// SetCurrentModel pins the model + provider every fresh spawn will
119-// inherit. The host wires this to the same Model / Provider the rest
120-// of zot is currently using, so agents started from the dashboard
121-// run on whatever the user last picked via /model. Pass empty
122-// strings to clear the override (the child then resolves its own
123-// default the same way a bare `zot` invocation does).
124118 func (d *subagentsDialog) SetCompactMode(enabled bool) {
125119 d.compactMode = enabled
126120 }
@@ -136,6 +130,12 @@ func (d *subagentsDialog) editorPrompt(th tui.Theme) string {
136130 return th.AccentBar(th.Accent)
137131 }
138132
133+// SetCurrentModel pins the model + provider every fresh spawn will
134+// inherit. The host wires this to the same Model / Provider the rest
135+// of zot is currently using, so agents started from the dashboard
136+// run on whatever the user last picked via /model. Pass empty
137+// strings to clear the override (the child then resolves its own
138+// default the same way a bare `zot` invocation does).
139139 func (d *subagentsDialog) SetCurrentModel(model, providerID string) {
140140 d.pendingModel = model
141141 d.pendingProvider = providerID
@@ -1067,7 +1067,7 @@ func (d *subagentsDialog) Render(th tui.Theme, width int) []string {
10671067 }
10681068
10691069 // Column header for readability.
1070- header := fmt.Sprintf(" %-18s %-26s %-8s %s", "PROCESS/TURN STATUS", "ID", "AGE", "ACTIVITY")
1070+ header := fmt.Sprintf(" %-18s %-26s %-8s %s", "PROC/TURN STATUS", "ID", "AGE", "ACTIVITY")
10711071 out = append(out, th.FG256(th.Muted, header))
10721072
10731073 for i, r := range d.rows {
@@ -115,12 +115,6 @@ type subagentsDialog struct {
115 115
116 func newSubagentsDialog() *subagentsDialog { return &subagentsDialog{} }116 func newSubagentsDialog() *subagentsDialog { return &subagentsDialog{} }
117 117
118-// SetCurrentModel pins the model + provider every fresh spawn will
119-// inherit. The host wires this to the same Model / Provider the rest
120-// of zot is currently using, so agents started from the dashboard
121-// run on whatever the user last picked via /model. Pass empty
122-// strings to clear the override (the child then resolves its own
123-// default the same way a bare `zot` invocation does).
124 func (d *subagentsDialog) SetCompactMode(enabled bool) {118 func (d *subagentsDialog) SetCompactMode(enabled bool) {
125 d.compactMode = enabled119 d.compactMode = enabled
126 }120 }
@@ -136,6 +130,12 @@ func (d *subagentsDialog) editorPrompt(th tui.Theme) string {
136 return th.AccentBar(th.Accent)130 return th.AccentBar(th.Accent)
137 }131 }
138 132
133+// SetCurrentModel pins the model + provider every fresh spawn will
134+// inherit. The host wires this to the same Model / Provider the rest
135+// of zot is currently using, so agents started from the dashboard
136+// run on whatever the user last picked via /model. Pass empty
137+// strings to clear the override (the child then resolves its own
138+// default the same way a bare `zot` invocation does).
139 func (d *subagentsDialog) SetCurrentModel(model, providerID string) {139 func (d *subagentsDialog) SetCurrentModel(model, providerID string) {
140 d.pendingModel = model140 d.pendingModel = model
141 d.pendingProvider = providerID141 d.pendingProvider = providerID
@@ -1067,7 +1067,7 @@ func (d *subagentsDialog) Render(th tui.Theme, width int) []string {
1067 }1067 }
1068 1068
1069 // Column header for readability.1069 // Column header for readability.
1070- header := fmt.Sprintf(" %-18s %-26s %-8s %s", "PROCESS/TURN STATUS", "ID", "AGE", "ACTIVITY")1070+ header := fmt.Sprintf(" %-18s %-26s %-8s %s", "PROC/TURN STATUS", "ID", "AGE", "ACTIVITY")
1071 out = append(out, th.FG256(th.Muted, header))1071 out = append(out, th.FG256(th.Muted, header))
1072 1072
1073 for i, r := range d.rows {1073 for i, r := range d.rows {
M packages/agent/modes/subagents_slash.go +14 -3
@@ -17,10 +17,14 @@ import (
1717 // /subagents new [--agent A] [--model M] [--provider P] [--reasoning L] <task...>
1818 // -> spawn an agent with optional profile/model overrides
1919 // /subagents kill <id> -> stop a running agent
20+// /subagents cancel <id> -> cancel the active turn
2021 // /subagents remove <id> -> tear down a terminated agent
2122 // /subagents logs <id> -> open the scrollable transcript view
23+// /subagents result|inspect <id> -> show the completed result
24+// /subagents wait <id> -> wait for the agent to finish
2225 // /subagents send <id> <text...> -> send a follow-up user turn to <id>
2326 // /subagents resume [id] -> resume an agent (omit id to pick from a list)
27+// /subagents restart-task|restart <id> -> restart the original task
2428 // /subagents attach <id> -> (planned) drop into the agent's TUI
2529 //
2630 // When cfg.Supervisor is nil the command tells the user the feature is
@@ -240,8 +244,15 @@ func (i *Interactive) runSubagents(ctx context.Context, args []string) {
240244 return
241245 }
242246 go func() {
243- a.Wait()
244- i.subagentsStatus("completed "+a.ID, "")
247+ if err := a.WaitContext(ctx); err != nil {
248+ return
249+ }
250+ // If cancellation became observable at the same time as
251+ // completion, do not report a completion that the caller
252+ // no longer owns.
253+ if ctx.Err() == nil {
254+ i.subagentsStatus("completed "+a.ID, "")
255+ }
245256 }()
246257 i.subagentsStatus("waiting for "+a.ID, "")
247258 case "resume-session", "resume", "reattach", "reopen":
@@ -311,7 +322,7 @@ func (i *Interactive) runSubagents(ctx context.Context, args []string) {
311322 // feature. Point the user at /subagents logs in the meantime.
312323 i.subagentsStatus("", "/subagents attach: not implemented yet (needs PTY reparenting). Use /subagents logs "+firstWord(rest)+" to watch its transcript.")
313324 default:
314- i.subagentsStatus("", "/subagents: unknown subcommand "+sub+" (try list / new / kill / remove / logs / send / result / resume-session / restart-task)")
325+ i.subagentsStatus("", "/subagents: unknown subcommand "+sub+" (try list / new / kill / cancel / remove / logs / send / result / wait / resume-session / restart-task)")
315326 }
316327 }
317328
@@ -17,10 +17,14 @@ import (
17 // /subagents new [--agent A] [--model M] [--provider P] [--reasoning L] <task...>17 // /subagents new [--agent A] [--model M] [--provider P] [--reasoning L] <task...>
18 // -> spawn an agent with optional profile/model overrides18 // -> spawn an agent with optional profile/model overrides
19 // /subagents kill <id> -> stop a running agent19 // /subagents kill <id> -> stop a running agent
20+// /subagents cancel <id> -> cancel the active turn
20 // /subagents remove <id> -> tear down a terminated agent21 // /subagents remove <id> -> tear down a terminated agent
21 // /subagents logs <id> -> open the scrollable transcript view22 // /subagents logs <id> -> open the scrollable transcript view
23+// /subagents result|inspect <id> -> show the completed result
24+// /subagents wait <id> -> wait for the agent to finish
22 // /subagents send <id> <text...> -> send a follow-up user turn to <id>25 // /subagents send <id> <text...> -> send a follow-up user turn to <id>
23 // /subagents resume [id] -> resume an agent (omit id to pick from a list)26 // /subagents resume [id] -> resume an agent (omit id to pick from a list)
27+// /subagents restart-task|restart <id> -> restart the original task
24 // /subagents attach <id> -> (planned) drop into the agent's TUI28 // /subagents attach <id> -> (planned) drop into the agent's TUI
25 //29 //
26 // When cfg.Supervisor is nil the command tells the user the feature is30 // When cfg.Supervisor is nil the command tells the user the feature is
@@ -240,8 +244,15 @@ func (i *Interactive) runSubagents(ctx context.Context, args []string) {
240 return244 return
241 }245 }
242 go func() {246 go func() {
243- a.Wait()247+ if err := a.WaitContext(ctx); err != nil {
244- i.subagentsStatus("completed "+a.ID, "")248+ return
249+ }
250+ // If cancellation became observable at the same time as
251+ // completion, do not report a completion that the caller
252+ // no longer owns.
253+ if ctx.Err() == nil {
254+ i.subagentsStatus("completed "+a.ID, "")
255+ }
245 }()256 }()
246 i.subagentsStatus("waiting for "+a.ID, "")257 i.subagentsStatus("waiting for "+a.ID, "")
247 case "resume-session", "resume", "reattach", "reopen":258 case "resume-session", "resume", "reattach", "reopen":
@@ -311,7 +322,7 @@ func (i *Interactive) runSubagents(ctx context.Context, args []string) {
311 // feature. Point the user at /subagents logs in the meantime.322 // feature. Point the user at /subagents logs in the meantime.
312 i.subagentsStatus("", "/subagents attach: not implemented yet (needs PTY reparenting). Use /subagents logs "+firstWord(rest)+" to watch its transcript.")323 i.subagentsStatus("", "/subagents attach: not implemented yet (needs PTY reparenting). Use /subagents logs "+firstWord(rest)+" to watch its transcript.")
313 default:324 default:
314- i.subagentsStatus("", "/subagents: unknown subcommand "+sub+" (try list / new / kill / remove / logs / send / result / resume-session / restart-task)")325+ i.subagentsStatus("", "/subagents: unknown subcommand "+sub+" (try list / new / kill / cancel / remove / logs / send / result / wait / resume-session / restart-task)")
315 }326 }
316 }327 }
317 328
M packages/agent/modes/subagents_slash_test.go +66 -0
@@ -81,6 +81,9 @@ func TestRunSupervisorSubcommandsDoNotPanic(t *testing.T) {
8181 iv.runSubagents(context.Background(), args)
8282 }()
8383 }
84+ if !strings.Contains(iv.statusErr, "cancel") || !strings.Contains(iv.statusErr, "wait") {
85+ t.Fatalf("unknown-command hint = %q; want cancel and wait", iv.statusErr)
86+ }
8487 }
8588
8689 func TestRunSupervisorRejectsNamedProfileWithoutResolver(t *testing.T) {
@@ -230,6 +233,69 @@ func TestRunSupervisorSendDeliversToAgentInbox(t *testing.T) {
230233 }
231234 }
232235
236+func TestRunSupervisorWaitHonorsRunContext(t *testing.T) {
237+ iv, f := newInteractiveForSupervisorTest(t)
238+ defer f.StopAll()
239+
240+ a, err := f.Spawn(context.Background(), "long-running task")
241+ if err != nil {
242+ t.Fatal(err)
243+ }
244+ ctx, cancel := context.WithCancel(context.Background())
245+ iv.runSubagents(ctx, []string{"wait", a.ID})
246+ iv.mu.Lock()
247+ status := iv.statusOK
248+ iv.mu.Unlock()
249+ if status != "waiting for "+a.ID {
250+ t.Fatalf("initial wait status = %q", status)
251+ }
252+ cancel()
253+
254+ // The wait watcher must stop observing the run context and must not
255+ // later overwrite the status with a false completion after cleanup.
256+ time.Sleep(20 * time.Millisecond)
257+ iv.mu.Lock()
258+ status = iv.statusOK
259+ iv.mu.Unlock()
260+ if strings.Contains(status, "completed ") {
261+ t.Fatalf("wait reported completion after cancellation: %q", status)
262+ }
263+}
264+
265+func TestRunSupervisorWaitReportsCompletionBeforeCancellation(t *testing.T) {
266+ root := t.TempDir()
267+ f := subagents.New(subagents.Config{
268+ Root: root,
269+ RepoRoot: root,
270+ NewRunner: func(*subagents.Agent) subagents.Runner {
271+ return subagents.RunnerFunc(func(context.Context, subagents.Sink) error { return nil })
272+ },
273+ })
274+ defer f.StopAll()
275+ iv := &Interactive{subagentsDialog: newSubagentsDialog(), dirty: make(chan struct{}, 1)}
276+ iv.cfg.Supervisor = f
277+
278+ a, err := f.Spawn(context.Background(), "quick task")
279+ if err != nil {
280+ t.Fatal(err)
281+ }
282+ iv.runSubagents(context.Background(), []string{"wait", a.ID})
283+ deadline := time.After(time.Second)
284+ for {
285+ iv.mu.Lock()
286+ status := iv.statusOK
287+ iv.mu.Unlock()
288+ if status == "completed "+a.ID {
289+ return
290+ }
291+ select {
292+ case <-deadline:
293+ t.Fatalf("wait did not report completion; status = %q", status)
294+ case <-time.After(time.Millisecond):
295+ }
296+ }
297+}
298+
233299 func TestNormalizeSpawnReasoning(t *testing.T) {
234300 cases := []struct {
235301 in, want string
@@ -81,6 +81,9 @@ func TestRunSupervisorSubcommandsDoNotPanic(t *testing.T) {
81 iv.runSubagents(context.Background(), args)81 iv.runSubagents(context.Background(), args)
82 }()82 }()
83 }83 }
84+ if !strings.Contains(iv.statusErr, "cancel") || !strings.Contains(iv.statusErr, "wait") {
85+ t.Fatalf("unknown-command hint = %q; want cancel and wait", iv.statusErr)
86+ }
84 }87 }
85 88
86 func TestRunSupervisorRejectsNamedProfileWithoutResolver(t *testing.T) {89 func TestRunSupervisorRejectsNamedProfileWithoutResolver(t *testing.T) {
@@ -230,6 +233,69 @@ func TestRunSupervisorSendDeliversToAgentInbox(t *testing.T) {
230 }233 }
231 }234 }
232 235
236+func TestRunSupervisorWaitHonorsRunContext(t *testing.T) {
237+ iv, f := newInteractiveForSupervisorTest(t)
238+ defer f.StopAll()
239+
240+ a, err := f.Spawn(context.Background(), "long-running task")
241+ if err != nil {
242+ t.Fatal(err)
243+ }
244+ ctx, cancel := context.WithCancel(context.Background())
245+ iv.runSubagents(ctx, []string{"wait", a.ID})
246+ iv.mu.Lock()
247+ status := iv.statusOK
248+ iv.mu.Unlock()
249+ if status != "waiting for "+a.ID {
250+ t.Fatalf("initial wait status = %q", status)
251+ }
252+ cancel()
253+
254+ // The wait watcher must stop observing the run context and must not
255+ // later overwrite the status with a false completion after cleanup.
256+ time.Sleep(20 * time.Millisecond)
257+ iv.mu.Lock()
258+ status = iv.statusOK
259+ iv.mu.Unlock()
260+ if strings.Contains(status, "completed ") {
261+ t.Fatalf("wait reported completion after cancellation: %q", status)
262+ }
263+}
264+
265+func TestRunSupervisorWaitReportsCompletionBeforeCancellation(t *testing.T) {
266+ root := t.TempDir()
267+ f := subagents.New(subagents.Config{
268+ Root: root,
269+ RepoRoot: root,
270+ NewRunner: func(*subagents.Agent) subagents.Runner {
271+ return subagents.RunnerFunc(func(context.Context, subagents.Sink) error { return nil })
272+ },
273+ })
274+ defer f.StopAll()
275+ iv := &Interactive{subagentsDialog: newSubagentsDialog(), dirty: make(chan struct{}, 1)}
276+ iv.cfg.Supervisor = f
277+
278+ a, err := f.Spawn(context.Background(), "quick task")
279+ if err != nil {
280+ t.Fatal(err)
281+ }
282+ iv.runSubagents(context.Background(), []string{"wait", a.ID})
283+ deadline := time.After(time.Second)
284+ for {
285+ iv.mu.Lock()
286+ status := iv.statusOK
287+ iv.mu.Unlock()
288+ if status == "completed "+a.ID {
289+ return
290+ }
291+ select {
292+ case <-deadline:
293+ t.Fatalf("wait did not report completion; status = %q", status)
294+ case <-time.After(time.Millisecond):
295+ }
296+ }
297+}
298+
233 func TestNormalizeSpawnReasoning(t *testing.T) {299 func TestNormalizeSpawnReasoning(t *testing.T) {
234 cases := []struct {300 cases := []struct {
235 in, want string301 in, want string
M packages/agent/settings_store.go +5 -3
@@ -213,7 +213,9 @@ func (configSettingsStore) SetTheme(name string) error {
213213 }
214214
215215 // AutoSubagentsEnabled reads the current auto-subagents flag from config.
216-// Used by the subagent_spawn tool at call time to gate execution.
216+// The build/CLI startup path uses it when initially injecting
217+// subagent_spawn into the tool registry; interactive mode mirrors the setting
218+// into the live tool registry when its settings toggle changes.
217219 func AutoSubagentsEnabled() bool {
218220 cfg, err := LoadConfig()
219221 if err != nil {
@@ -228,8 +230,8 @@ func AutoSubagentsEnabled() bool {
228230 // by name. Kept short so it doesn't bloat the cached prompt prefix.
229231 const AutoSubagentsSystemAddendum = `Auto-subagents are enabled. You have a subagent_spawn tool that starts background sub-agents in separate long-lived processes.
230232
231-Use it proactively when the user's request naturally splits into independent sub-tasks that can run concurrently; prefer isolation:"worktree" for parallel coding and use the returned result_ref/event path for completion (e.g. "refactor module A and module B", "write the implementation and the tests", "investigate three separate files"). Spawn one sub-agent per independent sub-task with a self-contained task description (sub-agents start with no context from this conversation). If [subagents_list] is present, choose the named profile whose description best matches the task and pass its name as the tool's agent field. Continue working on the remaining or coordinating work yourself in parallel; do not wait for sub-agents to finish before responding. Briefly tell the user which sub-agents you spawned and what each is doing.
233+Use it proactively when the user's request naturally splits into independent sub-tasks that can run concurrently; prefer isolation:"worktree" for parallel coding (e.g. "refactor module A and module B", "write the implementation and the tests", "investigate three separate files"). Spawn one sub-agent per independent sub-task with a self-contained task description (sub-agents start with no context from this conversation). If [subagents_list] is present, choose the named profile whose description best matches the task and pass its name as the tool's agent field. Continue working on the remaining or coordinating work yourself in parallel; do not wait for sub-agents to finish before responding. Briefly tell the user which sub-agents you spawned and what each is doing. When workers finish, use the host update's agent ID, status, task, optional error, and final response or tail to summarize the outcome and decide any follow-up.
232234
233235 Do NOT use subagent_spawn for trivial single-step work, for tasks that depend on each other sequentially, or when the user explicitly asked you to do the work yourself. Child workers cannot recursively spawn more sub-agents in v1.
234236
235-When every sub-agent you spawned reaches a terminal state, the host injects a single [auto-subagents update] message recapping each agent's status, task, and result reference. Treat that message as observed state (not as a new user request) and write a short follow-up summary referencing each agent by id.`
237+When every sub-agent you spawned reaches a terminal state, the host injects a single [auto-subagents update] message containing each agent's id, status, task, optional error, and final response (or a tail when the response is long). Treat that message as observed state (not as a new user request) and write a short follow-up summary referencing each agent by id.`
@@ -213,7 +213,9 @@ func (configSettingsStore) SetTheme(name string) error {
213 }213 }
214 214
215 // AutoSubagentsEnabled reads the current auto-subagents flag from config.215 // AutoSubagentsEnabled reads the current auto-subagents flag from config.
216-// Used by the subagent_spawn tool at call time to gate execution.216+// The build/CLI startup path uses it when initially injecting
217+// subagent_spawn into the tool registry; interactive mode mirrors the setting
218+// into the live tool registry when its settings toggle changes.
217 func AutoSubagentsEnabled() bool {219 func AutoSubagentsEnabled() bool {
218 cfg, err := LoadConfig()220 cfg, err := LoadConfig()
219 if err != nil {221 if err != nil {
@@ -228,8 +230,8 @@ func AutoSubagentsEnabled() bool {
228 // by name. Kept short so it doesn't bloat the cached prompt prefix.230 // by name. Kept short so it doesn't bloat the cached prompt prefix.
229 const AutoSubagentsSystemAddendum = `Auto-subagents are enabled. You have a subagent_spawn tool that starts background sub-agents in separate long-lived processes.231 const AutoSubagentsSystemAddendum = `Auto-subagents are enabled. You have a subagent_spawn tool that starts background sub-agents in separate long-lived processes.
230 232
231-Use it proactively when the user's request naturally splits into independent sub-tasks that can run concurrently; prefer isolation:"worktree" for parallel coding and use the returned result_ref/event path for completion (e.g. "refactor module A and module B", "write the implementation and the tests", "investigate three separate files"). Spawn one sub-agent per independent sub-task with a self-contained task description (sub-agents start with no context from this conversation). If [subagents_list] is present, choose the named profile whose description best matches the task and pass its name as the tool's agent field. Continue working on the remaining or coordinating work yourself in parallel; do not wait for sub-agents to finish before responding. Briefly tell the user which sub-agents you spawned and what each is doing.233+Use it proactively when the user's request naturally splits into independent sub-tasks that can run concurrently; prefer isolation:"worktree" for parallel coding (e.g. "refactor module A and module B", "write the implementation and the tests", "investigate three separate files"). Spawn one sub-agent per independent sub-task with a self-contained task description (sub-agents start with no context from this conversation). If [subagents_list] is present, choose the named profile whose description best matches the task and pass its name as the tool's agent field. Continue working on the remaining or coordinating work yourself in parallel; do not wait for sub-agents to finish before responding. Briefly tell the user which sub-agents you spawned and what each is doing. When workers finish, use the host update's agent ID, status, task, optional error, and final response or tail to summarize the outcome and decide any follow-up.
232 234
233 Do NOT use subagent_spawn for trivial single-step work, for tasks that depend on each other sequentially, or when the user explicitly asked you to do the work yourself. Child workers cannot recursively spawn more sub-agents in v1.235 Do NOT use subagent_spawn for trivial single-step work, for tasks that depend on each other sequentially, or when the user explicitly asked you to do the work yourself. Child workers cannot recursively spawn more sub-agents in v1.
234 236
235-When every sub-agent you spawned reaches a terminal state, the host injects a single [auto-subagents update] message recapping each agent's status, task, and result reference. Treat that message as observed state (not as a new user request) and write a short follow-up summary referencing each agent by id.`237+When every sub-agent you spawned reaches a terminal state, the host injects a single [auto-subagents update] message containing each agent's id, status, task, optional error, and final response (or a tail when the response is long). Treat that message as observed state (not as a new user request) and write a short follow-up summary referencing each agent by id.`
M packages/agent/subagent_worker.go +1 -4
@@ -235,6 +235,7 @@ func runSubagentWorkerMode(ctx context.Context, args Args, version string) error
235235 })
236236 em.emit("agent.idle", map[string]any{"turn_id": turnID})
237237
238+ cancel()
238239 mu.Lock()
239240 busyTurn = false
240241 cancelFn = nil
@@ -538,7 +539,3 @@ func truncateForLog(s string, n int) string {
538539 }
539540 return s[:n-3] + "..."
540541 }
541-
542-// _ keeps the provider import used; provider types may surface
543-// through ag.OnEvent / modes.EventToJSON in future iterations.
544-var _ provider.Content = provider.TextBlock{}
@@ -235,6 +235,7 @@ func runSubagentWorkerMode(ctx context.Context, args Args, version string) error
235 })235 })
236 em.emit("agent.idle", map[string]any{"turn_id": turnID})236 em.emit("agent.idle", map[string]any{"turn_id": turnID})
237 237
238+ cancel()
238 mu.Lock()239 mu.Lock()
239 busyTurn = false240 busyTurn = false
240 cancelFn = nil241 cancelFn = nil
@@ -538,7 +539,3 @@ func truncateForLog(s string, n int) string {
538 }539 }
539 return s[:n-3] + "..."540 return s[:n-3] + "..."
540 }541 }
541-
542-// _ keeps the provider import used; provider types may surface
543-// through ag.OnEvent / modes.EventToJSON in future iterations.
544-var _ provider.Content = provider.TextBlock{}
M packages/agent/subagent_worker_test.go +6 -4
@@ -99,10 +99,8 @@ func TestSupervisorEmitterMirrorDormantUntilStdoutBreaks(t *testing.T) {
9999 }
100100 }
101101
102-// TestSupervisorEmitterStdoutShapeMatchesSupervisorParser pins the
103-// wire-format contract: each emitted event lands on stdout as one
104-// JSON object per line with type+time at top level alongside the
105-// data fields. The supervisor's runner parses this exact shape.
102+// TestSupervisorEmitterLargeResultIsNotDuplicatedOnWire regresses the
103+// large-result wire duplication bug.
106104 func TestSupervisorEmitterLargeResultIsNotDuplicatedOnWire(t *testing.T) {
107105 path := filepath.Join(t.TempDir(), "events.jsonl")
108106 file, err := os.Create(path)
@@ -148,6 +146,10 @@ func TestSupervisorEmitterLargeResultIsNotDuplicatedOnWire(t *testing.T) {
148146 }
149147 }
150148
149+// TestSupervisorEmitterStdoutShapeMatchesSupervisorParser pins the
150+// wire-format contract: each emitted event lands on stdout as one
151+// JSON object per line with type+time at top level alongside the
152+// data fields. The supervisor's runner parses this exact shape.
151153 func TestSupervisorEmitterStdoutShapeMatchesSupervisorParser(t *testing.T) {
152154 // Pipe so we can read what the emitter wrote.
153155 r, w, err := os.Pipe()
@@ -99,10 +99,8 @@ func TestSupervisorEmitterMirrorDormantUntilStdoutBreaks(t *testing.T) {
99 }99 }
100 }100 }
101 101
102-// TestSupervisorEmitterStdoutShapeMatchesSupervisorParser pins the102+// TestSupervisorEmitterLargeResultIsNotDuplicatedOnWire regresses the
103-// wire-format contract: each emitted event lands on stdout as one103+// large-result wire duplication bug.
104-// JSON object per line with type+time at top level alongside the
105-// data fields. The supervisor's runner parses this exact shape.
106 func TestSupervisorEmitterLargeResultIsNotDuplicatedOnWire(t *testing.T) {104 func TestSupervisorEmitterLargeResultIsNotDuplicatedOnWire(t *testing.T) {
107 path := filepath.Join(t.TempDir(), "events.jsonl")105 path := filepath.Join(t.TempDir(), "events.jsonl")
108 file, err := os.Create(path)106 file, err := os.Create(path)
@@ -148,6 +146,10 @@ func TestSupervisorEmitterLargeResultIsNotDuplicatedOnWire(t *testing.T) {
148 }146 }
149 }147 }
150 148
149+// TestSupervisorEmitterStdoutShapeMatchesSupervisorParser pins the
150+// wire-format contract: each emitted event lands on stdout as one
151+// JSON object per line with type+time at top level alongside the
152+// data fields. The supervisor's runner parses this exact shape.
151 func TestSupervisorEmitterStdoutShapeMatchesSupervisorParser(t *testing.T) {153 func TestSupervisorEmitterStdoutShapeMatchesSupervisorParser(t *testing.T) {
152 // Pipe so we can read what the emitter wrote.154 // Pipe so we can read what the emitter wrote.
153 r, w, err := os.Pipe()155 r, w, err := os.Pipe()
M packages/agent/subagents/agent.go +34 -36
@@ -202,6 +202,13 @@ func (a *Agent) CurrentTurnID() string {
202202 return a.currentTurnID
203203 }
204204
205+// AttemptValue returns the current process attempt number.
206+func (a *Agent) AttemptValue() int {
207+ a.lifecycleMu.Lock()
208+ defer a.lifecycleMu.Unlock()
209+ return a.Attempt
210+}
211+
205212 // ProcessPIDValue returns the current worker pid, when known.
206213 func (a *Agent) ProcessPIDValue() int {
207214 a.lifecycleMu.Lock()
@@ -304,6 +311,19 @@ func (a *Agent) Err() error {
304311 // and by /subagents wait <id>.
305312 func (a *Agent) Wait() { <-a.done }
306313
314+// WaitContext waits for the agent to finish or for ctx cancellation.
315+func (a *Agent) WaitContext(ctx context.Context) error {
316+ if ctx == nil {
317+ ctx = context.Background()
318+ }
319+ select {
320+ case <-a.done:
321+ return nil
322+ case <-ctx.Done():
323+ return ctx.Err()
324+ }
325+}
326+
307327 func (a *Agent) waitForTurnResult(ctx context.Context) (*TurnResult, error) {
308328 if ctx == nil {
309329 ctx = context.Background()
@@ -345,7 +365,7 @@ func (a *Agent) SetOnTurnEnd(fn func(step int, errMsg string)) {
345365 a.mu.Unlock()
346366 if fn != nil {
347367 for _, notice := range pending {
348- go fn(notice.step, notice.errMsg)
368+ fn(notice.step, notice.errMsg)
349369 }
350370 }
351371 }
@@ -386,7 +406,10 @@ func (a *Agent) appendTranscriptLocked(chunk, linePrefix string, assistant bool)
386406 }
387407 a.mu.Lock()
388408 for _, line := range strings.Split(chunk, "\n") {
389- a.transcript = append(a.transcript, linePrefix+line)
409+ line = linePrefix + line
410+ a.transcript = append(a.transcript, line)
411+ a.outputBytes += len(line) + 1
412+ a.outputLines++
390413 }
391414 if assistant {
392415 a.lastAssistant = boundInlineText(message, a.maxOutputBytes, a.maxOutputLines)
@@ -401,26 +424,22 @@ func (a *Agent) appendTranscriptLocked(chunk, linePrefix string, assistant bool)
401424 if byteCap <= 0 {
402425 byteCap = 500_000
403426 }
404- for len(a.transcript) > lineCap {
427+ for a.outputLines > lineCap {
428+ line := a.transcript[0]
405429 a.transcript = a.transcript[1:]
430+ a.outputBytes -= len(line) + 1
431+ a.outputLines--
406432 a.outputTruncated = true
407433 }
408- for len(a.transcript) > 0 {
409- bytes := 0
410- for _, line := range a.transcript {
411- bytes += len(line) + 1
412- }
413- if bytes <= byteCap {
414- a.outputBytes = bytes
415- a.outputLines = len(a.transcript)
416- break
417- }
434+ for a.outputLines > 0 && a.outputBytes > byteCap {
435+ line := a.transcript[0]
418436 a.transcript = a.transcript[1:]
437+ a.outputBytes -= len(line) + 1
438+ a.outputLines--
419439 a.outputTruncated = true
420440 }
421- if len(a.transcript) == 0 {
441+ if a.outputLines == 0 {
422442 a.outputBytes = 0
423- a.outputLines = 0
424443 }
425444 a.mu.Unlock()
426445 a.markActivity(time.Now())
@@ -430,27 +449,6 @@ func (a *Agent) appendTranscriptLocked(chunk, linePrefix string, assistant bool)
430449 // form "<slug>-<nano>". The slug is derived from the task text so
431450 // dashboards stay readable; the nano suffix guarantees uniqueness
432451 // even when two agents are spawned in the same millisecond.
433-func boundInlineText(value string, maxBytes, maxLines int) string {
434- const marker = "...[output truncated]"
435- if maxLines > 0 {
436- lines := strings.Split(value, "\n")
437- if len(lines) > maxLines {
438- if maxLines == 1 {
439- value = marker
440- } else {
441- value = strings.Join(lines[:maxLines-1], "\n") + "\n" + marker
442- }
443- }
444- }
445- if maxBytes > 0 && len([]byte(value)) > maxBytes {
446- if maxBytes <= len(marker) {
447- return truncateUTF8(value, maxBytes)
448- }
449- return truncateUTF8(value, maxBytes-len(marker)) + marker
450- }
451- return value
452-}
453-
454452 func newAgentID(task string, now time.Time) string {
455453 slug := taskSlug(task)
456454 return fmt.Sprintf("%s-%d", slug, now.UnixNano()%1_000_000)
@@ -202,6 +202,13 @@ func (a *Agent) CurrentTurnID() string {
202 return a.currentTurnID202 return a.currentTurnID
203 }203 }
204 204
205+// AttemptValue returns the current process attempt number.
206+func (a *Agent) AttemptValue() int {
207+ a.lifecycleMu.Lock()
208+ defer a.lifecycleMu.Unlock()
209+ return a.Attempt
210+}
211+
205 // ProcessPIDValue returns the current worker pid, when known.212 // ProcessPIDValue returns the current worker pid, when known.
206 func (a *Agent) ProcessPIDValue() int {213 func (a *Agent) ProcessPIDValue() int {
207 a.lifecycleMu.Lock()214 a.lifecycleMu.Lock()
@@ -304,6 +311,19 @@ func (a *Agent) Err() error {
304 // and by /subagents wait <id>.311 // and by /subagents wait <id>.
305 func (a *Agent) Wait() { <-a.done }312 func (a *Agent) Wait() { <-a.done }
306 313
314+// WaitContext waits for the agent to finish or for ctx cancellation.
315+func (a *Agent) WaitContext(ctx context.Context) error {
316+ if ctx == nil {
317+ ctx = context.Background()
318+ }
319+ select {
320+ case <-a.done:
321+ return nil
322+ case <-ctx.Done():
323+ return ctx.Err()
324+ }
325+}
326+
307 func (a *Agent) waitForTurnResult(ctx context.Context) (*TurnResult, error) {327 func (a *Agent) waitForTurnResult(ctx context.Context) (*TurnResult, error) {
308 if ctx == nil {328 if ctx == nil {
309 ctx = context.Background()329 ctx = context.Background()
@@ -345,7 +365,7 @@ func (a *Agent) SetOnTurnEnd(fn func(step int, errMsg string)) {
345 a.mu.Unlock()365 a.mu.Unlock()
346 if fn != nil {366 if fn != nil {
347 for _, notice := range pending {367 for _, notice := range pending {
348- go fn(notice.step, notice.errMsg)368+ fn(notice.step, notice.errMsg)
349 }369 }
350 }370 }
351 }371 }
@@ -386,7 +406,10 @@ func (a *Agent) appendTranscriptLocked(chunk, linePrefix string, assistant bool)
386 }406 }
387 a.mu.Lock()407 a.mu.Lock()
388 for _, line := range strings.Split(chunk, "\n") {408 for _, line := range strings.Split(chunk, "\n") {
389- a.transcript = append(a.transcript, linePrefix+line)409+ line = linePrefix + line
410+ a.transcript = append(a.transcript, line)
411+ a.outputBytes += len(line) + 1
412+ a.outputLines++
390 }413 }
391 if assistant {414 if assistant {
392 a.lastAssistant = boundInlineText(message, a.maxOutputBytes, a.maxOutputLines)415 a.lastAssistant = boundInlineText(message, a.maxOutputBytes, a.maxOutputLines)
@@ -401,26 +424,22 @@ func (a *Agent) appendTranscriptLocked(chunk, linePrefix string, assistant bool)
401 if byteCap <= 0 {424 if byteCap <= 0 {
402 byteCap = 500_000425 byteCap = 500_000
403 }426 }
404- for len(a.transcript) > lineCap {427+ for a.outputLines > lineCap {
428+ line := a.transcript[0]
405 a.transcript = a.transcript[1:]429 a.transcript = a.transcript[1:]
430+ a.outputBytes -= len(line) + 1
431+ a.outputLines--
406 a.outputTruncated = true432 a.outputTruncated = true
407 }433 }
408- for len(a.transcript) > 0 {434+ for a.outputLines > 0 && a.outputBytes > byteCap {
409- bytes := 0435+ line := a.transcript[0]
410- for _, line := range a.transcript {
411- bytes += len(line) + 1
412- }
413- if bytes <= byteCap {
414- a.outputBytes = bytes
415- a.outputLines = len(a.transcript)
416- break
417- }
418 a.transcript = a.transcript[1:]436 a.transcript = a.transcript[1:]
437+ a.outputBytes -= len(line) + 1
438+ a.outputLines--
419 a.outputTruncated = true439 a.outputTruncated = true
420 }440 }
421- if len(a.transcript) == 0 {441+ if a.outputLines == 0 {
422 a.outputBytes = 0442 a.outputBytes = 0
423- a.outputLines = 0
424 }443 }
425 a.mu.Unlock()444 a.mu.Unlock()
426 a.markActivity(time.Now())445 a.markActivity(time.Now())
@@ -430,27 +449,6 @@ func (a *Agent) appendTranscriptLocked(chunk, linePrefix string, assistant bool)
430 // form "<slug>-<nano>". The slug is derived from the task text so449 // form "<slug>-<nano>". The slug is derived from the task text so
431 // dashboards stay readable; the nano suffix guarantees uniqueness450 // dashboards stay readable; the nano suffix guarantees uniqueness
432 // even when two agents are spawned in the same millisecond.451 // even when two agents are spawned in the same millisecond.
433-func boundInlineText(value string, maxBytes, maxLines int) string {
434- const marker = "...[output truncated]"
435- if maxLines > 0 {
436- lines := strings.Split(value, "\n")
437- if len(lines) > maxLines {
438- if maxLines == 1 {
439- value = marker
440- } else {
441- value = strings.Join(lines[:maxLines-1], "\n") + "\n" + marker
442- }
443- }
444- }
445- if maxBytes > 0 && len([]byte(value)) > maxBytes {
446- if maxBytes <= len(marker) {
447- return truncateUTF8(value, maxBytes)
448- }
449- return truncateUTF8(value, maxBytes-len(marker)) + marker
450- }
451- return value
452-}
453-
454 func newAgentID(task string, now time.Time) string {452 func newAgentID(task string, now time.Time) string {
455 slug := taskSlug(task)453 slug := taskSlug(task)
456 return fmt.Sprintf("%s-%d", slug, now.UnixNano()%1_000_000)454 return fmt.Sprintf("%s-%d", slug, now.UnixNano()%1_000_000)
A packages/agent/subagents/agent_test.go +49 -0
@@ -0,0 +1,49 @@
1+package subagents
2+
3+import "testing"
4+
5+func TestAgentAttemptValueAndTranscriptAccounting(t *testing.T) {
6+ a := &Agent{maxOutputBytes: 6, maxOutputLines: 10}
7+ if got := a.AttemptValue(); got != 0 {
8+ t.Fatalf("initial attempt = %d, want 0", got)
9+ }
10+ if got := a.incrementAttempt(); got != 1 || a.AttemptValue() != 1 {
11+ t.Fatalf("attempt after increment = %d, value = %d; want 1", got, a.AttemptValue())
12+ }
13+
14+ a.appendTranscript("a\nbb")
15+ if got := a.outputBytes; got != 5 {
16+ t.Fatalf("output bytes after append = %d, want 5", got)
17+ }
18+ if got := a.outputLines; got != 2 {
19+ t.Fatalf("output lines after append = %d, want 2", got)
20+ }
21+ a.appendTranscript("ccc")
22+ if got, want := a.Transcript(), []string{"ccc"}; len(got) != 1 || got[0] != want[0] {
23+ t.Fatalf("transcript after byte eviction = %q, want %q", got, want)
24+ }
25+ if a.outputBytes != 4 || a.outputLines != 1 {
26+ t.Fatalf("accounting after byte eviction = (%d bytes, %d lines), want (4, 1)", a.outputBytes, a.outputLines)
27+ }
28+
29+ a.maxOutputBytes = 1
30+ a.appendTranscript("z")
31+ if got := a.Transcript(); len(got) != 0 {
32+ t.Fatalf("transcript after evicting every line = %q, want empty", got)
33+ }
34+ if a.outputBytes != 0 || a.outputLines != 0 {
35+ t.Fatalf("empty accounting = (%d bytes, %d lines), want (0, 0)", a.outputBytes, a.outputLines)
36+ }
37+}
38+
39+func TestAgentTranscriptLineCapAccounting(t *testing.T) {
40+ a := &Agent{maxOutputBytes: 100, maxOutputLines: 2}
41+ a.appendTranscript("one\ntwo\nthree")
42+
43+ if got, want := a.Transcript(), []string{"two", "three"}; len(got) != len(want) || got[0] != want[0] || got[1] != want[1] {
44+ t.Fatalf("transcript = %q, want %q", got, want)
45+ }
46+ if a.outputBytes != len("two")+1+len("three")+1 || a.outputLines != 2 {
47+ t.Fatalf("line-cap accounting = (%d bytes, %d lines), want (10, 2)", a.outputBytes, a.outputLines)
48+ }
49+}
@@ -0,0 +1,49 @@
1+package subagents
2+
3+import "testing"
4+
5+func TestAgentAttemptValueAndTranscriptAccounting(t *testing.T) {
6+ a := &Agent{maxOutputBytes: 6, maxOutputLines: 10}
7+ if got := a.AttemptValue(); got != 0 {
8+ t.Fatalf("initial attempt = %d, want 0", got)
9+ }
10+ if got := a.incrementAttempt(); got != 1 || a.AttemptValue() != 1 {
11+ t.Fatalf("attempt after increment = %d, value = %d; want 1", got, a.AttemptValue())
12+ }
13+
14+ a.appendTranscript("a\nbb")
15+ if got := a.outputBytes; got != 5 {
16+ t.Fatalf("output bytes after append = %d, want 5", got)
17+ }
18+ if got := a.outputLines; got != 2 {
19+ t.Fatalf("output lines after append = %d, want 2", got)
20+ }
21+ a.appendTranscript("ccc")
22+ if got, want := a.Transcript(), []string{"ccc"}; len(got) != 1 || got[0] != want[0] {
23+ t.Fatalf("transcript after byte eviction = %q, want %q", got, want)
24+ }
25+ if a.outputBytes != 4 || a.outputLines != 1 {
26+ t.Fatalf("accounting after byte eviction = (%d bytes, %d lines), want (4, 1)", a.outputBytes, a.outputLines)
27+ }
28+
29+ a.maxOutputBytes = 1
30+ a.appendTranscript("z")
31+ if got := a.Transcript(); len(got) != 0 {
32+ t.Fatalf("transcript after evicting every line = %q, want empty", got)
33+ }
34+ if a.outputBytes != 0 || a.outputLines != 0 {
35+ t.Fatalf("empty accounting = (%d bytes, %d lines), want (0, 0)", a.outputBytes, a.outputLines)
36+ }
37+}
38+
39+func TestAgentTranscriptLineCapAccounting(t *testing.T) {
40+ a := &Agent{maxOutputBytes: 100, maxOutputLines: 2}
41+ a.appendTranscript("one\ntwo\nthree")
42+
43+ if got, want := a.Transcript(), []string{"two", "three"}; len(got) != len(want) || got[0] != want[0] || got[1] != want[1] {
44+ t.Fatalf("transcript = %q, want %q", got, want)
45+ }
46+ if a.outputBytes != len("two")+1+len("three")+1 || a.outputLines != 2 {
47+ t.Fatalf("line-cap accounting = (%d bytes, %d lines), want (10, 2)", a.outputBytes, a.outputLines)
48+ }
49+}
M packages/agent/subagents/event.go +73 -42
@@ -157,7 +157,7 @@ func (l *EventLog) Append(ev Event) error {
157157 return errors.New("event log: closed")
158158 }
159159 if ev.Time.IsZero() {
160- ev.Time = time.Now()
160+ ev.Time = time.Now().UTC()
161161 }
162162 b, err := json.Marshal(ev)
163163 if err != nil {
@@ -209,22 +209,63 @@ func ReadEventLog(path string) ([]Event, error) {
209209 return readEvents(f)
210210 }
211211
212+type eventRing struct {
213+ events []Event
214+ next int
215+ count int
216+}
217+
218+func newEventRing() eventRing {
219+ return eventRing{events: make([]Event, maxEventLogEvents)}
220+}
221+
222+func (r *eventRing) append(ev Event) {
223+ r.events[r.next] = ev
224+ r.next = (r.next + 1) % len(r.events)
225+ if r.count < len(r.events) {
226+ r.count++
227+ }
228+}
229+
230+func (r *eventRing) last() (Event, bool) {
231+ if r.count == 0 {
232+ return Event{}, false
233+ }
234+ i := r.next - 1
235+ if i < 0 {
236+ i += len(r.events)
237+ }
238+ return r.events[i], true
239+}
240+
241+func (r *eventRing) isLikelyDoubleWrite(ev Event) bool {
242+ previous, ok := r.last()
243+ return ok && isLikelyDoubleWrite(ev, previous)
244+}
245+
246+func (r *eventRing) materialize() []Event {
247+ out := make([]Event, r.count)
248+ if r.count == 0 {
249+ return out
250+ }
251+ start := r.next - r.count
252+ if start < 0 {
253+ start += len(r.events)
254+ }
255+ n := copy(out, r.events[start:])
256+ copy(out[n:], r.events[:r.count-n])
257+ return out
258+}
259+
212260 func readEvents(r io.Reader) ([]Event, error) {
213261 br := bufio.NewReader(r)
214- out := make([]Event, 0, 256)
262+ ring := newEventRing()
215263 for {
216264 line, err, truncated := readBoundedLine(br, maxEventLogLineBytes)
217265 if len(line) > 0 && !truncated {
218266 var ev Event
219- if jerr := json.Unmarshal(line, &ev); jerr == nil {
220- if !isLikelyDoubleWrite(ev, out) {
221- if len(out) >= maxEventLogEvents {
222- copy(out, out[1:])
223- out[len(out)-1] = ev
224- } else {
225- out = append(out, ev)
226- }
227- }
267+ if jerr := json.Unmarshal(line, &ev); jerr == nil && !ring.isLikelyDoubleWrite(ev) {
268+ ring.append(ev)
228269 }
229270 // Malformed and oversized lines are skipped silently; the
230271 // dashboard renders only well-formed events and the child is
@@ -232,9 +273,9 @@ func readEvents(r io.Reader) ([]Event, error) {
232273 }
233274 if err != nil {
234275 if errors.Is(err, io.EOF) {
235- return out, nil
276+ return ring.materialize(), nil
236277 }
237- return out, err
278+ return ring.materialize(), err
238279 }
239280 }
240281 }
@@ -245,7 +286,7 @@ func readEvents(r io.Reader) ([]Event, error) {
245286 // make the completed event invisible on the next poll.
246287 func readFollowerEvents(r io.Reader, offset int64) ([]Event, int64) {
247288 br := bufio.NewReader(r)
248- out := make([]Event, 0, 256)
289+ ring := newEventRing()
249290 committed := offset
250291 for {
251292 line, err, truncated, consumed, complete := readBoundedLineStats(br, maxEventLogLineBytes)
@@ -255,45 +296,35 @@ func readFollowerEvents(r io.Reader, offset int64) ([]Event, int64) {
255296 committed += consumed
256297 if len(line) > 0 && !truncated {
257298 var ev Event
258- if jerr := json.Unmarshal(line, &ev); jerr == nil && !isLikelyDoubleWrite(ev, out) {
259- if len(out) >= maxEventLogEvents {
260- copy(out, out[1:])
261- out[len(out)-1] = ev
262- } else {
263- out = append(out, ev)
264- }
299+ if jerr := json.Unmarshal(line, &ev); jerr == nil && !ring.isLikelyDoubleWrite(ev) {
300+ ring.append(ev)
265301 }
266302 }
267303 if err != nil {
268304 break
269305 }
270306 }
271- return out, committed
307+ return ring.materialize(), committed
272308 }
273309
274310 // isLikelyDoubleWrite reports whether ev is a back-to-back duplicate
275-// of the most recent event in `tail` — same type and identical Data
276-// payload, with timestamps within a small window (the supervisor and
277-// the child's mirror used to BOTH write each event to disk, landing
278-// the same content twice in quick succession in events.jsonl). The
279-// historical behaviour was fixed at write time (the child's mirror
280-// is now dormant unless the supervisor is gone), but on-disk files
281-// from before the fix are still polluted, so we dedupe defensively
282-// at read time too.
311+// of previous — same type and identical Data payload, with timestamps
312+// within a small window (the supervisor and the child's mirror used to
313+// BOTH write each event to disk, landing the same content twice in quick
314+// succession in events.jsonl). The historical behaviour was fixed at write
315+// time (the child's mirror is now dormant unless the supervisor is gone),
316+// but on-disk files from before the fix are still polluted, so we dedupe
317+// defensively at read time too.
283318 //
284-// We deliberately bound by time (250ms) so two genuinely identical
285-// adjacent events that happen seconds apart — e.g. an agent that
286-// runs the same tool twice in a row — still both render.
287-func isLikelyDoubleWrite(ev Event, tail []Event) bool {
288- if len(tail) == 0 {
289- return false
290- }
291- prev := tail[len(tail)-1]
292- if prev.Type != ev.Type {
319+// We deliberately bound by time (250ms) so two genuinely identical adjacent
320+// events that happen seconds apart — e.g. an agent that runs the same tool
321+// twice in a row — still both render.
322+func isLikelyDoubleWrite(ev, previous Event) bool {
323+ if previous.Type != ev.Type {
293324 return false
294325 }
295- if !ev.Time.IsZero() && !prev.Time.IsZero() {
296- dt := ev.Time.Sub(prev.Time)
326+ if !ev.Time.IsZero() && !previous.Time.IsZero() {
327+ dt := ev.Time.Sub(previous.Time)
297328 if dt < 0 {
298329 dt = -dt
299330 }
@@ -301,7 +332,7 @@ func isLikelyDoubleWrite(ev Event, tail []Event) bool {
301332 return false
302333 }
303334 }
304- return sameEventData(prev.Data, ev.Data)
335+ return sameEventData(previous.Data, ev.Data)
305336 }
306337
307338 // sameEventData deep-compares two event payloads. Cheap because the
@@ -157,7 +157,7 @@ func (l *EventLog) Append(ev Event) error {
157 return errors.New("event log: closed")157 return errors.New("event log: closed")
158 }158 }
159 if ev.Time.IsZero() {159 if ev.Time.IsZero() {
160- ev.Time = time.Now()160+ ev.Time = time.Now().UTC()
161 }161 }
162 b, err := json.Marshal(ev)162 b, err := json.Marshal(ev)
163 if err != nil {163 if err != nil {
@@ -209,22 +209,63 @@ func ReadEventLog(path string) ([]Event, error) {
209 return readEvents(f)209 return readEvents(f)
210 }210 }
211 211
212+type eventRing struct {
213+ events []Event
214+ next int
215+ count int
216+}
217+
218+func newEventRing() eventRing {
219+ return eventRing{events: make([]Event, maxEventLogEvents)}
220+}
221+
222+func (r *eventRing) append(ev Event) {
223+ r.events[r.next] = ev
224+ r.next = (r.next + 1) % len(r.events)
225+ if r.count < len(r.events) {
226+ r.count++
227+ }
228+}
229+
230+func (r *eventRing) last() (Event, bool) {
231+ if r.count == 0 {
232+ return Event{}, false
233+ }
234+ i := r.next - 1
235+ if i < 0 {
236+ i += len(r.events)
237+ }
238+ return r.events[i], true
239+}
240+
241+func (r *eventRing) isLikelyDoubleWrite(ev Event) bool {
242+ previous, ok := r.last()
243+ return ok && isLikelyDoubleWrite(ev, previous)
244+}
245+
246+func (r *eventRing) materialize() []Event {
247+ out := make([]Event, r.count)
248+ if r.count == 0 {
249+ return out
250+ }
251+ start := r.next - r.count
252+ if start < 0 {
253+ start += len(r.events)
254+ }
255+ n := copy(out, r.events[start:])
256+ copy(out[n:], r.events[:r.count-n])
257+ return out
258+}
259+
212 func readEvents(r io.Reader) ([]Event, error) {260 func readEvents(r io.Reader) ([]Event, error) {
213 br := bufio.NewReader(r)261 br := bufio.NewReader(r)
214- out := make([]Event, 0, 256)262+ ring := newEventRing()
215 for {263 for {
216 line, err, truncated := readBoundedLine(br, maxEventLogLineBytes)264 line, err, truncated := readBoundedLine(br, maxEventLogLineBytes)
217 if len(line) > 0 && !truncated {265 if len(line) > 0 && !truncated {
218 var ev Event266 var ev Event
219- if jerr := json.Unmarshal(line, &ev); jerr == nil {267+ if jerr := json.Unmarshal(line, &ev); jerr == nil && !ring.isLikelyDoubleWrite(ev) {
220- if !isLikelyDoubleWrite(ev, out) {268+ ring.append(ev)
221- if len(out) >= maxEventLogEvents {
222- copy(out, out[1:])
223- out[len(out)-1] = ev
224- } else {
225- out = append(out, ev)
226- }
227- }
228 }269 }
229 // Malformed and oversized lines are skipped silently; the270 // Malformed and oversized lines are skipped silently; the
230 // dashboard renders only well-formed events and the child is271 // dashboard renders only well-formed events and the child is
@@ -232,9 +273,9 @@ func readEvents(r io.Reader) ([]Event, error) {
232 }273 }
233 if err != nil {274 if err != nil {
234 if errors.Is(err, io.EOF) {275 if errors.Is(err, io.EOF) {
235- return out, nil276+ return ring.materialize(), nil
236 }277 }
237- return out, err278+ return ring.materialize(), err
238 }279 }
239 }280 }
240 }281 }
@@ -245,7 +286,7 @@ func readEvents(r io.Reader) ([]Event, error) {
245 // make the completed event invisible on the next poll.286 // make the completed event invisible on the next poll.
246 func readFollowerEvents(r io.Reader, offset int64) ([]Event, int64) {287 func readFollowerEvents(r io.Reader, offset int64) ([]Event, int64) {
247 br := bufio.NewReader(r)288 br := bufio.NewReader(r)
248- out := make([]Event, 0, 256)289+ ring := newEventRing()
249 committed := offset290 committed := offset
250 for {291 for {
251 line, err, truncated, consumed, complete := readBoundedLineStats(br, maxEventLogLineBytes)292 line, err, truncated, consumed, complete := readBoundedLineStats(br, maxEventLogLineBytes)
@@ -255,45 +296,35 @@ func readFollowerEvents(r io.Reader, offset int64) ([]Event, int64) {
255 committed += consumed296 committed += consumed
256 if len(line) > 0 && !truncated {297 if len(line) > 0 && !truncated {
257 var ev Event298 var ev Event
258- if jerr := json.Unmarshal(line, &ev); jerr == nil && !isLikelyDoubleWrite(ev, out) {299+ if jerr := json.Unmarshal(line, &ev); jerr == nil && !ring.isLikelyDoubleWrite(ev) {
259- if len(out) >= maxEventLogEvents {300+ ring.append(ev)
260- copy(out, out[1:])
261- out[len(out)-1] = ev
262- } else {
263- out = append(out, ev)
264- }
265 }301 }
266 }302 }
267 if err != nil {303 if err != nil {
268 break304 break
269 }305 }
270 }306 }
271- return out, committed307+ return ring.materialize(), committed
272 }308 }
273 309
274 // isLikelyDoubleWrite reports whether ev is a back-to-back duplicate310 // isLikelyDoubleWrite reports whether ev is a back-to-back duplicate
275-// of the most recent event in `tail` — same type and identical Data311+// of previous — same type and identical Data payload, with timestamps
276-// payload, with timestamps within a small window (the supervisor and312+// within a small window (the supervisor and the child's mirror used to
277-// the child's mirror used to BOTH write each event to disk, landing313+// BOTH write each event to disk, landing the same content twice in quick
278-// the same content twice in quick succession in events.jsonl). The314+// succession in events.jsonl). The historical behaviour was fixed at write
279-// historical behaviour was fixed at write time (the child's mirror315+// time (the child's mirror is now dormant unless the supervisor is gone),
280-// is now dormant unless the supervisor is gone), but on-disk files316+// but on-disk files from before the fix are still polluted, so we dedupe
281-// from before the fix are still polluted, so we dedupe defensively317+// defensively at read time too.
282-// at read time too.
283 //318 //
284-// We deliberately bound by time (250ms) so two genuinely identical319+// We deliberately bound by time (250ms) so two genuinely identical adjacent
285-// adjacent events that happen seconds apart — e.g. an agent that320+// events that happen seconds apart — e.g. an agent that runs the same tool
286-// runs the same tool twice in a row — still both render.321+// twice in a row — still both render.
287-func isLikelyDoubleWrite(ev Event, tail []Event) bool {322+func isLikelyDoubleWrite(ev, previous Event) bool {
288- if len(tail) == 0 {323+ if previous.Type != ev.Type {
289- return false
290- }
291- prev := tail[len(tail)-1]
292- if prev.Type != ev.Type {
293 return false324 return false
294 }325 }
295- if !ev.Time.IsZero() && !prev.Time.IsZero() {326+ if !ev.Time.IsZero() && !previous.Time.IsZero() {
296- dt := ev.Time.Sub(prev.Time)327+ dt := ev.Time.Sub(previous.Time)
297 if dt < 0 {328 if dt < 0 {
298 dt = -dt329 dt = -dt
299 }330 }
@@ -301,7 +332,7 @@ func isLikelyDoubleWrite(ev Event, tail []Event) bool {
301 return false332 return false
302 }333 }
303 }334 }
304- return sameEventData(prev.Data, ev.Data)335+ return sameEventData(previous.Data, ev.Data)
305 }336 }
306 337
307 // sameEventData deep-compares two event payloads. Cheap because the338 // sameEventData deep-compares two event payloads. Cheap because the
M packages/agent/subagents/event_test.go +69 -0
@@ -48,6 +48,31 @@ func TestEventLogAppendAndRead(t *testing.T) {
4848 }
4949 }
5050
51+func TestEventLogAppendUsesUTCForZeroTimestamp(t *testing.T) {
52+ path := filepath.Join(t.TempDir(), "events.jsonl")
53+ log, err := OpenEventLog(path)
54+ if err != nil {
55+ t.Fatal(err)
56+ }
57+ if err := log.Append(Event{Type: "zero_time", Version: ProtocolVersion, Data: map[string]any{}}); err != nil {
58+ t.Fatal(err)
59+ }
60+ if err := log.Close(); err != nil {
61+ t.Fatal(err)
62+ }
63+
64+ got, err := ReadEventLog(path)
65+ if err != nil {
66+ t.Fatal(err)
67+ }
68+ if len(got) != 1 {
69+ t.Fatalf("got %d events, want 1", len(got))
70+ }
71+ if got[0].Time.Location() != time.UTC {
72+ t.Fatalf("zero timestamp location = %v, want UTC", got[0].Time.Location())
73+ }
74+}
75+
5176 // TestReadEventLogIgnoresGarbage documents that one malformed line
5277 // doesn't break the whole replay. The runner's responsibility is to
5378 // only write well-formed events, but a partial crash could leave
@@ -91,6 +116,50 @@ func TestReadEventLogSkipsOversizedLines(t *testing.T) {
91116 }
92117 }
93118
119+func TestEventReadersRetainLatestEventsInOrder(t *testing.T) {
120+ total := maxEventLogEvents + 2
121+ var input bytes.Buffer
122+ for i := 0; i < total; i++ {
123+ ev := Event{
124+ Time: time.Unix(int64(i), 0).UTC(),
125+ Type: "indexed",
126+ Version: ProtocolVersion,
127+ Data: map[string]any{"index": i},
128+ }
129+ line, err := json.Marshal(ev)
130+ if err != nil {
131+ t.Fatal(err)
132+ }
133+ input.Write(line)
134+ input.WriteByte('\n')
135+ }
136+
137+ check := func(name string, got []Event) {
138+ t.Helper()
139+ if len(got) != maxEventLogEvents {
140+ t.Fatalf("%s retained %d events, want %d", name, len(got), maxEventLogEvents)
141+ }
142+ for i, ev := range got {
143+ index, ok := ev.Data["index"].(float64)
144+ if !ok || int(index) != i+2 {
145+ t.Fatalf("%s event %d index = %v, want %d", name, i, ev.Data["index"], i+2)
146+ }
147+ }
148+ }
149+
150+ got, err := readEvents(bytes.NewReader(input.Bytes()))
151+ if err != nil {
152+ t.Fatal(err)
153+ }
154+ check("readEvents", got)
155+
156+ got, offset := readFollowerEvents(bytes.NewReader(input.Bytes()), 0)
157+ check("readFollowerEvents", got)
158+ if offset != int64(input.Len()) {
159+ t.Fatalf("follower offset = %d, want %d", offset, input.Len())
160+ }
161+}
162+
94163 func TestReadEventLogIgnoresGarbage(t *testing.T) {
95164 path := filepath.Join(t.TempDir(), "events.jsonl")
96165 log, err := OpenEventLog(path)
@@ -48,6 +48,31 @@ func TestEventLogAppendAndRead(t *testing.T) {
48 }48 }
49 }49 }
50 50
51+func TestEventLogAppendUsesUTCForZeroTimestamp(t *testing.T) {
52+ path := filepath.Join(t.TempDir(), "events.jsonl")
53+ log, err := OpenEventLog(path)
54+ if err != nil {
55+ t.Fatal(err)
56+ }
57+ if err := log.Append(Event{Type: "zero_time", Version: ProtocolVersion, Data: map[string]any{}}); err != nil {
58+ t.Fatal(err)
59+ }
60+ if err := log.Close(); err != nil {
61+ t.Fatal(err)
62+ }
63+
64+ got, err := ReadEventLog(path)
65+ if err != nil {
66+ t.Fatal(err)
67+ }
68+ if len(got) != 1 {
69+ t.Fatalf("got %d events, want 1", len(got))
70+ }
71+ if got[0].Time.Location() != time.UTC {
72+ t.Fatalf("zero timestamp location = %v, want UTC", got[0].Time.Location())
73+ }
74+}
75+
51 // TestReadEventLogIgnoresGarbage documents that one malformed line76 // TestReadEventLogIgnoresGarbage documents that one malformed line
52 // doesn't break the whole replay. The runner's responsibility is to77 // doesn't break the whole replay. The runner's responsibility is to
53 // only write well-formed events, but a partial crash could leave78 // only write well-formed events, but a partial crash could leave
@@ -91,6 +116,50 @@ func TestReadEventLogSkipsOversizedLines(t *testing.T) {
91 }116 }
92 }117 }
93 118
119+func TestEventReadersRetainLatestEventsInOrder(t *testing.T) {
120+ total := maxEventLogEvents + 2
121+ var input bytes.Buffer
122+ for i := 0; i < total; i++ {
123+ ev := Event{
124+ Time: time.Unix(int64(i), 0).UTC(),
125+ Type: "indexed",
126+ Version: ProtocolVersion,
127+ Data: map[string]any{"index": i},
128+ }
129+ line, err := json.Marshal(ev)
130+ if err != nil {
131+ t.Fatal(err)
132+ }
133+ input.Write(line)
134+ input.WriteByte('\n')
135+ }
136+
137+ check := func(name string, got []Event) {
138+ t.Helper()
139+ if len(got) != maxEventLogEvents {
140+ t.Fatalf("%s retained %d events, want %d", name, len(got), maxEventLogEvents)
141+ }
142+ for i, ev := range got {
143+ index, ok := ev.Data["index"].(float64)
144+ if !ok || int(index) != i+2 {
145+ t.Fatalf("%s event %d index = %v, want %d", name, i, ev.Data["index"], i+2)
146+ }
147+ }
148+ }
149+
150+ got, err := readEvents(bytes.NewReader(input.Bytes()))
151+ if err != nil {
152+ t.Fatal(err)
153+ }
154+ check("readEvents", got)
155+
156+ got, offset := readFollowerEvents(bytes.NewReader(input.Bytes()), 0)
157+ check("readFollowerEvents", got)
158+ if offset != int64(input.Len()) {
159+ t.Fatalf("follower offset = %d, want %d", offset, input.Len())
160+ }
161+}
162+
94 func TestReadEventLogIgnoresGarbage(t *testing.T) {163 func TestReadEventLogIgnoresGarbage(t *testing.T) {
95 path := filepath.Join(t.TempDir(), "events.jsonl")164 path := filepath.Join(t.TempDir(), "events.jsonl")
96 log, err := OpenEventLog(path)165 log, err := OpenEventLog(path)
M packages/agent/subagents/inbox.go +14 -3
@@ -178,6 +178,7 @@ type Listener struct {
178178 out chan string
179179 done chan struct{}
180180 wg sync.WaitGroup
181+ closed bool
181182 closeOnce sync.Once
182183 }
183184
@@ -186,9 +187,13 @@ type Listener struct {
186187 // A live endpoint is preserved and rejected; only a refused probe
187188 // is treated as a stale socket eligible for removal.
188189 func Listen(path string) (*Listener, error) {
189- if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil {
190+ dir := filepath.Dir(path)
191+ if err := os.MkdirAll(dir, 0o700); err != nil {
190192 return nil, fmt.Errorf("inbox dir: %w", err)
191193 }
194+ if err := os.Chmod(dir, 0o700); err != nil {
195+ return nil, fmt.Errorf("inbox dir permissions: %w", err)
196+ }
192197 // Probe an existing endpoint before removing it. Unlinking an active
193198 // Unix socket does not stop its listener, so blindly removing the path
194199 // could let Resume start a second worker on the same session and event log.
@@ -227,8 +232,9 @@ func (l *Listener) Lines() <-chan string { return l.out }
227232 // the socket file, and closes Lines. Idempotent.
228233 func (l *Listener) Close() error {
229234 l.closeOnce.Do(func() {
230- close(l.done)
231235 l.mu.Lock()
236+ l.closed = true
237+ close(l.done)
232238 for conn := range l.conns {
233239 _ = conn.Close()
234240 }
@@ -257,9 +263,14 @@ func (l *Listener) acceptLoop() {
257263 }
258264 }
259265 l.mu.Lock()
266+ if l.closed {
267+ l.mu.Unlock()
268+ _ = c.Close()
269+ return
270+ }
260271 l.conns[c] = struct{}{}
261- l.mu.Unlock()
262272 l.wg.Add(1)
273+ l.mu.Unlock()
263274 go func() {
264275 defer l.wg.Done()
265276 l.readLoop(c)
@@ -178,6 +178,7 @@ type Listener struct {
178 out chan string178 out chan string
179 done chan struct{}179 done chan struct{}
180 wg sync.WaitGroup180 wg sync.WaitGroup
181+ closed bool
181 closeOnce sync.Once182 closeOnce sync.Once
182 }183 }
183 184
@@ -186,9 +187,13 @@ type Listener struct {
186 // A live endpoint is preserved and rejected; only a refused probe187 // A live endpoint is preserved and rejected; only a refused probe
187 // is treated as a stale socket eligible for removal.188 // is treated as a stale socket eligible for removal.
188 func Listen(path string) (*Listener, error) {189 func Listen(path string) (*Listener, error) {
189- if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil {190+ dir := filepath.Dir(path)
191+ if err := os.MkdirAll(dir, 0o700); err != nil {
190 return nil, fmt.Errorf("inbox dir: %w", err)192 return nil, fmt.Errorf("inbox dir: %w", err)
191 }193 }
194+ if err := os.Chmod(dir, 0o700); err != nil {
195+ return nil, fmt.Errorf("inbox dir permissions: %w", err)
196+ }
192 // Probe an existing endpoint before removing it. Unlinking an active197 // Probe an existing endpoint before removing it. Unlinking an active
193 // Unix socket does not stop its listener, so blindly removing the path198 // Unix socket does not stop its listener, so blindly removing the path
194 // could let Resume start a second worker on the same session and event log.199 // could let Resume start a second worker on the same session and event log.
@@ -227,8 +232,9 @@ func (l *Listener) Lines() <-chan string { return l.out }
227 // the socket file, and closes Lines. Idempotent.232 // the socket file, and closes Lines. Idempotent.
228 func (l *Listener) Close() error {233 func (l *Listener) Close() error {
229 l.closeOnce.Do(func() {234 l.closeOnce.Do(func() {
230- close(l.done)
231 l.mu.Lock()235 l.mu.Lock()
236+ l.closed = true
237+ close(l.done)
232 for conn := range l.conns {238 for conn := range l.conns {
233 _ = conn.Close()239 _ = conn.Close()
234 }240 }
@@ -257,9 +263,14 @@ func (l *Listener) acceptLoop() {
257 }263 }
258 }264 }
259 l.mu.Lock()265 l.mu.Lock()
266+ if l.closed {
267+ l.mu.Unlock()
268+ _ = c.Close()
269+ return
270+ }
260 l.conns[c] = struct{}{}271 l.conns[c] = struct{}{}
261- l.mu.Unlock()
262 l.wg.Add(1)272 l.wg.Add(1)
273+ l.mu.Unlock()
263 go func() {274 go func() {
264 defer l.wg.Done()275 defer l.wg.Done()
265 l.readLoop(c)276 l.readLoop(c)
M packages/agent/subagents/inbox_test.go +17 -3
@@ -41,7 +41,7 @@ func TestInboxRoundTrip(t *testing.T) {
4141 }
4242 defer ln.Close()
4343
44- got := collectFor(t, ln.Lines(), 3, time.Second)
44+ got := collectFor(t, ln.Lines(), 4, time.Second)
4545 inbox := NewInbox(path)
4646 defer inbox.Close()
4747
@@ -51,16 +51,30 @@ func TestInboxRoundTrip(t *testing.T) {
5151 t.Fatalf("sendBytes(%q): %v", msg, err)
5252 }
5353 }
54+ if err := inbox.SendCommand(NewCommand(CommandTurnStart, "agent-1", "turn-1", TurnStartPayload{Prompt: "round trip"})); err != nil {
55+ t.Fatalf("SendCommand: %v", err)
56+ }
5457
5558 gotLines := <-got
56- if len(gotLines) != len(want) {
57- t.Fatalf("got %d lines, want %d: %v", len(gotLines), len(want), gotLines)
59+ if len(gotLines) != len(want)+1 {
60+ t.Fatalf("got %d lines, want %d: %v", len(gotLines), len(want)+1, gotLines)
5861 }
5962 for i, w := range want {
6063 if gotLines[i] != w {
6164 t.Errorf("line %d = %q; want %q", i, gotLines[i], w)
6265 }
6366 }
67+ parsed, err := ParseCommand(gotLines[len(want)])
68+ if err != nil {
69+ t.Fatalf("ParseCommand: %v", err)
70+ }
71+ var payload TurnStartPayload
72+ if err := parsed.DecodePayload(&payload); err != nil {
73+ t.Fatalf("DecodePayload: %v", err)
74+ }
75+ if payload.Prompt != "round trip" {
76+ t.Fatalf("decoded prompt = %q, want %q", payload.Prompt, "round trip")
77+ }
6478 }
6579
6680 // TestInboxNotReady regression-tests the explicit "child hasn't
@@ -41,7 +41,7 @@ func TestInboxRoundTrip(t *testing.T) {
41 }41 }
42 defer ln.Close()42 defer ln.Close()
43 43
44- got := collectFor(t, ln.Lines(), 3, time.Second)44+ got := collectFor(t, ln.Lines(), 4, time.Second)
45 inbox := NewInbox(path)45 inbox := NewInbox(path)
46 defer inbox.Close()46 defer inbox.Close()
47 47
@@ -51,16 +51,30 @@ func TestInboxRoundTrip(t *testing.T) {
51 t.Fatalf("sendBytes(%q): %v", msg, err)51 t.Fatalf("sendBytes(%q): %v", msg, err)
52 }52 }
53 }53 }
54+ if err := inbox.SendCommand(NewCommand(CommandTurnStart, "agent-1", "turn-1", TurnStartPayload{Prompt: "round trip"})); err != nil {
55+ t.Fatalf("SendCommand: %v", err)
56+ }
54 57
55 gotLines := <-got58 gotLines := <-got
56- if len(gotLines) != len(want) {59+ if len(gotLines) != len(want)+1 {
57- t.Fatalf("got %d lines, want %d: %v", len(gotLines), len(want), gotLines)60+ t.Fatalf("got %d lines, want %d: %v", len(gotLines), len(want)+1, gotLines)
58 }61 }
59 for i, w := range want {62 for i, w := range want {
60 if gotLines[i] != w {63 if gotLines[i] != w {
61 t.Errorf("line %d = %q; want %q", i, gotLines[i], w)64 t.Errorf("line %d = %q; want %q", i, gotLines[i], w)
62 }65 }
63 }66 }
67+ parsed, err := ParseCommand(gotLines[len(want)])
68+ if err != nil {
69+ t.Fatalf("ParseCommand: %v", err)
70+ }
71+ var payload TurnStartPayload
72+ if err := parsed.DecodePayload(&payload); err != nil {
73+ t.Fatalf("DecodePayload: %v", err)
74+ }
75+ if payload.Prompt != "round trip" {
76+ t.Fatalf("decoded prompt = %q, want %q", payload.Prompt, "round trip")
77+ }
64 }78 }
65 79
66 // TestInboxNotReady regression-tests the explicit "child hasn't80 // TestInboxNotReady regression-tests the explicit "child hasn't
M packages/agent/subagents/lease_windows.go +4 -2
@@ -14,7 +14,8 @@ func acquireAgentLease(stateDir string) (*agentLease, error) {
1414 return nil, err
1515 }
1616 flags := uint32(windows.LOCKFILE_EXCLUSIVE_LOCK | windows.LOCKFILE_FAIL_IMMEDIATELY)
17- if err := windows.LockFileEx(windows.Handle(file.Fd()), flags, 0, 1, 0, nil); err != nil {
17+ var overlapped windows.Overlapped
18+ if err := windows.LockFileEx(windows.Handle(file.Fd()), flags, 0, 1, 0, &overlapped); err != nil {
1819 _ = file.Close()
1920 if errors.Is(err, windows.ERROR_LOCK_VIOLATION) {
2021 return nil, ErrAgentLeaseHeld
@@ -28,7 +29,8 @@ func (l *agentLease) Close() error {
2829 if l == nil || l.file == nil {
2930 return nil
3031 }
31- unlockErr := windows.UnlockFileEx(windows.Handle(l.file.Fd()), 0, 1, 0, nil)
32+ var overlapped windows.Overlapped
33+ unlockErr := windows.UnlockFileEx(windows.Handle(l.file.Fd()), 0, 1, 0, &overlapped)
3234 closeErr := l.file.Close()
3335 l.file = nil
3436 if unlockErr != nil {
@@ -14,7 +14,8 @@ func acquireAgentLease(stateDir string) (*agentLease, error) {
14 return nil, err14 return nil, err
15 }15 }
16 flags := uint32(windows.LOCKFILE_EXCLUSIVE_LOCK | windows.LOCKFILE_FAIL_IMMEDIATELY)16 flags := uint32(windows.LOCKFILE_EXCLUSIVE_LOCK | windows.LOCKFILE_FAIL_IMMEDIATELY)
17- if err := windows.LockFileEx(windows.Handle(file.Fd()), flags, 0, 1, 0, nil); err != nil {17+ var overlapped windows.Overlapped
18+ if err := windows.LockFileEx(windows.Handle(file.Fd()), flags, 0, 1, 0, &overlapped); err != nil {
18 _ = file.Close()19 _ = file.Close()
19 if errors.Is(err, windows.ERROR_LOCK_VIOLATION) {20 if errors.Is(err, windows.ERROR_LOCK_VIOLATION) {
20 return nil, ErrAgentLeaseHeld21 return nil, ErrAgentLeaseHeld
@@ -28,7 +29,8 @@ func (l *agentLease) Close() error {
28 if l == nil || l.file == nil {29 if l == nil || l.file == nil {
29 return nil30 return nil
30 }31 }
31- unlockErr := windows.UnlockFileEx(windows.Handle(l.file.Fd()), 0, 1, 0, nil)32+ var overlapped windows.Overlapped
33+ unlockErr := windows.UnlockFileEx(windows.Handle(l.file.Fd()), 0, 1, 0, &overlapped)
32 closeErr := l.file.Close()34 closeErr := l.file.Close()
33 l.file = nil35 l.file = nil
34 if unlockErr != nil {36 if unlockErr != nil {
M packages/agent/subagents/model.go +4 -3
@@ -46,9 +46,10 @@ const (
4646 CaptureDiff CaptureMode = "diff"
4747 )
4848
49-// SubagentPolicy contains manager-owned safety and resource limits. Zero
50-// values mean "use the manager default" for limits that have a safe default;
51-// zero remains unlimited for optional output caps and timeouts.
49+// SubagentPolicy contains manager-owned safety and resource limits. normalize
50+// replaces zero MaxOutputBytes, MaxOutputLines, QueueTimeout, and
51+// DefaultTimeout values with positive manager defaults. Other zero values retain
52+// their documented semantics.
5253 type SubagentPolicy struct {
5354 MaxConcurrent int
5455 MaxConcurrentPerParent int
@@ -46,9 +46,10 @@ const (
46 CaptureDiff CaptureMode = "diff"46 CaptureDiff CaptureMode = "diff"
47 )47 )
48 48
49-// SubagentPolicy contains manager-owned safety and resource limits. Zero49+// SubagentPolicy contains manager-owned safety and resource limits. normalize
50-// values mean "use the manager default" for limits that have a safe default;50+// replaces zero MaxOutputBytes, MaxOutputLines, QueueTimeout, and
51-// zero remains unlimited for optional output caps and timeouts.51+// DefaultTimeout values with positive manager defaults. Other zero values retain
52+// their documented semantics.
52 type SubagentPolicy struct {53 type SubagentPolicy struct {
53 MaxConcurrent int54 MaxConcurrent int
54 MaxConcurrentPerParent int55 MaxConcurrentPerParent int
M packages/agent/subagents/persist.go +16 -16
@@ -180,22 +180,6 @@ func readAgentMeta(stateDir string) (agentMeta, error) {
180180 return m, nil
181181 }
182182
183-// Reload scans <root>/agents/*/meta.json and re-registers every
184-// previously-spawned agent as a StatusDetached entry. Agents already
185-// present in memory are left alone (Reload is idempotent and safe to
186-// call after Spawn, though in practice the cli invokes it exactly
187-// once just after New()).
188-//
189-// The reloaded agents have no live Runner; the user can:
190-// - view their transcript (the dashboard reads from EventLogPath),
191-// - resume them via Supervisor.Resume (starts a fresh subprocess on the
192-// same worktree / session / inbox path),
193-// - remove them (worktree + meta + events log gone).
194-//
195-// Reload returns the number of agents loaded plus any per-directory
196-// error encountered. Malformed entries are skipped rather than
197-// failing the whole reload — one bad meta.json shouldn't hide the
198-// rest of the subagents.
199183 func safeAgentID(id string) bool {
200184 return id != "" && id != "." && id != ".." && filepath.Base(id) == id && !strings.ContainsAny(id, `/\\`)
201185 }
@@ -258,6 +242,22 @@ func firstNonEmpty(values ...string) string {
258242 return ""
259243 }
260244
245+// Reload scans <root>/agents/*/meta.json and re-registers every
246+// previously-spawned agent as a StatusDetached entry. Agents already
247+// present in memory are left alone (Reload is idempotent and safe to
248+// call after Spawn, though in practice the cli invokes it exactly
249+// once just after New()).
250+//
251+// The reloaded agents have no live Runner; the user can:
252+// - view their transcript (the dashboard reads from EventLogPath),
253+// - resume them via Supervisor.Resume (starts a fresh subprocess on the
254+// same worktree / session / inbox path),
255+// - remove them (worktree + meta + events log gone).
256+//
257+// Reload returns the number of agents loaded plus any per-directory
258+// error encountered. Malformed entries are skipped rather than
259+// failing the whole reload — one bad meta.json shouldn't hide the
260+// rest of the subagents.
261261 func (f *Supervisor) Reload() (loaded int, errs []error) {
262262 root := filepath.Clean(f.cfg.Root)
263263 if root == "." {
@@ -180,22 +180,6 @@ func readAgentMeta(stateDir string) (agentMeta, error) {
180 return m, nil180 return m, nil
181 }181 }
182 182
183-// Reload scans <root>/agents/*/meta.json and re-registers every
184-// previously-spawned agent as a StatusDetached entry. Agents already
185-// present in memory are left alone (Reload is idempotent and safe to
186-// call after Spawn, though in practice the cli invokes it exactly
187-// once just after New()).
188-//
189-// The reloaded agents have no live Runner; the user can:
190-// - view their transcript (the dashboard reads from EventLogPath),
191-// - resume them via Supervisor.Resume (starts a fresh subprocess on the
192-// same worktree / session / inbox path),
193-// - remove them (worktree + meta + events log gone).
194-//
195-// Reload returns the number of agents loaded plus any per-directory
196-// error encountered. Malformed entries are skipped rather than
197-// failing the whole reload — one bad meta.json shouldn't hide the
198-// rest of the subagents.
199 func safeAgentID(id string) bool {183 func safeAgentID(id string) bool {
200 return id != "" && id != "." && id != ".." && filepath.Base(id) == id && !strings.ContainsAny(id, `/\\`)184 return id != "" && id != "." && id != ".." && filepath.Base(id) == id && !strings.ContainsAny(id, `/\\`)
201 }185 }
@@ -258,6 +242,22 @@ func firstNonEmpty(values ...string) string {
258 return ""242 return ""
259 }243 }
260 244
245+// Reload scans <root>/agents/*/meta.json and re-registers every
246+// previously-spawned agent as a StatusDetached entry. Agents already
247+// present in memory are left alone (Reload is idempotent and safe to
248+// call after Spawn, though in practice the cli invokes it exactly
249+// once just after New()).
250+//
251+// The reloaded agents have no live Runner; the user can:
252+// - view their transcript (the dashboard reads from EventLogPath),
253+// - resume them via Supervisor.Resume (starts a fresh subprocess on the
254+// same worktree / session / inbox path),
255+// - remove them (worktree + meta + events log gone).
256+//
257+// Reload returns the number of agents loaded plus any per-directory
258+// error encountered. Malformed entries are skipped rather than
259+// failing the whole reload — one bad meta.json shouldn't hide the
260+// rest of the subagents.
261 func (f *Supervisor) Reload() (loaded int, errs []error) {261 func (f *Supervisor) Reload() (loaded int, errs []error) {
262 root := filepath.Clean(f.cfg.Root)262 root := filepath.Clean(f.cfg.Root)
263 if root == "." {263 if root == "." {
M packages/agent/subagents/persist_test.go +3 -2
@@ -152,12 +152,12 @@ func TestResumePreservesPerAgentTimeout(t *testing.T) {
152152 first.StopAll()
153153 a.Wait()
154154
155- var observed time.Duration
155+ observedCh := make(chan time.Duration, 1)
156156 second := New(Config{
157157 Root: root, RepoRoot: root,
158158 Policy: SubagentPolicy{DefaultTimeout: time.Hour},
159159 NewRunner: func(a *Agent) Runner {
160- observed = a.Timeout
160+ observedCh <- a.Timeout
161161 return RunnerFunc(func(ctx context.Context, _ Sink) error {
162162 <-ctx.Done()
163163 return ctx.Err()
@@ -171,6 +171,7 @@ func TestResumePreservesPerAgentTimeout(t *testing.T) {
171171 if err != nil {
172172 t.Fatal(err)
173173 }
174+ observed := <-observedCh
174175 if resumed.Timeout != wantTimeout || observed != wantTimeout {
175176 t.Fatalf("resumed timeout = %s, runner observed %s; want %s", resumed.Timeout, observed, wantTimeout)
176177 }
@@ -152,12 +152,12 @@ func TestResumePreservesPerAgentTimeout(t *testing.T) {
152 first.StopAll()152 first.StopAll()
153 a.Wait()153 a.Wait()
154 154
155- var observed time.Duration155+ observedCh := make(chan time.Duration, 1)
156 second := New(Config{156 second := New(Config{
157 Root: root, RepoRoot: root,157 Root: root, RepoRoot: root,
158 Policy: SubagentPolicy{DefaultTimeout: time.Hour},158 Policy: SubagentPolicy{DefaultTimeout: time.Hour},
159 NewRunner: func(a *Agent) Runner {159 NewRunner: func(a *Agent) Runner {
160- observed = a.Timeout160+ observedCh <- a.Timeout
161 return RunnerFunc(func(ctx context.Context, _ Sink) error {161 return RunnerFunc(func(ctx context.Context, _ Sink) error {
162 <-ctx.Done()162 <-ctx.Done()
163 return ctx.Err()163 return ctx.Err()
@@ -171,6 +171,7 @@ func TestResumePreservesPerAgentTimeout(t *testing.T) {
171 if err != nil {171 if err != nil {
172 t.Fatal(err)172 t.Fatal(err)
173 }173 }
174+ observed := <-observedCh
174 if resumed.Timeout != wantTimeout || observed != wantTimeout {175 if resumed.Timeout != wantTimeout || observed != wantTimeout {
175 t.Fatalf("resumed timeout = %s, runner observed %s; want %s", resumed.Timeout, observed, wantTimeout)176 t.Fatalf("resumed timeout = %s, runner observed %s; want %s", resumed.Timeout, observed, wantTimeout)
176 }177 }
M packages/agent/subagents/process_unix.go +22 -2
@@ -3,18 +3,38 @@
33 package subagents
44
55 import (
6+ "io"
67 "os/exec"
8+ "sync"
79 "syscall"
10+ "time"
811 )
912
1013 // configureWorkerProcess makes cancellation target the worker's process
1114 // group rather than only the worker itself. CommandContext calls cmd.Cancel
1215 // from its context watcher, so descendants that inherited the worker's group
1316 // are terminated before execRunner waits for their output pipes to close.
14-func configureWorkerProcess(cmd *exec.Cmd) {
17+func configureWorkerProcess(cmd *exec.Cmd, pipes ...io.Closer) {
1518 setProcessGroup(cmd)
19+ cmd.WaitDelay = workerProcessWaitDelay
20+ var closePipes sync.Once
1621 cmd.Cancel = func() error {
17- return killProcessGroup(cmd)
22+ err := killProcessGroup(cmd)
23+ closePipes.Do(func() { closeWorkerPipes(pipes) })
24+ return err
25+ }
26+}
27+
28+const workerProcessWaitDelay = 5 * time.Second
29+
30+// closeWorkerPipes closes the parent-side readers returned by StdoutPipe and
31+// StderrPipe. Closing these descriptors lets the custom reader goroutines exit
32+// if a descendant has outlived the worker process.
33+func closeWorkerPipes(pipes []io.Closer) {
34+ for _, pipe := range pipes {
35+ if pipe != nil {
36+ _ = pipe.Close()
37+ }
1838 }
1939 }
2040
@@ -3,18 +3,38 @@
3 package subagents3 package subagents
4 4
5 import (5 import (
6+ "io"
6 "os/exec"7 "os/exec"
8+ "sync"
7 "syscall"9 "syscall"
10+ "time"
8 )11 )
9 12
10 // configureWorkerProcess makes cancellation target the worker's process13 // configureWorkerProcess makes cancellation target the worker's process
11 // group rather than only the worker itself. CommandContext calls cmd.Cancel14 // group rather than only the worker itself. CommandContext calls cmd.Cancel
12 // from its context watcher, so descendants that inherited the worker's group15 // from its context watcher, so descendants that inherited the worker's group
13 // are terminated before execRunner waits for their output pipes to close.16 // are terminated before execRunner waits for their output pipes to close.
14-func configureWorkerProcess(cmd *exec.Cmd) {17+func configureWorkerProcess(cmd *exec.Cmd, pipes ...io.Closer) {
15 setProcessGroup(cmd)18 setProcessGroup(cmd)
19+ cmd.WaitDelay = workerProcessWaitDelay
20+ var closePipes sync.Once
16 cmd.Cancel = func() error {21 cmd.Cancel = func() error {
17- return killProcessGroup(cmd)22+ err := killProcessGroup(cmd)
23+ closePipes.Do(func() { closeWorkerPipes(pipes) })
24+ return err
25+ }
26+}
27+
28+const workerProcessWaitDelay = 5 * time.Second
29+
30+// closeWorkerPipes closes the parent-side readers returned by StdoutPipe and
31+// StderrPipe. Closing these descriptors lets the custom reader goroutines exit
32+// if a descendant has outlived the worker process.
33+func closeWorkerPipes(pipes []io.Closer) {
34+ for _, pipe := range pipes {
35+ if pipe != nil {
36+ _ = pipe.Close()
37+ }
18 }38 }
19 }39 }
20 40
M packages/agent/subagents/process_unix_test.go +29 -0
@@ -6,6 +6,7 @@ import (
66 "context"
77 "errors"
88 "fmt"
9+ "io"
910 "os"
1011 "os/exec"
1112 "strconv"
@@ -27,6 +28,9 @@ func TestWorkerCancellationKillsProcessGroup(t *testing.T) {
2728
2829 cmd := exec.CommandContext(ctx, shell, "-c", fmt.Sprintf("sleep 30 & echo $! > %q; wait", pidPath))
2930 configureWorkerProcess(cmd)
31+ if cmd.WaitDelay != workerProcessWaitDelay {
32+ t.Fatalf("WaitDelay = %s, want %s", cmd.WaitDelay, workerProcessWaitDelay)
33+ }
3034 if err := cmd.Start(); err != nil {
3135 t.Fatal(err)
3236 }
@@ -54,6 +58,31 @@ func TestWorkerCancellationKillsProcessGroup(t *testing.T) {
5458 }
5559 }
5660
61+func TestWorkerCancellationClosesOutputPipes(t *testing.T) {
62+ cmd := exec.Command("sh")
63+ stdout, err := cmd.StdoutPipe()
64+ if err != nil {
65+ t.Fatal(err)
66+ }
67+ stderr, err := cmd.StderrPipe()
68+ if err != nil {
69+ t.Fatal(err)
70+ }
71+ defer stdout.Close()
72+ defer stderr.Close()
73+
74+ configureWorkerProcess(cmd)
75+ if err := cmd.Cancel(); err != nil {
76+ t.Fatal(err)
77+ }
78+ for name, pipe := range map[string]io.Reader{"stdout": stdout, "stderr": stderr} {
79+ data := make([]byte, 1)
80+ if _, err := pipe.Read(data); err != io.EOF {
81+ t.Errorf("%s read error = %v, want EOF", name, err)
82+ }
83+ }
84+}
85+
5786 func waitForWorkerChildPID(t *testing.T, path string) int {
5887 t.Helper()
5988 deadline := time.Now().Add(2 * time.Second)
@@ -6,6 +6,7 @@ import (
6 "context"6 "context"
7 "errors"7 "errors"
8 "fmt"8 "fmt"
9+ "io"
9 "os"10 "os"
10 "os/exec"11 "os/exec"
11 "strconv"12 "strconv"
@@ -27,6 +28,9 @@ func TestWorkerCancellationKillsProcessGroup(t *testing.T) {
27 28
28 cmd := exec.CommandContext(ctx, shell, "-c", fmt.Sprintf("sleep 30 & echo $! > %q; wait", pidPath))29 cmd := exec.CommandContext(ctx, shell, "-c", fmt.Sprintf("sleep 30 & echo $! > %q; wait", pidPath))
29 configureWorkerProcess(cmd)30 configureWorkerProcess(cmd)
31+ if cmd.WaitDelay != workerProcessWaitDelay {
32+ t.Fatalf("WaitDelay = %s, want %s", cmd.WaitDelay, workerProcessWaitDelay)
33+ }
30 if err := cmd.Start(); err != nil {34 if err := cmd.Start(); err != nil {
31 t.Fatal(err)35 t.Fatal(err)
32 }36 }
@@ -54,6 +58,31 @@ func TestWorkerCancellationKillsProcessGroup(t *testing.T) {
54 }58 }
55 }59 }
56 60
61+func TestWorkerCancellationClosesOutputPipes(t *testing.T) {
62+ cmd := exec.Command("sh")
63+ stdout, err := cmd.StdoutPipe()
64+ if err != nil {
65+ t.Fatal(err)
66+ }
67+ stderr, err := cmd.StderrPipe()
68+ if err != nil {
69+ t.Fatal(err)
70+ }
71+ defer stdout.Close()
72+ defer stderr.Close()
73+
74+ configureWorkerProcess(cmd)
75+ if err := cmd.Cancel(); err != nil {
76+ t.Fatal(err)
77+ }
78+ for name, pipe := range map[string]io.Reader{"stdout": stdout, "stderr": stderr} {
79+ data := make([]byte, 1)
80+ if _, err := pipe.Read(data); err != io.EOF {
81+ t.Errorf("%s read error = %v, want EOF", name, err)
82+ }
83+ }
84+}
85+
57 func waitForWorkerChildPID(t *testing.T, path string) int {86 func waitForWorkerChildPID(t *testing.T, path string) int {
58 t.Helper()87 t.Helper()
59 deadline := time.Now().Add(2 * time.Second)88 deadline := time.Now().Add(2 * time.Second)
M packages/agent/subagents/process_windows.go +44 -3
@@ -3,21 +3,30 @@
33 package subagents
44
55 import (
6+ "errors"
7+ "fmt"
8+ "io"
9+ "os"
610 "os/exec"
711 "strconv"
12+ "strings"
813 "syscall"
14+ "time"
915 )
1016
1117 // configureWorkerProcess makes CommandContext cancellation terminate the
1218 // worker and its descendants on Windows. Windows does not provide a Unix-like
1319 // process-group kill operation, so killProcessGroup uses taskkill's tree mode.
14-func configureWorkerProcess(cmd *exec.Cmd) {
20+func configureWorkerProcess(cmd *exec.Cmd, _ ...io.Closer) {
1521 setProcessGroup(cmd)
22+ cmd.WaitDelay = workerProcessWaitDelay
1623 cmd.Cancel = func() error {
1724 return killProcessGroup(cmd)
1825 }
1926 }
2027
28+const workerProcessWaitDelay = 5 * time.Second
29+
2130 // setProcessGroup gives the worker its own Windows process group. The group
2231 // flag prevents it from being attached to the supervisor's console group;
2332 // taskkill /T below is still needed because Windows group creation alone does
@@ -35,12 +44,44 @@ func killProcessGroup(cmd *exec.Cmd) error {
3544 if cmd == nil || cmd.Process == nil {
3645 return nil
3746 }
38- return forceKillProcess(cmd.Process.Pid)
47+ err := forceKillProcess(cmd.Process.Pid)
48+ var taskkillErr *taskkillError
49+ if errors.As(err, &taskkillErr) && taskkillErr.processDone() {
50+ return os.ErrProcessDone
51+ }
52+ return err
3953 }
4054
4155 func forceKillProcess(pid int) error {
4256 if pid <= 0 {
4357 return nil
4458 }
45- return exec.Command("taskkill", "/PID", strconv.Itoa(pid), "/T", "/F").Run()
59+ output, err := exec.Command("taskkill", "/PID", strconv.Itoa(pid), "/T", "/F").CombinedOutput()
60+ if err == nil {
61+ return nil
62+ }
63+ return &taskkillError{pid: pid, err: err, diagnostics: string(output)}
64+}
65+
66+type taskkillError struct {
67+ pid int
68+ err error
69+ diagnostics string
70+}
71+
72+func (e *taskkillError) Error() string {
73+ if diagnostics := strings.TrimSpace(e.diagnostics); diagnostics != "" {
74+ return fmt.Sprintf("taskkill process tree for PID %d: %v: %s", e.pid, e.err, diagnostics)
75+ }
76+ return fmt.Sprintf("taskkill process tree for PID %d: %v", e.pid, e.err)
77+}
78+
79+func (e *taskkillError) Unwrap() error { return e.err }
80+
81+// taskkill returns exit code 128 when the requested process no longer exists.
82+// Other taskkill failures remain errors so callers can distinguish a
83+// cancellation race from a real process-control failure.
84+func (e *taskkillError) processDone() bool {
85+ var exitErr *exec.ExitError
86+ return errors.As(e.err, &exitErr) && exitErr.ExitCode() == 128
4687 }
@@ -3,21 +3,30 @@
3 package subagents3 package subagents
4 4
5 import (5 import (
6+ "errors"
7+ "fmt"
8+ "io"
9+ "os"
6 "os/exec"10 "os/exec"
7 "strconv"11 "strconv"
12+ "strings"
8 "syscall"13 "syscall"
14+ "time"
9 )15 )
10 16
11 // configureWorkerProcess makes CommandContext cancellation terminate the17 // configureWorkerProcess makes CommandContext cancellation terminate the
12 // worker and its descendants on Windows. Windows does not provide a Unix-like18 // worker and its descendants on Windows. Windows does not provide a Unix-like
13 // process-group kill operation, so killProcessGroup uses taskkill's tree mode.19 // process-group kill operation, so killProcessGroup uses taskkill's tree mode.
14-func configureWorkerProcess(cmd *exec.Cmd) {20+func configureWorkerProcess(cmd *exec.Cmd, _ ...io.Closer) {
15 setProcessGroup(cmd)21 setProcessGroup(cmd)
22+ cmd.WaitDelay = workerProcessWaitDelay
16 cmd.Cancel = func() error {23 cmd.Cancel = func() error {
17 return killProcessGroup(cmd)24 return killProcessGroup(cmd)
18 }25 }
19 }26 }
20 27
28+const workerProcessWaitDelay = 5 * time.Second
29+
21 // setProcessGroup gives the worker its own Windows process group. The group30 // setProcessGroup gives the worker its own Windows process group. The group
22 // flag prevents it from being attached to the supervisor's console group;31 // flag prevents it from being attached to the supervisor's console group;
23 // taskkill /T below is still needed because Windows group creation alone does32 // taskkill /T below is still needed because Windows group creation alone does
@@ -35,12 +44,44 @@ func killProcessGroup(cmd *exec.Cmd) error {
35 if cmd == nil || cmd.Process == nil {44 if cmd == nil || cmd.Process == nil {
36 return nil45 return nil
37 }46 }
38- return forceKillProcess(cmd.Process.Pid)47+ err := forceKillProcess(cmd.Process.Pid)
48+ var taskkillErr *taskkillError
49+ if errors.As(err, &taskkillErr) && taskkillErr.processDone() {
50+ return os.ErrProcessDone
51+ }
52+ return err
39 }53 }
40 54
41 func forceKillProcess(pid int) error {55 func forceKillProcess(pid int) error {
42 if pid <= 0 {56 if pid <= 0 {
43 return nil57 return nil
44 }58 }
45- return exec.Command("taskkill", "/PID", strconv.Itoa(pid), "/T", "/F").Run()59+ output, err := exec.Command("taskkill", "/PID", strconv.Itoa(pid), "/T", "/F").CombinedOutput()
60+ if err == nil {
61+ return nil
62+ }
63+ return &taskkillError{pid: pid, err: err, diagnostics: string(output)}
64+}
65+
66+type taskkillError struct {
67+ pid int
68+ err error
69+ diagnostics string
70+}
71+
72+func (e *taskkillError) Error() string {
73+ if diagnostics := strings.TrimSpace(e.diagnostics); diagnostics != "" {
74+ return fmt.Sprintf("taskkill process tree for PID %d: %v: %s", e.pid, e.err, diagnostics)
75+ }
76+ return fmt.Sprintf("taskkill process tree for PID %d: %v", e.pid, e.err)
77+}
78+
79+func (e *taskkillError) Unwrap() error { return e.err }
80+
81+// taskkill returns exit code 128 when the requested process no longer exists.
82+// Other taskkill failures remain errors so callers can distinguish a
83+// cancellation race from a real process-control failure.
84+func (e *taskkillError) processDone() bool {
85+ var exitErr *exec.ExitError
86+ return errors.As(e.err, &exitErr) && exitErr.ExitCode() == 128
46 }87 }
M packages/agent/subagents/protocol.go +26 -81
@@ -39,25 +39,6 @@ const (
3939 EventAgentExited = "agent.exited"
4040 )
4141
42-// Short aliases are kept alongside the direction-prefixed names because the
43-// latter read well at call sites while the former are convenient in switches.
44-const (
45- AgentPing = CommandAgentPing
46- TurnStart = CommandTurnStart
47- TurnCancel = CommandTurnCancel
48- AgentShutdown = CommandAgentShutdown
49-
50- AgentReady = EventAgentReady
51- AgentHeartbeat = EventAgentHeartbeat
52- TurnStarted = EventTurnStarted
53- TurnProgress = EventTurnProgress
54- ToolStarted = EventToolStarted
55- ToolFinished = EventToolFinished
56- MessageDelta = EventMessageDelta
57- AgentIdle = EventAgentIdle
58- AgentExited = EventAgentExited
59-)
60-
6142 var (
6243 // ErrEmptyProtocolLine is returned for a blank JSONL line or command.
6344 ErrEmptyProtocolLine = errors.New("subagent protocol: empty line")
@@ -107,11 +88,6 @@ func IsEventName(name string) bool {
10788 return ok
10889 }
10990
110-// CommandName and EventName make APIs which accept only a direction more
111-// self-documenting without preventing unknown names from being preserved.
112-type CommandName string
113-type EventName string
114-
11591 // MessageID returns a fresh opaque id suitable for correlating a command and
11692 // its result. UUIDs are used rather than a counter so ids remain unique when
11793 // several supervisors or workers are running at once.
@@ -152,15 +128,12 @@ type Envelope struct {
152128 // Unknown contains unrecognised top-level fields from a versioned
153129 // envelope. Payload itself retains unrecognised payload fields.
154130 Unknown map[string]json.RawMessage `json:"-"`
155-}
156131
157-// Command and ProtocolEvent are aliases rather than separate structs so a
158-// caller can pass either kind to the same JSONL transport without a wrapper.
159-// Direction checks are available through IsCommand and IsEvent.
160-type Command = Envelope
161-type ProtocolEvent = Envelope
162-type EventEnvelope = Envelope
163-type CommandEnvelope = Envelope
132+ // constructionErr records a payload-construction failure for the
133+ // compatibility-preserving value constructor. It is surfaced by Validate
134+ // and MarshalJSON rather than being turned into a valid null payload.
135+ constructionErr error
136+}
164137
165138 // NewEnvelope creates a current-protocol envelope and assigns a message id
166139 // and timestamp. payload may be any JSON-marshalable value. A nil payload is
@@ -174,18 +147,18 @@ func NewEnvelope(typ, agentID, turnID string, payload ...any) Envelope {
174147 if err == nil {
175148 return e
176149 }
177- // Keep the constructor useful in error-free call sites. MarshalJSON will
178- // still succeed with a valid JSON null; callers which need the original
179- // error should use NewEnvelopeWithPayload. Preserve the metadata even
180- // when only the caller's payload was not marshalable.
150+ // Preserve the value-returning API used by command/event call sites, but
151+ // retain the construction failure so validation and wire encoding fail
152+ // instead of silently sending a null payload.
181153 return Envelope{
182- Version: ProtocolVersion,
183- Type: typ,
184- MessageID: NewMessageID(),
185- AgentID: agentID,
186- TurnID: turnID,
187- Timestamp: time.Now().UTC(),
188- Payload: json.RawMessage("null"),
154+ Version: ProtocolVersion,
155+ Type: typ,
156+ MessageID: NewMessageID(),
157+ AgentID: agentID,
158+ TurnID: turnID,
159+ Timestamp: time.Now().UTC(),
160+ Payload: nil,
161+ constructionErr: fmt.Errorf("subagent protocol payload: %w", err),
189162 }
190163 }
191164
@@ -216,27 +189,11 @@ func NewCommand(name, agentID, turnID string, payload ...any) Envelope {
216189 }
217190
218191 // NewEventEnvelope creates a current-protocol event. The name is not checked
219-// against EventName so unknown future events remain forward-compatible.
192+// against the known event registry so unknown future events remain forward-compatible.
220193 func NewEventEnvelope(name, agentID, turnID string, payload ...any) Envelope {
221194 return NewEnvelope(name, agentID, turnID, payload...)
222195 }
223196
224-// NewProtocolEvent is a more explicit spelling of NewEventEnvelope. NewEvent
225-// is the canonical event-envelope constructor in this package.
226-func NewProtocolEvent(name, agentID, turnID string, payload ...any) Envelope {
227- return NewEventEnvelope(name, agentID, turnID, payload...)
228-}
229-
230-// NewCommandEnvelope is an explicit alias for NewCommand.
231-func NewCommandEnvelope(name, agentID, turnID string, payload ...any) Envelope {
232- return NewCommand(name, agentID, turnID, payload...)
233-}
234-
235-// NewEventMessage is an explicit alias for NewEventEnvelope.
236-func NewEventMessage(name, agentID, turnID string, payload ...any) Envelope {
237- return NewEventEnvelope(name, agentID, turnID, payload...)
238-}
239-
240197 func firstPayload(payload []any) any {
241198 if len(payload) == 0 || payload[0] == nil {
242199 return map[string]any{}
@@ -259,6 +216,9 @@ func marshalPayload(payload any) (json.RawMessage, error) {
259216 // intentionally separate from ParseEnvelope: parsing an unknown future event
260217 // should succeed, while a sender can opt into strict validation before write.
261218 func (e Envelope) Validate() error {
219+ if e.constructionErr != nil {
220+ return e.constructionErr
221+ }
262222 if e.Version != ProtocolVersion {
263223 return fmt.Errorf("%w: %d", ErrUnsupportedProtocolVersion, e.Version)
264224 }
@@ -357,19 +317,10 @@ func ParseEnvelope(data []byte) (Envelope, error) {
357317 return e, nil
358318 }
359319
360-// DecodeEnvelope is an alias for ParseEnvelope for callers using decoder
361-// terminology.
362-func DecodeEnvelope(data []byte) (Envelope, error) { return ParseEnvelope(data) }
363-
364320 // ParseJSONL parses one newline-delimited JSON message. It accepts a final
365321 // line without a newline, as is customary for a stream after a clean EOF.
366322 func ParseJSONL(line []byte) (Envelope, error) { return ParseEnvelope(line) }
367323
368-// ParseEnvelopeLine is the string form of ParseJSONL.
369-func ParseEnvelopeLine(line string) (Envelope, error) {
370- return ParseEnvelope([]byte(line))
371-}
372-
373324 // ParseEvent parses a current-protocol JSON event. A known command is
374325 // rejected; unknown types are accepted because an older peer cannot know
375326 // whether a future event name is present in its event registry.
@@ -384,12 +335,6 @@ func ParseEvent(data []byte) (Envelope, error) {
384335 return e, nil
385336 }
386337
387-// ParseProtocolEvent is an explicit alias for ParseEvent.
388-func ParseProtocolEvent(data []byte) (Envelope, error) { return ParseEvent(data) }
389-
390-// ParseEventLine parses one JSONL event line.
391-func ParseEventLine(line string) (Envelope, error) { return ParseEvent([]byte(line)) }
392-
393338 // ParseCommand parses one current-protocol JSON command.
394339 func ParseCommand(line string) (Envelope, error) {
395340 raw := strings.TrimSpace(line)
@@ -406,9 +351,6 @@ func ParseCommand(line string) (Envelope, error) {
406351 return e, nil
407352 }
408353
409-// ParseCommandLine is an alias for ParseCommand.
410-func ParseCommandLine(line string) (Envelope, error) { return ParseCommand(line) }
411-
412354 // MarshalEnvelope returns one JSON object without a trailing newline.
413355 func MarshalEnvelope(e Envelope) ([]byte, error) {
414356 return json.Marshal(e)
@@ -423,9 +365,6 @@ func MarshalJSONL(e Envelope) ([]byte, error) {
423365 return append(b, '\n'), nil
424366 }
425367
426-// EncodeEnvelope is the stream-friendly spelling of MarshalJSONL.
427-func EncodeEnvelope(e Envelope) ([]byte, error) { return MarshalJSONL(e) }
428-
429368 // WriteEnvelope writes one complete newline-terminated JSONL message.
430369 func WriteEnvelope(w io.Writer, e Envelope) error {
431370 if w == nil {
@@ -467,7 +406,13 @@ func (e Envelope) MarshalJSON() ([]byte, error) {
467406 }
468407
469408 func (e Envelope) marshalVersionedJSON() ([]byte, error) {
409+ if e.constructionErr != nil {
410+ return nil, e.constructionErr
411+ }
470412 version := e.Version
413+ if version == 0 {
414+ version = ProtocolVersion
415+ }
471416 messageID := e.MessageID
472417 if messageID == "" {
473418 messageID = NewMessageID()
@@ -39,25 +39,6 @@ const (
39 EventAgentExited = "agent.exited"39 EventAgentExited = "agent.exited"
40 )40 )
41 41
42-// Short aliases are kept alongside the direction-prefixed names because the
43-// latter read well at call sites while the former are convenient in switches.
44-const (
45- AgentPing = CommandAgentPing
46- TurnStart = CommandTurnStart
47- TurnCancel = CommandTurnCancel
48- AgentShutdown = CommandAgentShutdown
49-
50- AgentReady = EventAgentReady
51- AgentHeartbeat = EventAgentHeartbeat
52- TurnStarted = EventTurnStarted
53- TurnProgress = EventTurnProgress
54- ToolStarted = EventToolStarted
55- ToolFinished = EventToolFinished
56- MessageDelta = EventMessageDelta
57- AgentIdle = EventAgentIdle
58- AgentExited = EventAgentExited
59-)
60-
61 var (42 var (
62 // ErrEmptyProtocolLine is returned for a blank JSONL line or command.43 // ErrEmptyProtocolLine is returned for a blank JSONL line or command.
63 ErrEmptyProtocolLine = errors.New("subagent protocol: empty line")44 ErrEmptyProtocolLine = errors.New("subagent protocol: empty line")
@@ -107,11 +88,6 @@ func IsEventName(name string) bool {
107 return ok88 return ok
108 }89 }
109 90
110-// CommandName and EventName make APIs which accept only a direction more
111-// self-documenting without preventing unknown names from being preserved.
112-type CommandName string
113-type EventName string
114-
115 // MessageID returns a fresh opaque id suitable for correlating a command and91 // MessageID returns a fresh opaque id suitable for correlating a command and
116 // its result. UUIDs are used rather than a counter so ids remain unique when92 // its result. UUIDs are used rather than a counter so ids remain unique when
117 // several supervisors or workers are running at once.93 // several supervisors or workers are running at once.
@@ -152,15 +128,12 @@ type Envelope struct {
152 // Unknown contains unrecognised top-level fields from a versioned128 // Unknown contains unrecognised top-level fields from a versioned
153 // envelope. Payload itself retains unrecognised payload fields.129 // envelope. Payload itself retains unrecognised payload fields.
154 Unknown map[string]json.RawMessage `json:"-"`130 Unknown map[string]json.RawMessage `json:"-"`
155-}
156 131
157-// Command and ProtocolEvent are aliases rather than separate structs so a132+ // constructionErr records a payload-construction failure for the
158-// caller can pass either kind to the same JSONL transport without a wrapper.133+ // compatibility-preserving value constructor. It is surfaced by Validate
159-// Direction checks are available through IsCommand and IsEvent.134+ // and MarshalJSON rather than being turned into a valid null payload.
160-type Command = Envelope135+ constructionErr error
161-type ProtocolEvent = Envelope136+}
162-type EventEnvelope = Envelope
163-type CommandEnvelope = Envelope
164 137
165 // NewEnvelope creates a current-protocol envelope and assigns a message id138 // NewEnvelope creates a current-protocol envelope and assigns a message id
166 // and timestamp. payload may be any JSON-marshalable value. A nil payload is139 // and timestamp. payload may be any JSON-marshalable value. A nil payload is
@@ -174,18 +147,18 @@ func NewEnvelope(typ, agentID, turnID string, payload ...any) Envelope {
174 if err == nil {147 if err == nil {
175 return e148 return e
176 }149 }
177- // Keep the constructor useful in error-free call sites. MarshalJSON will150+ // Preserve the value-returning API used by command/event call sites, but
178- // still succeed with a valid JSON null; callers which need the original151+ // retain the construction failure so validation and wire encoding fail
179- // error should use NewEnvelopeWithPayload. Preserve the metadata even152+ // instead of silently sending a null payload.
180- // when only the caller's payload was not marshalable.
181 return Envelope{153 return Envelope{
182- Version: ProtocolVersion,154+ Version: ProtocolVersion,
183- Type: typ,155+ Type: typ,
184- MessageID: NewMessageID(),156+ MessageID: NewMessageID(),
185- AgentID: agentID,157+ AgentID: agentID,
186- TurnID: turnID,158+ TurnID: turnID,
187- Timestamp: time.Now().UTC(),159+ Timestamp: time.Now().UTC(),
188- Payload: json.RawMessage("null"),160+ Payload: nil,
161+ constructionErr: fmt.Errorf("subagent protocol payload: %w", err),
189 }162 }
190 }163 }
191 164
@@ -216,27 +189,11 @@ func NewCommand(name, agentID, turnID string, payload ...any) Envelope {
216 }189 }
217 190
218 // NewEventEnvelope creates a current-protocol event. The name is not checked191 // NewEventEnvelope creates a current-protocol event. The name is not checked
219-// against EventName so unknown future events remain forward-compatible.192+// against the known event registry so unknown future events remain forward-compatible.
220 func NewEventEnvelope(name, agentID, turnID string, payload ...any) Envelope {193 func NewEventEnvelope(name, agentID, turnID string, payload ...any) Envelope {
221 return NewEnvelope(name, agentID, turnID, payload...)194 return NewEnvelope(name, agentID, turnID, payload...)
222 }195 }
223 196
224-// NewProtocolEvent is a more explicit spelling of NewEventEnvelope. NewEvent
225-// is the canonical event-envelope constructor in this package.
226-func NewProtocolEvent(name, agentID, turnID string, payload ...any) Envelope {
227- return NewEventEnvelope(name, agentID, turnID, payload...)
228-}
229-
230-// NewCommandEnvelope is an explicit alias for NewCommand.
231-func NewCommandEnvelope(name, agentID, turnID string, payload ...any) Envelope {
232- return NewCommand(name, agentID, turnID, payload...)
233-}
234-
235-// NewEventMessage is an explicit alias for NewEventEnvelope.
236-func NewEventMessage(name, agentID, turnID string, payload ...any) Envelope {
237- return NewEventEnvelope(name, agentID, turnID, payload...)
238-}
239-
240 func firstPayload(payload []any) any {197 func firstPayload(payload []any) any {
241 if len(payload) == 0 || payload[0] == nil {198 if len(payload) == 0 || payload[0] == nil {
242 return map[string]any{}199 return map[string]any{}
@@ -259,6 +216,9 @@ func marshalPayload(payload any) (json.RawMessage, error) {
259 // intentionally separate from ParseEnvelope: parsing an unknown future event216 // intentionally separate from ParseEnvelope: parsing an unknown future event
260 // should succeed, while a sender can opt into strict validation before write.217 // should succeed, while a sender can opt into strict validation before write.
261 func (e Envelope) Validate() error {218 func (e Envelope) Validate() error {
219+ if e.constructionErr != nil {
220+ return e.constructionErr
221+ }
262 if e.Version != ProtocolVersion {222 if e.Version != ProtocolVersion {
263 return fmt.Errorf("%w: %d", ErrUnsupportedProtocolVersion, e.Version)223 return fmt.Errorf("%w: %d", ErrUnsupportedProtocolVersion, e.Version)
264 }224 }
@@ -357,19 +317,10 @@ func ParseEnvelope(data []byte) (Envelope, error) {
357 return e, nil317 return e, nil
358 }318 }
359 319
360-// DecodeEnvelope is an alias for ParseEnvelope for callers using decoder
361-// terminology.
362-func DecodeEnvelope(data []byte) (Envelope, error) { return ParseEnvelope(data) }
363-
364 // ParseJSONL parses one newline-delimited JSON message. It accepts a final320 // ParseJSONL parses one newline-delimited JSON message. It accepts a final
365 // line without a newline, as is customary for a stream after a clean EOF.321 // line without a newline, as is customary for a stream after a clean EOF.
366 func ParseJSONL(line []byte) (Envelope, error) { return ParseEnvelope(line) }322 func ParseJSONL(line []byte) (Envelope, error) { return ParseEnvelope(line) }
367 323
368-// ParseEnvelopeLine is the string form of ParseJSONL.
369-func ParseEnvelopeLine(line string) (Envelope, error) {
370- return ParseEnvelope([]byte(line))
371-}
372-
373 // ParseEvent parses a current-protocol JSON event. A known command is324 // ParseEvent parses a current-protocol JSON event. A known command is
374 // rejected; unknown types are accepted because an older peer cannot know325 // rejected; unknown types are accepted because an older peer cannot know
375 // whether a future event name is present in its event registry.326 // whether a future event name is present in its event registry.
@@ -384,12 +335,6 @@ func ParseEvent(data []byte) (Envelope, error) {
384 return e, nil335 return e, nil
385 }336 }
386 337
387-// ParseProtocolEvent is an explicit alias for ParseEvent.
388-func ParseProtocolEvent(data []byte) (Envelope, error) { return ParseEvent(data) }
389-
390-// ParseEventLine parses one JSONL event line.
391-func ParseEventLine(line string) (Envelope, error) { return ParseEvent([]byte(line)) }
392-
393 // ParseCommand parses one current-protocol JSON command.338 // ParseCommand parses one current-protocol JSON command.
394 func ParseCommand(line string) (Envelope, error) {339 func ParseCommand(line string) (Envelope, error) {
395 raw := strings.TrimSpace(line)340 raw := strings.TrimSpace(line)
@@ -406,9 +351,6 @@ func ParseCommand(line string) (Envelope, error) {
406 return e, nil351 return e, nil
407 }352 }
408 353
409-// ParseCommandLine is an alias for ParseCommand.
410-func ParseCommandLine(line string) (Envelope, error) { return ParseCommand(line) }
411-
412 // MarshalEnvelope returns one JSON object without a trailing newline.354 // MarshalEnvelope returns one JSON object without a trailing newline.
413 func MarshalEnvelope(e Envelope) ([]byte, error) {355 func MarshalEnvelope(e Envelope) ([]byte, error) {
414 return json.Marshal(e)356 return json.Marshal(e)
@@ -423,9 +365,6 @@ func MarshalJSONL(e Envelope) ([]byte, error) {
423 return append(b, '\n'), nil365 return append(b, '\n'), nil
424 }366 }
425 367
426-// EncodeEnvelope is the stream-friendly spelling of MarshalJSONL.
427-func EncodeEnvelope(e Envelope) ([]byte, error) { return MarshalJSONL(e) }
428-
429 // WriteEnvelope writes one complete newline-terminated JSONL message.368 // WriteEnvelope writes one complete newline-terminated JSONL message.
430 func WriteEnvelope(w io.Writer, e Envelope) error {369 func WriteEnvelope(w io.Writer, e Envelope) error {
431 if w == nil {370 if w == nil {
@@ -467,7 +406,13 @@ func (e Envelope) MarshalJSON() ([]byte, error) {
467 }406 }
468 407
469 func (e Envelope) marshalVersionedJSON() ([]byte, error) {408 func (e Envelope) marshalVersionedJSON() ([]byte, error) {
409+ if e.constructionErr != nil {
410+ return nil, e.constructionErr
411+ }
470 version := e.Version412 version := e.Version
413+ if version == 0 {
414+ version = ProtocolVersion
415+ }
471 messageID := e.MessageID416 messageID := e.MessageID
472 if messageID == "" {417 if messageID == "" {
473 messageID = NewMessageID()418 messageID = NewMessageID()
M packages/agent/subagents/protocol_test.go +34 -3
@@ -108,9 +108,9 @@ func TestProtocolJSONCommandsAndDirectionChecks(t *testing.T) {
108108 if err != nil {
109109 t.Fatalf("MarshalJSONL: %v", err)
110110 }
111- got, err := ParseCommandLine(string(line))
111+ got, err := ParseCommand(string(line))
112112 if err != nil {
113- t.Fatalf("ParseCommandLine: %v", err)
113+ t.Fatalf("ParseCommand: %v", err)
114114 }
115115 if !got.IsCommand() || got.IsEvent() || got.Type != CommandTurnStart {
116116 t.Fatalf("direction = command:%v event:%v type:%q", got.IsCommand(), got.IsEvent(), got.Type)
@@ -124,7 +124,7 @@ func TestProtocolJSONCommandsAndDirectionChecks(t *testing.T) {
124124 if err != nil {
125125 t.Fatalf("Marshal event: %v", err)
126126 }
127- if _, err := ParseCommandLine(string(eventLine)); !errors.Is(err, ErrNotCommand) {
127+ if _, err := ParseCommand(string(eventLine)); !errors.Is(err, ErrNotCommand) {
128128 t.Fatalf("ParseCommand(event) = %v, want ErrNotCommand", err)
129129 }
130130 }
@@ -200,6 +200,37 @@ func TestProtocolEmptyAndMalformedInputs(t *testing.T) {
200200 if _, err := ParseEnvelope([]byte(`{"version":1,"type":"x","payload":`)); err == nil {
201201 t.Fatal("ParseEnvelope malformed JSON succeeded")
202202 }
203+
204+ invalid := NewEnvelope("x", "agent-1", "turn-1", func() {})
205+ if err := invalid.Validate(); err == nil {
206+ t.Fatal("NewEnvelope accepted an unmarshalable payload")
207+ }
208+ if _, err := MarshalEnvelope(invalid); err == nil {
209+ t.Fatal("MarshalEnvelope accepted an unmarshalable payload")
210+ }
211+
212+ zeroVersion := Envelope{
213+ Type: "x",
214+ MessageID: "message-1",
215+ AgentID: "agent-1",
216+ Timestamp: time.Now().UTC(),
217+ Payload: json.RawMessage(`{}`),
218+ }
219+ wire, err := MarshalEnvelope(zeroVersion)
220+ if err != nil {
221+ t.Fatalf("MarshalEnvelope zero version: %v", err)
222+ }
223+ var fields map[string]json.RawMessage
224+ if err := json.Unmarshal(wire, &fields); err != nil {
225+ t.Fatalf("decode zero-version envelope: %v", err)
226+ }
227+ var version int
228+ if err := json.Unmarshal(fields["version"], &version); err != nil {
229+ t.Fatalf("decode version: %v", err)
230+ }
231+ if version != ProtocolVersion {
232+ t.Fatalf("version = %d, want %d", version, ProtocolVersion)
233+ }
203234 }
204235
205236 func TestProtocolResultShape(t *testing.T) {
@@ -108,9 +108,9 @@ func TestProtocolJSONCommandsAndDirectionChecks(t *testing.T) {
108 if err != nil {108 if err != nil {
109 t.Fatalf("MarshalJSONL: %v", err)109 t.Fatalf("MarshalJSONL: %v", err)
110 }110 }
111- got, err := ParseCommandLine(string(line))111+ got, err := ParseCommand(string(line))
112 if err != nil {112 if err != nil {
113- t.Fatalf("ParseCommandLine: %v", err)113+ t.Fatalf("ParseCommand: %v", err)
114 }114 }
115 if !got.IsCommand() || got.IsEvent() || got.Type != CommandTurnStart {115 if !got.IsCommand() || got.IsEvent() || got.Type != CommandTurnStart {
116 t.Fatalf("direction = command:%v event:%v type:%q", got.IsCommand(), got.IsEvent(), got.Type)116 t.Fatalf("direction = command:%v event:%v type:%q", got.IsCommand(), got.IsEvent(), got.Type)
@@ -124,7 +124,7 @@ func TestProtocolJSONCommandsAndDirectionChecks(t *testing.T) {
124 if err != nil {124 if err != nil {
125 t.Fatalf("Marshal event: %v", err)125 t.Fatalf("Marshal event: %v", err)
126 }126 }
127- if _, err := ParseCommandLine(string(eventLine)); !errors.Is(err, ErrNotCommand) {127+ if _, err := ParseCommand(string(eventLine)); !errors.Is(err, ErrNotCommand) {
128 t.Fatalf("ParseCommand(event) = %v, want ErrNotCommand", err)128 t.Fatalf("ParseCommand(event) = %v, want ErrNotCommand", err)
129 }129 }
130 }130 }
@@ -200,6 +200,37 @@ func TestProtocolEmptyAndMalformedInputs(t *testing.T) {
200 if _, err := ParseEnvelope([]byte(`{"version":1,"type":"x","payload":`)); err == nil {200 if _, err := ParseEnvelope([]byte(`{"version":1,"type":"x","payload":`)); err == nil {
201 t.Fatal("ParseEnvelope malformed JSON succeeded")201 t.Fatal("ParseEnvelope malformed JSON succeeded")
202 }202 }
203+
204+ invalid := NewEnvelope("x", "agent-1", "turn-1", func() {})
205+ if err := invalid.Validate(); err == nil {
206+ t.Fatal("NewEnvelope accepted an unmarshalable payload")
207+ }
208+ if _, err := MarshalEnvelope(invalid); err == nil {
209+ t.Fatal("MarshalEnvelope accepted an unmarshalable payload")
210+ }
211+
212+ zeroVersion := Envelope{
213+ Type: "x",
214+ MessageID: "message-1",
215+ AgentID: "agent-1",
216+ Timestamp: time.Now().UTC(),
217+ Payload: json.RawMessage(`{}`),
218+ }
219+ wire, err := MarshalEnvelope(zeroVersion)
220+ if err != nil {
221+ t.Fatalf("MarshalEnvelope zero version: %v", err)
222+ }
223+ var fields map[string]json.RawMessage
224+ if err := json.Unmarshal(wire, &fields); err != nil {
225+ t.Fatalf("decode zero-version envelope: %v", err)
226+ }
227+ var version int
228+ if err := json.Unmarshal(fields["version"], &version); err != nil {
229+ t.Fatalf("decode version: %v", err)
230+ }
231+ if version != ProtocolVersion {
232+ t.Fatalf("version = %d, want %d", version, ProtocolVersion)
233+ }
203 }234 }
204 235
205 func TestProtocolResultShape(t *testing.T) {236 func TestProtocolResultShape(t *testing.T) {
M packages/agent/subagents/result.go +193 -29
@@ -93,28 +93,34 @@ func (r *TurnResult) Bounded(maxBytes, maxLines int) *TurnResult {
9393 if out == nil {
9494 return nil
9595 }
96- const marker = "...[output truncated]"
9796 if len([]byte(out.Summary)) > 4*1024 {
9897 out.Summary = truncateUTF8(out.Summary, 4*1024)
9998 }
99+ out.Output = boundInlineText(out.Output, maxBytes, maxLines)
100+ return out
101+}
102+
103+// boundInlineText applies the shared line, byte, and UTF-8-safe bounds used
104+// for inline assistant output and durable turn-result previews.
105+func boundInlineText(value string, maxBytes, maxLines int) string {
106+ const marker = "...[output truncated]"
100107 if maxLines > 0 {
101- lines := strings.Split(out.Output, "\n")
108+ lines := strings.Split(value, "\n")
102109 if len(lines) > maxLines {
103110 if maxLines == 1 {
104- out.Output = marker
111+ value = marker
105112 } else {
106- out.Output = strings.Join(lines[:maxLines-1], "\n") + "\n" + marker
113+ value = strings.Join(lines[:maxLines-1], "\n") + "\n" + marker
107114 }
108115 }
109116 }
110- if maxBytes > 0 && len([]byte(out.Output)) > maxBytes {
117+ if maxBytes > 0 && len([]byte(value)) > maxBytes {
111118 if maxBytes <= len(marker) {
112- out.Output = truncateUTF8(out.Output, maxBytes)
113- } else {
114- out.Output = truncateUTF8(out.Output, maxBytes-len(marker)) + marker
119+ return truncateUTF8(value, maxBytes)
115120 }
121+ return truncateUTF8(value, maxBytes-len(marker)) + marker
116122 }
117- return out
123+ return value
118124 }
119125
120126 func truncateUTF8(value string, maxBytes int) string {
@@ -125,11 +131,78 @@ func truncateUTF8(value string, maxBytes int) string {
125131 if len(data) <= maxBytes {
126132 return value
127133 }
128- data = data[:maxBytes]
129- for len(data) > 0 && !utf8.Valid(data) {
130- data = data[:len(data)-1]
134+
135+ cut := maxBytes
136+ // Only inspect the short suffix that can belong to a UTF-8 rune ending
137+ // at the cut. Validating the entire prefix would make this quadratic for
138+ // invalid input and would incorrectly discard valid bytes before an
139+ // earlier invalid byte.
140+ start := cut - 1
141+ minimum := cut - utf8.UTFMax
142+ if minimum < 0 {
143+ minimum = 0
144+ }
145+ for start >= minimum && isUTF8Continuation(data[start]) {
146+ start--
147+ }
148+ if start >= minimum {
149+ if size := utf8LeadSize(data[start]); size > cut-start && isUTF8Prefix(data[start:cut], size) {
150+ cut = start
151+ }
152+ }
153+ return string(data[:cut])
154+}
155+
156+func isUTF8Continuation(b byte) bool {
157+ return b&0xc0 == 0x80
158+}
159+
160+func utf8LeadSize(b byte) int {
161+ switch {
162+ case b < utf8.RuneSelf:
163+ return 1
164+ case b >= 0xc2 && b <= 0xdf:
165+ return 2
166+ case b >= 0xe0 && b <= 0xef:
167+ return 3
168+ case b >= 0xf0 && b <= 0xf4:
169+ return 4
170+ default:
171+ return 0
172+ }
173+}
174+
175+func isUTF8Prefix(data []byte, size int) bool {
176+ if len(data) <= 1 {
177+ return true
178+ }
179+ if !isUTF8Continuation(data[1]) {
180+ return false
131181 }
132- return string(data)
182+ switch data[0] {
183+ case 0xe0:
184+ if data[1] < 0xa0 {
185+ return false
186+ }
187+ case 0xed:
188+ if data[1] > 0x9f {
189+ return false
190+ }
191+ case 0xf0:
192+ if data[1] < 0x90 {
193+ return false
194+ }
195+ case 0xf4:
196+ if data[1] > 0x8f {
197+ return false
198+ }
199+ }
200+ for _, b := range data[2:] {
201+ if !isUTF8Continuation(b) {
202+ return false
203+ }
204+ }
205+ return len(data) < size
133206 }
134207
135208 func validateTurnResultAgent(result *TurnResult, agentID string) error {
@@ -211,10 +284,52 @@ func PatchRef(id string) string { return AgentRef(id) + "/patch" }
211284
212285 const maxResultFileBytes = 8 * 1024 * 1024
213286
287+const (
288+ workspaceCaptureErrorCode = "workspace_capture_failed"
289+ workspaceCaptureErrorMessage = "workspace capture failed"
290+)
291+
214292 func resultPath(stateDir string) string { return filepath.Join(stateDir, "result.json") }
215293 func patchPath(stateDir string) string { return filepath.Join(stateDir, "patch.diff") }
216294
217-func writeTurnResult(stateDir string, result *TurnResult) error {
295+// writeSyncedFile writes a complete temporary file and closes it after the
296+// contents have been synced. Keeping this separate makes it harder for an
297+// atomic rename caller to accidentally publish an unflushed or open file.
298+func writeSyncedFile(path string, data []byte, perm os.FileMode) (err error) {
299+ file, err := os.OpenFile(path, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, perm)
300+ if err != nil {
301+ return fmt.Errorf("open: %w", err)
302+ }
303+ defer func() {
304+ closeErr := file.Close()
305+ if closeErr == nil {
306+ return
307+ }
308+ closeErr = fmt.Errorf("close: %w", closeErr)
309+ if err == nil {
310+ err = closeErr
311+ } else {
312+ err = errors.Join(err, closeErr)
313+ }
314+ }()
315+
316+ for written := 0; written < len(data); {
317+ n, writeErr := file.Write(data[written:])
318+ written += n
319+ if writeErr != nil {
320+ return fmt.Errorf("write: %w", writeErr)
321+ }
322+ if n == 0 {
323+ return fmt.Errorf("write: %w", io.ErrShortWrite)
324+ }
325+ }
326+ if err := file.Sync(); err != nil {
327+ return fmt.Errorf("sync: %w", err)
328+ }
329+ return nil
330+}
331+
332+func writeTurnResult(stateDir string, result *TurnResult) (err error) {
218333 if result == nil {
219334 return errors.New("subagents: nil result")
220335 }
@@ -238,11 +353,15 @@ func writeTurnResult(stateDir string, result *TurnResult) error {
238353 return fmt.Errorf("subagents result dir: %w", err)
239354 }
240355 tmp := resultPath(stateDir) + ".tmp"
241- if err := os.WriteFile(tmp, append(data, '\n'), 0o600); err != nil {
356+ defer func() {
357+ if err != nil {
358+ _ = os.Remove(tmp)
359+ }
360+ }()
361+ if err := writeSyncedFile(tmp, append(data, '\n'), 0o600); err != nil {
242362 return fmt.Errorf("subagents result write: %w", err)
243363 }
244364 if err := os.Rename(tmp, resultPath(stateDir)); err != nil {
245- _ = os.Remove(tmp)
246365 return fmt.Errorf("subagents result rename: %w", err)
247366 }
248367 return nil
@@ -254,10 +373,12 @@ func (f *Supervisor) captureWorkspace(a *Agent) {
254373 }
255374 stateDir := a.stateDirectory(f.cfg.Root)
256375 if _, err := os.Stat(stateDir); err != nil {
376+ recordWorkspaceCaptureFailure(a)
257377 return
258378 }
259379 capture, err := a.workspaceCapture()
260380 if err != nil {
381+ recordWorkspaceCaptureFailure(a)
261382 return
262383 }
263384 a.lifecycleMu.Lock()
@@ -266,13 +387,16 @@ func (f *Supervisor) captureWorkspace(a *Agent) {
266387 if len(capture.Patch) == 0 {
267388 return
268389 }
269- path := patchPath(a.stateDirectory(f.cfg.Root))
390+ path := patchPath(stateDir)
270391 tmp := path + ".tmp"
271- if err := os.WriteFile(tmp, capture.Patch, 0o600); err != nil {
392+ if err := writeSyncedFile(tmp, capture.Patch, 0o600); err != nil {
393+ _ = os.Remove(tmp)
394+ recordWorkspaceCaptureFailure(a)
272395 return
273396 }
274397 if err := os.Rename(tmp, path); err != nil {
275398 _ = os.Remove(tmp)
399+ recordWorkspaceCaptureFailure(a)
276400 return
277401 }
278402 a.lifecycleMu.Lock()
@@ -280,10 +404,45 @@ func (f *Supervisor) captureWorkspace(a *Agent) {
280404 a.lifecycleMu.Unlock()
281405 }
282406
407+// recordWorkspaceCaptureFailure records a stable, non-sensitive error in the
408+// result already received from the child (or creates the durable fallback).
409+// The underlying filesystem/capture error can contain private paths or data,
410+// so it is deliberately not copied into ResultError.
411+func recordWorkspaceCaptureFailure(a *Agent) {
412+ if a == nil {
413+ return
414+ }
415+ result := a.Result()
416+ if result == nil || validateTurnResultAgent(result, a.ID) != nil {
417+ result = &TurnResult{AgentID: a.ID}
418+ }
419+ result.Version = ProtocolVersion
420+ if result.AgentID == "" {
421+ result.AgentID = a.ID
422+ }
423+ if result.TurnID == "" {
424+ turnID := a.CurrentTurnID()
425+ if turnID == "" {
426+ turnID = fmt.Sprintf("turn-%d", a.AttemptValue())
427+ }
428+ result.TurnID = turnID
429+ }
430+ if result.CreatedAt.IsZero() {
431+ result.CreatedAt = time.Now().UTC()
432+ }
433+ result.Status = ResultFailed
434+ result.Error = &ResultError{
435+ Code: workspaceCaptureErrorCode,
436+ Message: workspaceCaptureErrorMessage,
437+ }
438+ a.setResult(result)
439+}
440+
283441 func (f *Supervisor) ensureResult(a *Agent, status Status, runErr error) {
284442 if a == nil {
285443 return
286444 }
445+ attempt := a.AttemptValue()
287446 result := a.Result()
288447 if result != nil {
289448 if err := validateTurnResultAgent(result, a.ID); err != nil {
@@ -297,7 +456,7 @@ func (f *Supervisor) ensureResult(a *Agent, status Status, runErr error) {
297456 result.AgentID = a.ID
298457 }
299458 if result.TurnID == "" {
300- result.TurnID = firstNonEmpty(a.CurrentTurnID(), fmt.Sprintf("turn-%d", a.Attempt))
459+ result.TurnID = firstNonEmpty(a.CurrentTurnID(), fmt.Sprintf("turn-%d", attempt))
301460 }
302461 if result.Version == 0 {
303462 result.Version = ProtocolVersion
@@ -312,7 +471,7 @@ func (f *Supervisor) ensureResult(a *Agent, status Status, runErr error) {
312471 if result == nil {
313472 turnID := a.CurrentTurnID()
314473 if turnID == "" {
315- turnID = fmt.Sprintf("turn-%d", a.Attempt)
474+ turnID = fmt.Sprintf("turn-%d", attempt)
316475 }
317476 a.mu.Lock()
318477 output := a.lastAssistant
@@ -339,7 +498,7 @@ func (f *Supervisor) ensureResult(a *Agent, status Status, runErr error) {
339498 result.AgentID = a.ID
340499 }
341500 if result.TurnID == "" {
342- result.TurnID = firstNonEmpty(a.CurrentTurnID(), fmt.Sprintf("turn-%d", a.Attempt))
501+ result.TurnID = firstNonEmpty(a.CurrentTurnID(), fmt.Sprintf("turn-%d", attempt))
343502 }
344503 if result.Version == 0 {
345504 result.Version = ProtocolVersion
@@ -347,8 +506,15 @@ func (f *Supervisor) ensureResult(a *Agent, status Status, runErr error) {
347506 if result.CreatedAt.IsZero() {
348507 result.CreatedAt = time.Now().UTC()
349508 }
350- if runErr != nil && result.Error == nil {
351- result.Error = &ResultError{Code: "runner_failed", Message: truncate(runErr.Error(), 500)}
509+ if runErr != nil {
510+ if status == StatusKilled || errors.Is(runErr, context.Canceled) {
511+ result.Status = ResultCanceled
512+ } else {
513+ result.Status = ResultFailed
514+ }
515+ if result.Error == nil {
516+ result.Error = &ResultError{Code: "runner_failed", Message: truncate(runErr.Error(), 500)}
517+ }
352518 }
353519 if len(result.ChangedFiles) == 0 {
354520 a.lifecycleMu.Lock()
@@ -361,12 +527,10 @@ func (f *Supervisor) ensureResult(a *Agent, status Status, runErr error) {
361527 result = result.Bounded(a.maxOutputBytes, a.maxOutputLines)
362528 a.setResult(result)
363529 stateDir := a.stateDirectory(f.cfg.Root)
364- if _, err := os.Stat(stateDir); err == nil {
365- if err := writeTurnResult(stateDir, result); err == nil {
366- a.lifecycleMu.Lock()
367- a.resultRef = ResultRef(a.ID)
368- a.lifecycleMu.Unlock()
369- }
530+ if err := writeTurnResult(stateDir, result); err == nil {
531+ a.lifecycleMu.Lock()
532+ a.resultRef = ResultRef(a.ID)
533+ a.lifecycleMu.Unlock()
370534 }
371535 }
372536
@@ -93,28 +93,34 @@ func (r *TurnResult) Bounded(maxBytes, maxLines int) *TurnResult {
93 if out == nil {93 if out == nil {
94 return nil94 return nil
95 }95 }
96- const marker = "...[output truncated]"
97 if len([]byte(out.Summary)) > 4*1024 {96 if len([]byte(out.Summary)) > 4*1024 {
98 out.Summary = truncateUTF8(out.Summary, 4*1024)97 out.Summary = truncateUTF8(out.Summary, 4*1024)
99 }98 }
99+ out.Output = boundInlineText(out.Output, maxBytes, maxLines)
100+ return out
101+}
102+
103+// boundInlineText applies the shared line, byte, and UTF-8-safe bounds used
104+// for inline assistant output and durable turn-result previews.
105+func boundInlineText(value string, maxBytes, maxLines int) string {
106+ const marker = "...[output truncated]"
100 if maxLines > 0 {107 if maxLines > 0 {
101- lines := strings.Split(out.Output, "\n")108+ lines := strings.Split(value, "\n")
102 if len(lines) > maxLines {109 if len(lines) > maxLines {
103 if maxLines == 1 {110 if maxLines == 1 {
104- out.Output = marker111+ value = marker
105 } else {112 } else {
106- out.Output = strings.Join(lines[:maxLines-1], "\n") + "\n" + marker113+ value = strings.Join(lines[:maxLines-1], "\n") + "\n" + marker
107 }114 }
108 }115 }
109 }116 }
110- if maxBytes > 0 && len([]byte(out.Output)) > maxBytes {117+ if maxBytes > 0 && len([]byte(value)) > maxBytes {
111 if maxBytes <= len(marker) {118 if maxBytes <= len(marker) {
112- out.Output = truncateUTF8(out.Output, maxBytes)119+ return truncateUTF8(value, maxBytes)
113- } else {
114- out.Output = truncateUTF8(out.Output, maxBytes-len(marker)) + marker
115 }120 }
121+ return truncateUTF8(value, maxBytes-len(marker)) + marker
116 }122 }
117- return out123+ return value
118 }124 }
119 125
120 func truncateUTF8(value string, maxBytes int) string {126 func truncateUTF8(value string, maxBytes int) string {
@@ -125,11 +131,78 @@ func truncateUTF8(value string, maxBytes int) string {
125 if len(data) <= maxBytes {131 if len(data) <= maxBytes {
126 return value132 return value
127 }133 }
128- data = data[:maxBytes]134+
129- for len(data) > 0 && !utf8.Valid(data) {135+ cut := maxBytes
130- data = data[:len(data)-1]136+ // Only inspect the short suffix that can belong to a UTF-8 rune ending
137+ // at the cut. Validating the entire prefix would make this quadratic for
138+ // invalid input and would incorrectly discard valid bytes before an
139+ // earlier invalid byte.
140+ start := cut - 1
141+ minimum := cut - utf8.UTFMax
142+ if minimum < 0 {
143+ minimum = 0
144+ }
145+ for start >= minimum && isUTF8Continuation(data[start]) {
146+ start--
147+ }
148+ if start >= minimum {
149+ if size := utf8LeadSize(data[start]); size > cut-start && isUTF8Prefix(data[start:cut], size) {
150+ cut = start
151+ }
152+ }
153+ return string(data[:cut])
154+}
155+
156+func isUTF8Continuation(b byte) bool {
157+ return b&0xc0 == 0x80
158+}
159+
160+func utf8LeadSize(b byte) int {
161+ switch {
162+ case b < utf8.RuneSelf:
163+ return 1
164+ case b >= 0xc2 && b <= 0xdf:
165+ return 2
166+ case b >= 0xe0 && b <= 0xef:
167+ return 3
168+ case b >= 0xf0 && b <= 0xf4:
169+ return 4
170+ default:
171+ return 0
172+ }
173+}
174+
175+func isUTF8Prefix(data []byte, size int) bool {
176+ if len(data) <= 1 {
177+ return true
178+ }
179+ if !isUTF8Continuation(data[1]) {
180+ return false
131 }181 }
132- return string(data)182+ switch data[0] {
183+ case 0xe0:
184+ if data[1] < 0xa0 {
185+ return false
186+ }
187+ case 0xed:
188+ if data[1] > 0x9f {
189+ return false
190+ }
191+ case 0xf0:
192+ if data[1] < 0x90 {
193+ return false
194+ }
195+ case 0xf4:
196+ if data[1] > 0x8f {
197+ return false
198+ }
199+ }
200+ for _, b := range data[2:] {
201+ if !isUTF8Continuation(b) {
202+ return false
203+ }
204+ }
205+ return len(data) < size
133 }206 }
134 207
135 func validateTurnResultAgent(result *TurnResult, agentID string) error {208 func validateTurnResultAgent(result *TurnResult, agentID string) error {
@@ -211,10 +284,52 @@ func PatchRef(id string) string { return AgentRef(id) + "/patch" }
211 284
212 const maxResultFileBytes = 8 * 1024 * 1024285 const maxResultFileBytes = 8 * 1024 * 1024
213 286
287+const (
288+ workspaceCaptureErrorCode = "workspace_capture_failed"
289+ workspaceCaptureErrorMessage = "workspace capture failed"
290+)
291+
214 func resultPath(stateDir string) string { return filepath.Join(stateDir, "result.json") }292 func resultPath(stateDir string) string { return filepath.Join(stateDir, "result.json") }
215 func patchPath(stateDir string) string { return filepath.Join(stateDir, "patch.diff") }293 func patchPath(stateDir string) string { return filepath.Join(stateDir, "patch.diff") }
216 294
217-func writeTurnResult(stateDir string, result *TurnResult) error {295+// writeSyncedFile writes a complete temporary file and closes it after the
296+// contents have been synced. Keeping this separate makes it harder for an
297+// atomic rename caller to accidentally publish an unflushed or open file.
298+func writeSyncedFile(path string, data []byte, perm os.FileMode) (err error) {
299+ file, err := os.OpenFile(path, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, perm)
300+ if err != nil {
301+ return fmt.Errorf("open: %w", err)
302+ }
303+ defer func() {
304+ closeErr := file.Close()
305+ if closeErr == nil {
306+ return
307+ }
308+ closeErr = fmt.Errorf("close: %w", closeErr)
309+ if err == nil {
310+ err = closeErr
311+ } else {
312+ err = errors.Join(err, closeErr)
313+ }
314+ }()
315+
316+ for written := 0; written < len(data); {
317+ n, writeErr := file.Write(data[written:])
318+ written += n
319+ if writeErr != nil {
320+ return fmt.Errorf("write: %w", writeErr)
321+ }
322+ if n == 0 {
323+ return fmt.Errorf("write: %w", io.ErrShortWrite)
324+ }
325+ }
326+ if err := file.Sync(); err != nil {
327+ return fmt.Errorf("sync: %w", err)
328+ }
329+ return nil
330+}
331+
332+func writeTurnResult(stateDir string, result *TurnResult) (err error) {
218 if result == nil {333 if result == nil {
219 return errors.New("subagents: nil result")334 return errors.New("subagents: nil result")
220 }335 }
@@ -238,11 +353,15 @@ func writeTurnResult(stateDir string, result *TurnResult) error {
238 return fmt.Errorf("subagents result dir: %w", err)353 return fmt.Errorf("subagents result dir: %w", err)
239 }354 }
240 tmp := resultPath(stateDir) + ".tmp"355 tmp := resultPath(stateDir) + ".tmp"
241- if err := os.WriteFile(tmp, append(data, '\n'), 0o600); err != nil {356+ defer func() {
357+ if err != nil {
358+ _ = os.Remove(tmp)
359+ }
360+ }()
361+ if err := writeSyncedFile(tmp, append(data, '\n'), 0o600); err != nil {
242 return fmt.Errorf("subagents result write: %w", err)362 return fmt.Errorf("subagents result write: %w", err)
243 }363 }
244 if err := os.Rename(tmp, resultPath(stateDir)); err != nil {364 if err := os.Rename(tmp, resultPath(stateDir)); err != nil {
245- _ = os.Remove(tmp)
246 return fmt.Errorf("subagents result rename: %w", err)365 return fmt.Errorf("subagents result rename: %w", err)
247 }366 }
248 return nil367 return nil
@@ -254,10 +373,12 @@ func (f *Supervisor) captureWorkspace(a *Agent) {
254 }373 }
255 stateDir := a.stateDirectory(f.cfg.Root)374 stateDir := a.stateDirectory(f.cfg.Root)
256 if _, err := os.Stat(stateDir); err != nil {375 if _, err := os.Stat(stateDir); err != nil {
376+ recordWorkspaceCaptureFailure(a)
257 return377 return
258 }378 }
259 capture, err := a.workspaceCapture()379 capture, err := a.workspaceCapture()
260 if err != nil {380 if err != nil {
381+ recordWorkspaceCaptureFailure(a)
261 return382 return
262 }383 }
263 a.lifecycleMu.Lock()384 a.lifecycleMu.Lock()
@@ -266,13 +387,16 @@ func (f *Supervisor) captureWorkspace(a *Agent) {
266 if len(capture.Patch) == 0 {387 if len(capture.Patch) == 0 {
267 return388 return
268 }389 }
269- path := patchPath(a.stateDirectory(f.cfg.Root))390+ path := patchPath(stateDir)
270 tmp := path + ".tmp"391 tmp := path + ".tmp"
271- if err := os.WriteFile(tmp, capture.Patch, 0o600); err != nil {392+ if err := writeSyncedFile(tmp, capture.Patch, 0o600); err != nil {
393+ _ = os.Remove(tmp)
394+ recordWorkspaceCaptureFailure(a)
272 return395 return
273 }396 }
274 if err := os.Rename(tmp, path); err != nil {397 if err := os.Rename(tmp, path); err != nil {
275 _ = os.Remove(tmp)398 _ = os.Remove(tmp)
399+ recordWorkspaceCaptureFailure(a)
276 return400 return
277 }401 }
278 a.lifecycleMu.Lock()402 a.lifecycleMu.Lock()
@@ -280,10 +404,45 @@ func (f *Supervisor) captureWorkspace(a *Agent) {
280 a.lifecycleMu.Unlock()404 a.lifecycleMu.Unlock()
281 }405 }
282 406
407+// recordWorkspaceCaptureFailure records a stable, non-sensitive error in the
408+// result already received from the child (or creates the durable fallback).
409+// The underlying filesystem/capture error can contain private paths or data,
410+// so it is deliberately not copied into ResultError.
411+func recordWorkspaceCaptureFailure(a *Agent) {
412+ if a == nil {
413+ return
414+ }
415+ result := a.Result()
416+ if result == nil || validateTurnResultAgent(result, a.ID) != nil {
417+ result = &TurnResult{AgentID: a.ID}
418+ }
419+ result.Version = ProtocolVersion
420+ if result.AgentID == "" {
421+ result.AgentID = a.ID
422+ }
423+ if result.TurnID == "" {
424+ turnID := a.CurrentTurnID()
425+ if turnID == "" {
426+ turnID = fmt.Sprintf("turn-%d", a.AttemptValue())
427+ }
428+ result.TurnID = turnID
429+ }
430+ if result.CreatedAt.IsZero() {
431+ result.CreatedAt = time.Now().UTC()
432+ }
433+ result.Status = ResultFailed
434+ result.Error = &ResultError{
435+ Code: workspaceCaptureErrorCode,
436+ Message: workspaceCaptureErrorMessage,
437+ }
438+ a.setResult(result)
439+}
440+
283 func (f *Supervisor) ensureResult(a *Agent, status Status, runErr error) {441 func (f *Supervisor) ensureResult(a *Agent, status Status, runErr error) {
284 if a == nil {442 if a == nil {
285 return443 return
286 }444 }
445+ attempt := a.AttemptValue()
287 result := a.Result()446 result := a.Result()
288 if result != nil {447 if result != nil {
289 if err := validateTurnResultAgent(result, a.ID); err != nil {448 if err := validateTurnResultAgent(result, a.ID); err != nil {
@@ -297,7 +456,7 @@ func (f *Supervisor) ensureResult(a *Agent, status Status, runErr error) {
297 result.AgentID = a.ID456 result.AgentID = a.ID
298 }457 }
299 if result.TurnID == "" {458 if result.TurnID == "" {
300- result.TurnID = firstNonEmpty(a.CurrentTurnID(), fmt.Sprintf("turn-%d", a.Attempt))459+ result.TurnID = firstNonEmpty(a.CurrentTurnID(), fmt.Sprintf("turn-%d", attempt))
301 }460 }
302 if result.Version == 0 {461 if result.Version == 0 {
303 result.Version = ProtocolVersion462 result.Version = ProtocolVersion
@@ -312,7 +471,7 @@ func (f *Supervisor) ensureResult(a *Agent, status Status, runErr error) {
312 if result == nil {471 if result == nil {
313 turnID := a.CurrentTurnID()472 turnID := a.CurrentTurnID()
314 if turnID == "" {473 if turnID == "" {
315- turnID = fmt.Sprintf("turn-%d", a.Attempt)474+ turnID = fmt.Sprintf("turn-%d", attempt)
316 }475 }
317 a.mu.Lock()476 a.mu.Lock()
318 output := a.lastAssistant477 output := a.lastAssistant
@@ -339,7 +498,7 @@ func (f *Supervisor) ensureResult(a *Agent, status Status, runErr error) {
339 result.AgentID = a.ID498 result.AgentID = a.ID
340 }499 }
341 if result.TurnID == "" {500 if result.TurnID == "" {
342- result.TurnID = firstNonEmpty(a.CurrentTurnID(), fmt.Sprintf("turn-%d", a.Attempt))501+ result.TurnID = firstNonEmpty(a.CurrentTurnID(), fmt.Sprintf("turn-%d", attempt))
343 }502 }
344 if result.Version == 0 {503 if result.Version == 0 {
345 result.Version = ProtocolVersion504 result.Version = ProtocolVersion
@@ -347,8 +506,15 @@ func (f *Supervisor) ensureResult(a *Agent, status Status, runErr error) {
347 if result.CreatedAt.IsZero() {506 if result.CreatedAt.IsZero() {
348 result.CreatedAt = time.Now().UTC()507 result.CreatedAt = time.Now().UTC()
349 }508 }
350- if runErr != nil && result.Error == nil {509+ if runErr != nil {
351- result.Error = &ResultError{Code: "runner_failed", Message: truncate(runErr.Error(), 500)}510+ if status == StatusKilled || errors.Is(runErr, context.Canceled) {
511+ result.Status = ResultCanceled
512+ } else {
513+ result.Status = ResultFailed
514+ }
515+ if result.Error == nil {
516+ result.Error = &ResultError{Code: "runner_failed", Message: truncate(runErr.Error(), 500)}
517+ }
352 }518 }
353 if len(result.ChangedFiles) == 0 {519 if len(result.ChangedFiles) == 0 {
354 a.lifecycleMu.Lock()520 a.lifecycleMu.Lock()
@@ -361,12 +527,10 @@ func (f *Supervisor) ensureResult(a *Agent, status Status, runErr error) {
361 result = result.Bounded(a.maxOutputBytes, a.maxOutputLines)527 result = result.Bounded(a.maxOutputBytes, a.maxOutputLines)
362 a.setResult(result)528 a.setResult(result)
363 stateDir := a.stateDirectory(f.cfg.Root)529 stateDir := a.stateDirectory(f.cfg.Root)
364- if _, err := os.Stat(stateDir); err == nil {530+ if err := writeTurnResult(stateDir, result); err == nil {
365- if err := writeTurnResult(stateDir, result); err == nil {531+ a.lifecycleMu.Lock()
366- a.lifecycleMu.Lock()532+ a.resultRef = ResultRef(a.ID)
367- a.resultRef = ResultRef(a.ID)533+ a.lifecycleMu.Unlock()
368- a.lifecycleMu.Unlock()
369- }
370 }534 }
371 }535 }
372 536
M packages/agent/subagents/result_test.go +201 -0
@@ -1,7 +1,9 @@
11 package subagents
22
33 import (
4+ "context"
45 "encoding/json"
6+ "errors"
57 "os"
68 "path/filepath"
79 "strings"
@@ -47,6 +49,48 @@ func TestTurnResultBoundedHonorsLineAndUTF8Limits(t *testing.T) {
4749 }
4850 }
4951
52+func TestTruncateUTF8CutsOnlyIncompleteTrailingRune(t *testing.T) {
53+ invalidPrefix := string([]byte{0xff, 'a', 'b', 'c'})
54+ tests := []struct {
55+ name string
56+ value string
57+ maxBytes int
58+ want string
59+ }{
60+ {
61+ name: "multibyte",
62+ value: "prefix😀suffix",
63+ maxBytes: len([]byte("prefix")) + 2,
64+ want: "prefix",
65+ },
66+ {
67+ name: "invalid byte before incomplete rune",
68+ value: invalidPrefix + string([]byte{0xe2, 0x82}) + "tail",
69+ maxBytes: len([]byte(invalidPrefix)) + 2,
70+ want: invalidPrefix,
71+ },
72+ {
73+ name: "invalid byte before ascii cut",
74+ value: invalidPrefix + "😀",
75+ maxBytes: len([]byte(invalidPrefix)),
76+ want: invalidPrefix,
77+ },
78+ {
79+ name: "invalid trailing byte is preserved",
80+ value: string([]byte{'a', 0xff, 0x80}) + "😀",
81+ maxBytes: 3,
82+ want: string([]byte{'a', 0xff, 0x80}),
83+ },
84+ }
85+ for _, tt := range tests {
86+ t.Run(tt.name, func(t *testing.T) {
87+ if got := truncateUTF8(tt.value, tt.maxBytes); got != tt.want {
88+ t.Fatalf("truncateUTF8() = %q (%x), want %q (%x)", got, got, tt.want, tt.want)
89+ }
90+ })
91+ }
92+}
93+
5094 func TestDecodeTurnResultEventValidatesIdentityAndStatus(t *testing.T) {
5195 invalid := NewEvent(EventTurnResult, map[string]any{
5296 "status": "unknown",
@@ -105,3 +149,160 @@ func TestTurnResultReferencesAndPersistence(t *testing.T) {
105149 t.Fatalf("result permissions = %o, want 600", info.Mode().Perm())
106150 }
107151 }
152+
153+func TestWriteTurnResultCleansTemporaryFileAfterRenameFailure(t *testing.T) {
154+ state := t.TempDir()
155+ if err := os.Mkdir(resultPath(state), 0o700); err != nil {
156+ t.Fatal(err)
157+ }
158+ result := &TurnResult{AgentID: "a", TurnID: "t", Status: ResultSucceeded}
159+ if err := writeTurnResult(state, result); err == nil {
160+ t.Fatal("writeTurnResult succeeded when the result path was a directory")
161+ }
162+ if _, err := os.Stat(resultPath(state) + ".tmp"); !os.IsNotExist(err) {
163+ t.Fatalf("temporary result file still exists, stat error = %v", err)
164+ }
165+}
166+
167+func TestCaptureWorkspaceFailureIsDurableAndPrivate(t *testing.T) {
168+ makeAgent := func(stateDir string, capture func() (WorkspaceCapture, error)) *Agent {
169+ return &Agent{ID: "agent-1", stateDir: stateDir, workspaceCapture: capture}
170+ }
171+ assertFailure := func(t *testing.T, supervisor *Supervisor, agent *Agent, stateDir string) {
172+ t.Helper()
173+ supervisor.captureWorkspace(agent)
174+ supervisor.ensureResult(agent, StatusDone, nil)
175+ checkCaptureFailureResult(t, agent, stateDir)
176+ }
177+
178+ t.Run("stat", func(t *testing.T) {
179+ stateDir := filepath.Join(t.TempDir(), "missing")
180+ agent := makeAgent(stateDir, func() (WorkspaceCapture, error) {
181+ t.Fatal("workspace capture should not run when the state directory cannot be stat'ed")
182+ return WorkspaceCapture{}, nil
183+ })
184+ assertFailure(t, &Supervisor{}, agent, stateDir)
185+ })
186+
187+ t.Run("capture", func(t *testing.T) {
188+ stateDir := t.TempDir()
189+ agent := makeAgent(stateDir, func() (WorkspaceCapture, error) {
190+ return WorkspaceCapture{}, errors.New("capture leaked /private/secret")
191+ })
192+ assertFailure(t, &Supervisor{}, agent, stateDir)
193+ })
194+
195+ t.Run("temp write", func(t *testing.T) {
196+ stateDir := t.TempDir()
197+ if err := os.Mkdir(patchPath(stateDir)+".tmp", 0o700); err != nil {
198+ t.Fatal(err)
199+ }
200+ agent := makeAgent(stateDir, func() (WorkspaceCapture, error) {
201+ return WorkspaceCapture{Patch: []byte("patch")}, nil
202+ })
203+ assertFailure(t, &Supervisor{}, agent, stateDir)
204+ })
205+
206+ t.Run("rename", func(t *testing.T) {
207+ stateDir := t.TempDir()
208+ if err := os.Mkdir(patchPath(stateDir), 0o700); err != nil {
209+ t.Fatal(err)
210+ }
211+ agent := makeAgent(stateDir, func() (WorkspaceCapture, error) {
212+ return WorkspaceCapture{Patch: []byte("patch")}, nil
213+ })
214+ assertFailure(t, &Supervisor{}, agent, stateDir)
215+ if _, err := os.Stat(patchPath(stateDir) + ".tmp"); !os.IsNotExist(err) {
216+ t.Fatalf("workspace temporary patch still exists, stat error = %v", err)
217+ }
218+ })
219+}
220+
221+func TestCaptureWorkspaceSuccessPersistsPatch(t *testing.T) {
222+ stateDir := t.TempDir()
223+ agent := &Agent{
224+ ID: "agent-1",
225+ stateDir: stateDir,
226+ workspaceCapture: func() (WorkspaceCapture, error) {
227+ return WorkspaceCapture{Patch: []byte("patch"), ChangedFiles: []string{"file.txt"}}, nil
228+ },
229+ }
230+ (&Supervisor{}).captureWorkspace(agent)
231+
232+ patch, err := os.ReadFile(patchPath(stateDir))
233+ if err != nil {
234+ t.Fatal(err)
235+ }
236+ if string(patch) != "patch" {
237+ t.Fatalf("patch = %q, want %q", patch, "patch")
238+ }
239+ agent.lifecycleMu.Lock()
240+ patchRef := agent.patchRef
241+ changedFiles := append([]string(nil), agent.changedFiles...)
242+ agent.lifecycleMu.Unlock()
243+ if patchRef != PatchRef(agent.ID) || len(changedFiles) != 1 || changedFiles[0] != "file.txt" {
244+ t.Fatalf("capture metadata = ref %q, changed files %#v", patchRef, changedFiles)
245+ }
246+}
247+
248+func checkCaptureFailureResult(t *testing.T, agent *Agent, stateDir string) {
249+ t.Helper()
250+ result := agent.Result()
251+ if result == nil || result.Status != ResultFailed || result.Error == nil {
252+ t.Fatalf("capture failure result = %#v", result)
253+ }
254+ if result.Error.Code != workspaceCaptureErrorCode || result.Error.Message != workspaceCaptureErrorMessage {
255+ t.Fatalf("capture failure error = %#v", result.Error)
256+ }
257+ if strings.Contains(result.Error.Message, stateDir) || strings.Contains(result.Error.Message, "secret") {
258+ t.Fatalf("capture failure leaked private data: %#v", result.Error)
259+ }
260+ durable, err := readTurnResult(stateDir)
261+ if err != nil {
262+ t.Fatal(err)
263+ }
264+ if durable.Error == nil || durable.Error.Code != workspaceCaptureErrorCode || durable.Status != ResultFailed {
265+ t.Fatalf("durable capture failure = %#v", durable)
266+ }
267+}
268+
269+func TestEnsureResultReconcilesChildStatusWithRunnerError(t *testing.T) {
270+ tests := []struct {
271+ name string
272+ status Status
273+ runErr error
274+ wantState TurnStatus
275+ }{
276+ {name: "runner failure", status: StatusFailed, runErr: errors.New("runner failed"), wantState: ResultFailed},
277+ {name: "runner cancellation", status: StatusFailed, runErr: context.Canceled, wantState: ResultCanceled},
278+ {name: "killed", status: StatusKilled, runErr: context.Canceled, wantState: ResultCanceled},
279+ }
280+ for _, tt := range tests {
281+ t.Run(tt.name, func(t *testing.T) {
282+ stateDir := t.TempDir()
283+ agent := &Agent{
284+ ID: "agent-1",
285+ stateDir: stateDir,
286+ result: &TurnResult{
287+ AgentID: "agent-1", TurnID: "turn-1", Status: ResultSucceeded,
288+ },
289+ }
290+ supervisor := &Supervisor{}
291+ supervisor.ensureResult(agent, tt.status, tt.runErr)
292+ result := agent.Result()
293+ if result == nil || result.Status != tt.wantState {
294+ t.Fatalf("result status = %#v, want %q", result, tt.wantState)
295+ }
296+ if result.Error == nil {
297+ t.Fatal("runner error was not recorded")
298+ }
299+ durable, err := readTurnResult(stateDir)
300+ if err != nil {
301+ t.Fatal(err)
302+ }
303+ if durable.Status != tt.wantState {
304+ t.Fatalf("durable result status = %q, want %q", durable.Status, tt.wantState)
305+ }
306+ })
307+ }
308+}
@@ -1,7 +1,9 @@
1 package subagents1 package subagents
2 2
3 import (3 import (
4+ "context"
4 "encoding/json"5 "encoding/json"
6+ "errors"
5 "os"7 "os"
6 "path/filepath"8 "path/filepath"
7 "strings"9 "strings"
@@ -47,6 +49,48 @@ func TestTurnResultBoundedHonorsLineAndUTF8Limits(t *testing.T) {
47 }49 }
48 }50 }
49 51
52+func TestTruncateUTF8CutsOnlyIncompleteTrailingRune(t *testing.T) {
53+ invalidPrefix := string([]byte{0xff, 'a', 'b', 'c'})
54+ tests := []struct {
55+ name string
56+ value string
57+ maxBytes int
58+ want string
59+ }{
60+ {
61+ name: "multibyte",
62+ value: "prefix😀suffix",
63+ maxBytes: len([]byte("prefix")) + 2,
64+ want: "prefix",
65+ },
66+ {
67+ name: "invalid byte before incomplete rune",
68+ value: invalidPrefix + string([]byte{0xe2, 0x82}) + "tail",
69+ maxBytes: len([]byte(invalidPrefix)) + 2,
70+ want: invalidPrefix,
71+ },
72+ {
73+ name: "invalid byte before ascii cut",
74+ value: invalidPrefix + "😀",
75+ maxBytes: len([]byte(invalidPrefix)),
76+ want: invalidPrefix,
77+ },
78+ {
79+ name: "invalid trailing byte is preserved",
80+ value: string([]byte{'a', 0xff, 0x80}) + "😀",
81+ maxBytes: 3,
82+ want: string([]byte{'a', 0xff, 0x80}),
83+ },
84+ }
85+ for _, tt := range tests {
86+ t.Run(tt.name, func(t *testing.T) {
87+ if got := truncateUTF8(tt.value, tt.maxBytes); got != tt.want {
88+ t.Fatalf("truncateUTF8() = %q (%x), want %q (%x)", got, got, tt.want, tt.want)
89+ }
90+ })
91+ }
92+}
93+
50 func TestDecodeTurnResultEventValidatesIdentityAndStatus(t *testing.T) {94 func TestDecodeTurnResultEventValidatesIdentityAndStatus(t *testing.T) {
51 invalid := NewEvent(EventTurnResult, map[string]any{95 invalid := NewEvent(EventTurnResult, map[string]any{
52 "status": "unknown",96 "status": "unknown",
@@ -105,3 +149,160 @@ func TestTurnResultReferencesAndPersistence(t *testing.T) {
105 t.Fatalf("result permissions = %o, want 600", info.Mode().Perm())149 t.Fatalf("result permissions = %o, want 600", info.Mode().Perm())
106 }150 }
107 }151 }
152+
153+func TestWriteTurnResultCleansTemporaryFileAfterRenameFailure(t *testing.T) {
154+ state := t.TempDir()
155+ if err := os.Mkdir(resultPath(state), 0o700); err != nil {
156+ t.Fatal(err)
157+ }
158+ result := &TurnResult{AgentID: "a", TurnID: "t", Status: ResultSucceeded}
159+ if err := writeTurnResult(state, result); err == nil {
160+ t.Fatal("writeTurnResult succeeded when the result path was a directory")
161+ }
162+ if _, err := os.Stat(resultPath(state) + ".tmp"); !os.IsNotExist(err) {
163+ t.Fatalf("temporary result file still exists, stat error = %v", err)
164+ }
165+}
166+
167+func TestCaptureWorkspaceFailureIsDurableAndPrivate(t *testing.T) {
168+ makeAgent := func(stateDir string, capture func() (WorkspaceCapture, error)) *Agent {
169+ return &Agent{ID: "agent-1", stateDir: stateDir, workspaceCapture: capture}
170+ }
171+ assertFailure := func(t *testing.T, supervisor *Supervisor, agent *Agent, stateDir string) {
172+ t.Helper()
173+ supervisor.captureWorkspace(agent)
174+ supervisor.ensureResult(agent, StatusDone, nil)
175+ checkCaptureFailureResult(t, agent, stateDir)
176+ }
177+
178+ t.Run("stat", func(t *testing.T) {
179+ stateDir := filepath.Join(t.TempDir(), "missing")
180+ agent := makeAgent(stateDir, func() (WorkspaceCapture, error) {
181+ t.Fatal("workspace capture should not run when the state directory cannot be stat'ed")
182+ return WorkspaceCapture{}, nil
183+ })
184+ assertFailure(t, &Supervisor{}, agent, stateDir)
185+ })
186+
187+ t.Run("capture", func(t *testing.T) {
188+ stateDir := t.TempDir()
189+ agent := makeAgent(stateDir, func() (WorkspaceCapture, error) {
190+ return WorkspaceCapture{}, errors.New("capture leaked /private/secret")
191+ })
192+ assertFailure(t, &Supervisor{}, agent, stateDir)
193+ })
194+
195+ t.Run("temp write", func(t *testing.T) {
196+ stateDir := t.TempDir()
197+ if err := os.Mkdir(patchPath(stateDir)+".tmp", 0o700); err != nil {
198+ t.Fatal(err)
199+ }
200+ agent := makeAgent(stateDir, func() (WorkspaceCapture, error) {
201+ return WorkspaceCapture{Patch: []byte("patch")}, nil
202+ })
203+ assertFailure(t, &Supervisor{}, agent, stateDir)
204+ })
205+
206+ t.Run("rename", func(t *testing.T) {
207+ stateDir := t.TempDir()
208+ if err := os.Mkdir(patchPath(stateDir), 0o700); err != nil {
209+ t.Fatal(err)
210+ }
211+ agent := makeAgent(stateDir, func() (WorkspaceCapture, error) {
212+ return WorkspaceCapture{Patch: []byte("patch")}, nil
213+ })
214+ assertFailure(t, &Supervisor{}, agent, stateDir)
215+ if _, err := os.Stat(patchPath(stateDir) + ".tmp"); !os.IsNotExist(err) {
216+ t.Fatalf("workspace temporary patch still exists, stat error = %v", err)
217+ }
218+ })
219+}
220+
221+func TestCaptureWorkspaceSuccessPersistsPatch(t *testing.T) {
222+ stateDir := t.TempDir()
223+ agent := &Agent{
224+ ID: "agent-1",
225+ stateDir: stateDir,
226+ workspaceCapture: func() (WorkspaceCapture, error) {
227+ return WorkspaceCapture{Patch: []byte("patch"), ChangedFiles: []string{"file.txt"}}, nil
228+ },
229+ }
230+ (&Supervisor{}).captureWorkspace(agent)
231+
232+ patch, err := os.ReadFile(patchPath(stateDir))
233+ if err != nil {
234+ t.Fatal(err)
235+ }
236+ if string(patch) != "patch" {
237+ t.Fatalf("patch = %q, want %q", patch, "patch")
238+ }
239+ agent.lifecycleMu.Lock()
240+ patchRef := agent.patchRef
241+ changedFiles := append([]string(nil), agent.changedFiles...)
242+ agent.lifecycleMu.Unlock()
243+ if patchRef != PatchRef(agent.ID) || len(changedFiles) != 1 || changedFiles[0] != "file.txt" {
244+ t.Fatalf("capture metadata = ref %q, changed files %#v", patchRef, changedFiles)
245+ }
246+}
247+
248+func checkCaptureFailureResult(t *testing.T, agent *Agent, stateDir string) {
249+ t.Helper()
250+ result := agent.Result()
251+ if result == nil || result.Status != ResultFailed || result.Error == nil {
252+ t.Fatalf("capture failure result = %#v", result)
253+ }
254+ if result.Error.Code != workspaceCaptureErrorCode || result.Error.Message != workspaceCaptureErrorMessage {
255+ t.Fatalf("capture failure error = %#v", result.Error)
256+ }
257+ if strings.Contains(result.Error.Message, stateDir) || strings.Contains(result.Error.Message, "secret") {
258+ t.Fatalf("capture failure leaked private data: %#v", result.Error)
259+ }
260+ durable, err := readTurnResult(stateDir)
261+ if err != nil {
262+ t.Fatal(err)
263+ }
264+ if durable.Error == nil || durable.Error.Code != workspaceCaptureErrorCode || durable.Status != ResultFailed {
265+ t.Fatalf("durable capture failure = %#v", durable)
266+ }
267+}
268+
269+func TestEnsureResultReconcilesChildStatusWithRunnerError(t *testing.T) {
270+ tests := []struct {
271+ name string
272+ status Status
273+ runErr error
274+ wantState TurnStatus
275+ }{
276+ {name: "runner failure", status: StatusFailed, runErr: errors.New("runner failed"), wantState: ResultFailed},
277+ {name: "runner cancellation", status: StatusFailed, runErr: context.Canceled, wantState: ResultCanceled},
278+ {name: "killed", status: StatusKilled, runErr: context.Canceled, wantState: ResultCanceled},
279+ }
280+ for _, tt := range tests {
281+ t.Run(tt.name, func(t *testing.T) {
282+ stateDir := t.TempDir()
283+ agent := &Agent{
284+ ID: "agent-1",
285+ stateDir: stateDir,
286+ result: &TurnResult{
287+ AgentID: "agent-1", TurnID: "turn-1", Status: ResultSucceeded,
288+ },
289+ }
290+ supervisor := &Supervisor{}
291+ supervisor.ensureResult(agent, tt.status, tt.runErr)
292+ result := agent.Result()
293+ if result == nil || result.Status != tt.wantState {
294+ t.Fatalf("result status = %#v, want %q", result, tt.wantState)
295+ }
296+ if result.Error == nil {
297+ t.Fatal("runner error was not recorded")
298+ }
299+ durable, err := readTurnResult(stateDir)
300+ if err != nil {
301+ t.Fatal(err)
302+ }
303+ if durable.Status != tt.wantState {
304+ t.Fatalf("durable result status = %q, want %q", durable.Status, tt.wantState)
305+ }
306+ })
307+ }
308+}
M packages/agent/subagents/runner.go +10 -3
@@ -213,7 +213,6 @@ func (r *execRunner) Run(ctx context.Context, sink Sink) error {
213213 }
214214
215215 cmd := exec.CommandContext(ctx, args[0], args[1:]...)
216- configureWorkerProcess(cmd)
217216 cmd.Dir = r.agent.Dir
218217 cmd.Env = append(workerEnvironment(r.agent.Provider),
219218 "ZOT_SUBAGENT_AGENT_ID="+r.agent.ID,
@@ -251,6 +250,7 @@ func (r *execRunner) Run(ctx context.Context, sink Sink) error {
251250 if err != nil {
252251 return err
253252 }
253+ configureWorkerProcess(cmd, stdout, stderr)
254254
255255 // "spawning" is briefly shown until the first event arrives;
256256 // the child's "spawned" lifecycle event then overwrites it.
@@ -422,13 +422,13 @@ func isBedrockCredentialEnv(name string) bool {
422422
423423 func workerSecretEnvName(name string) bool {
424424 name = strings.ToUpper(strings.TrimSpace(name))
425- for _, suffix := range []string{"_API_KEY", "_OAUTH_TOKEN", "_TOKEN", "_SECRET", "_PASSWORD", "_CREDENTIAL", "_CREDENTIALS"} {
425+ for _, suffix := range []string{"_KEY", "_API_KEY", "_OAUTH_TOKEN", "_TOKEN", "_SECRET", "_PASSWORD", "_CREDENTIAL", "_CREDENTIALS"} {
426426 if strings.HasSuffix(name, suffix) {
427427 return true
428428 }
429429 }
430430 switch name {
431- case "HF_TOKEN", "AWS_BEARER_TOKEN_BEDROCK", "COPILOT_GITHUB_TOKEN", "GITHUB_COPILOT_TOKEN", "GOOGLE_APPLICATION_CREDENTIALS":
431+ case "AWS_ACCESS_KEY_ID", "HF_TOKEN", "AWS_BEARER_TOKEN_BEDROCK", "COPILOT_GITHUB_TOKEN", "GITHUB_COPILOT_TOKEN", "GOOGLE_APPLICATION_CREDENTIALS":
432432 return true
433433 default:
434434 return false
@@ -568,6 +568,13 @@ func updateAgentFromEvent(a *Agent, ev Event) {
568568 a.setProcessState(ProcessExited)
569569 persist = true
570570 case "turn_end":
571+ // Provider/tool-loop turn_end events (for example stop=tool_use)
572+ // do not carry the daemon's prompt step and are not terminal for
573+ // the delegated task. They remain in the event log, but must not
574+ // overwrite the delegated turn state or trigger persistence.
575+ if _, ok := ev.Data["step"].(float64); !ok {
576+ break
577+ }
571578 if message, _ := ev.Data["error"].(string); message != "" {
572579 a.setTurnState(TurnFailed, ev.TurnID)
573580 } else {
@@ -213,7 +213,6 @@ func (r *execRunner) Run(ctx context.Context, sink Sink) error {
213 }213 }
214 214
215 cmd := exec.CommandContext(ctx, args[0], args[1:]...)215 cmd := exec.CommandContext(ctx, args[0], args[1:]...)
216- configureWorkerProcess(cmd)
217 cmd.Dir = r.agent.Dir216 cmd.Dir = r.agent.Dir
218 cmd.Env = append(workerEnvironment(r.agent.Provider),217 cmd.Env = append(workerEnvironment(r.agent.Provider),
219 "ZOT_SUBAGENT_AGENT_ID="+r.agent.ID,218 "ZOT_SUBAGENT_AGENT_ID="+r.agent.ID,
@@ -251,6 +250,7 @@ func (r *execRunner) Run(ctx context.Context, sink Sink) error {
251 if err != nil {250 if err != nil {
252 return err251 return err
253 }252 }
253+ configureWorkerProcess(cmd, stdout, stderr)
254 254
255 // "spawning" is briefly shown until the first event arrives;255 // "spawning" is briefly shown until the first event arrives;
256 // the child's "spawned" lifecycle event then overwrites it.256 // the child's "spawned" lifecycle event then overwrites it.
@@ -422,13 +422,13 @@ func isBedrockCredentialEnv(name string) bool {
422 422
423 func workerSecretEnvName(name string) bool {423 func workerSecretEnvName(name string) bool {
424 name = strings.ToUpper(strings.TrimSpace(name))424 name = strings.ToUpper(strings.TrimSpace(name))
425- for _, suffix := range []string{"_API_KEY", "_OAUTH_TOKEN", "_TOKEN", "_SECRET", "_PASSWORD", "_CREDENTIAL", "_CREDENTIALS"} {425+ for _, suffix := range []string{"_KEY", "_API_KEY", "_OAUTH_TOKEN", "_TOKEN", "_SECRET", "_PASSWORD", "_CREDENTIAL", "_CREDENTIALS"} {
426 if strings.HasSuffix(name, suffix) {426 if strings.HasSuffix(name, suffix) {
427 return true427 return true
428 }428 }
429 }429 }
430 switch name {430 switch name {
431- case "HF_TOKEN", "AWS_BEARER_TOKEN_BEDROCK", "COPILOT_GITHUB_TOKEN", "GITHUB_COPILOT_TOKEN", "GOOGLE_APPLICATION_CREDENTIALS":431+ case "AWS_ACCESS_KEY_ID", "HF_TOKEN", "AWS_BEARER_TOKEN_BEDROCK", "COPILOT_GITHUB_TOKEN", "GITHUB_COPILOT_TOKEN", "GOOGLE_APPLICATION_CREDENTIALS":
432 return true432 return true
433 default:433 default:
434 return false434 return false
@@ -568,6 +568,13 @@ func updateAgentFromEvent(a *Agent, ev Event) {
568 a.setProcessState(ProcessExited)568 a.setProcessState(ProcessExited)
569 persist = true569 persist = true
570 case "turn_end":570 case "turn_end":
571+ // Provider/tool-loop turn_end events (for example stop=tool_use)
572+ // do not carry the daemon's prompt step and are not terminal for
573+ // the delegated task. They remain in the event log, but must not
574+ // overwrite the delegated turn state or trigger persistence.
575+ if _, ok := ev.Data["step"].(float64); !ok {
576+ break
577+ }
571 if message, _ := ev.Data["error"].(string); message != "" {578 if message, _ := ev.Data["error"].(string); message != "" {
572 a.setTurnState(TurnFailed, ev.TurnID)579 a.setTurnState(TurnFailed, ev.TurnID)
573 } else {580 } else {
M packages/agent/subagents/runner_test.go +66 -0
@@ -64,6 +64,8 @@ func TestWorkerEnvironmentRedactsProviderSecrets(t *testing.T) {
6464 t.Setenv("HF_TOKEN", "hf-secret")
6565 t.Setenv("GITHUB_TOKEN", "github-secret")
6666 t.Setenv("CUSTOM_PROVIDER_TOKEN", "token-secret")
67+ t.Setenv("CUSTOM_PROVIDER_KEY", "key-secret")
68+ t.Setenv("AWS_ACCESS_KEY_ID", "aws-access-secret")
6769 t.Setenv("GOOGLE_APPLICATION_CREDENTIALS", "/tmp/service-account.json")
6870 t.Setenv("ZOT_SUBAGENT_TEST_VALUE", "not-secret")
6971 env := strings.Join(workerEnvironment("openai"), "\n")
@@ -73,6 +75,8 @@ func TestWorkerEnvironmentRedactsProviderSecrets(t *testing.T) {
7375 "HF_TOKEN=hf-secret",
7476 "GITHUB_TOKEN=github-secret",
7577 "CUSTOM_PROVIDER_TOKEN=token-secret",
78+ "CUSTOM_PROVIDER_KEY=key-secret",
79+ "AWS_ACCESS_KEY_ID=aws-access-secret",
7680 "GOOGLE_APPLICATION_CREDENTIALS=/tmp/service-account.json",
7781 } {
7882 if strings.Contains(env, secret) {
@@ -88,6 +92,9 @@ func TestWorkerEnvironmentRedactsProviderSecrets(t *testing.T) {
8892 if !strings.Contains(bedrockEnv, "AWS_BEARER_TOKEN_BEDROCK=bedrock-secret") {
8993 t.Fatal("Bedrock ambient credential chain was unexpectedly removed")
9094 }
95+ if !strings.Contains(bedrockEnv, "AWS_ACCESS_KEY_ID=aws-access-secret") {
96+ t.Fatal("Bedrock AWS access-key credential was unexpectedly removed")
97+ }
9198 }
9299
93100 func TestExecRunnerTransfersCredentialOnlyOnStdin(t *testing.T) {
@@ -281,6 +288,40 @@ func TestDefaultChildArgsResumeOmitsTask(t *testing.T) {
281288 }
282289 }
283290
291+func TestUpdateAgentFromEventIgnoresProviderToolLoopTurnEnd(t *testing.T) {
292+ a := &Agent{}
293+ a.setTurnState(TurnRunning, "turn-1")
294+ persisted := 0
295+ a.persistFn = func(*Agent) { persisted++ }
296+
297+ updateAgentFromEvent(a, NewEvent("turn_end", map[string]any{"stop": "tool_use"}))
298+
299+ if got := a.TurnState(); got != TurnRunning {
300+ t.Fatalf("turn state = %q, want %q", got, TurnRunning)
301+ }
302+ if got := a.CurrentTurnID(); got != "turn-1" {
303+ t.Fatalf("turn ID = %q, want turn-1", got)
304+ }
305+ if persisted != 0 {
306+ t.Fatalf("persist called %d times for provider/tool-loop turn_end, want 0", persisted)
307+ }
308+}
309+
310+func TestUpdateAgentFromEventPersistsTerminalTurnEndWithStep(t *testing.T) {
311+ a := &Agent{}
312+ persisted := 0
313+ a.persistFn = func(*Agent) { persisted++ }
314+
315+ updateAgentFromEvent(a, NewEvent("turn_end", map[string]any{"step": float64(2), "error": "boom"}))
316+
317+ if got := a.TurnState(); got != TurnFailed {
318+ t.Fatalf("turn state = %q, want %q", got, TurnFailed)
319+ }
320+ if persisted != 1 {
321+ t.Fatalf("persist called %d times for terminal turn_end, want 1", persisted)
322+ }
323+}
324+
284325 func TestNotifyPromptTurnEndIgnoresProviderToolLoopTurnEnd(t *testing.T) {
285326 called := make(chan struct{}, 1)
286327 a := &Agent{}
@@ -320,6 +361,31 @@ func TestNotifyPromptTurnEndFiresForDaemonPromptCompletion(t *testing.T) {
320361 }
321362 }
322363
364+func TestSetOnTurnEndReplaysPendingNoticesInArrivalOrder(t *testing.T) {
365+ a := &Agent{}
366+ notifyPromptTurnEnd(a, NewEvent("turn_end", map[string]any{"step": float64(1)}))
367+ notifyPromptTurnEnd(a, NewEvent("turn_end", map[string]any{"step": float64(2), "error": "boom"}))
368+
369+ type notice struct {
370+ step int
371+ err string
372+ }
373+ var got []notice
374+ a.SetOnTurnEnd(func(step int, errMsg string) {
375+ got = append(got, notice{step: step, err: errMsg})
376+ })
377+
378+ want := []notice{{step: 1}, {step: 2, err: "boom"}}
379+ if len(got) != len(want) {
380+ t.Fatalf("replayed %d notices, want %d: %+v", len(got), len(want), got)
381+ }
382+ for i := range want {
383+ if got[i] != want[i] {
384+ t.Errorf("replayed notice %d = %+v, want %+v", i, got[i], want[i])
385+ }
386+ }
387+}
388+
323389 func indexOf(xs []string, want string) int {
324390 for i, x := range xs {
325391 if x == want {
@@ -64,6 +64,8 @@ func TestWorkerEnvironmentRedactsProviderSecrets(t *testing.T) {
64 t.Setenv("HF_TOKEN", "hf-secret")64 t.Setenv("HF_TOKEN", "hf-secret")
65 t.Setenv("GITHUB_TOKEN", "github-secret")65 t.Setenv("GITHUB_TOKEN", "github-secret")
66 t.Setenv("CUSTOM_PROVIDER_TOKEN", "token-secret")66 t.Setenv("CUSTOM_PROVIDER_TOKEN", "token-secret")
67+ t.Setenv("CUSTOM_PROVIDER_KEY", "key-secret")
68+ t.Setenv("AWS_ACCESS_KEY_ID", "aws-access-secret")
67 t.Setenv("GOOGLE_APPLICATION_CREDENTIALS", "/tmp/service-account.json")69 t.Setenv("GOOGLE_APPLICATION_CREDENTIALS", "/tmp/service-account.json")
68 t.Setenv("ZOT_SUBAGENT_TEST_VALUE", "not-secret")70 t.Setenv("ZOT_SUBAGENT_TEST_VALUE", "not-secret")
69 env := strings.Join(workerEnvironment("openai"), "\n")71 env := strings.Join(workerEnvironment("openai"), "\n")
@@ -73,6 +75,8 @@ func TestWorkerEnvironmentRedactsProviderSecrets(t *testing.T) {
73 "HF_TOKEN=hf-secret",75 "HF_TOKEN=hf-secret",
74 "GITHUB_TOKEN=github-secret",76 "GITHUB_TOKEN=github-secret",
75 "CUSTOM_PROVIDER_TOKEN=token-secret",77 "CUSTOM_PROVIDER_TOKEN=token-secret",
78+ "CUSTOM_PROVIDER_KEY=key-secret",
79+ "AWS_ACCESS_KEY_ID=aws-access-secret",
76 "GOOGLE_APPLICATION_CREDENTIALS=/tmp/service-account.json",80 "GOOGLE_APPLICATION_CREDENTIALS=/tmp/service-account.json",
77 } {81 } {
78 if strings.Contains(env, secret) {82 if strings.Contains(env, secret) {
@@ -88,6 +92,9 @@ func TestWorkerEnvironmentRedactsProviderSecrets(t *testing.T) {
88 if !strings.Contains(bedrockEnv, "AWS_BEARER_TOKEN_BEDROCK=bedrock-secret") {92 if !strings.Contains(bedrockEnv, "AWS_BEARER_TOKEN_BEDROCK=bedrock-secret") {
89 t.Fatal("Bedrock ambient credential chain was unexpectedly removed")93 t.Fatal("Bedrock ambient credential chain was unexpectedly removed")
90 }94 }
95+ if !strings.Contains(bedrockEnv, "AWS_ACCESS_KEY_ID=aws-access-secret") {
96+ t.Fatal("Bedrock AWS access-key credential was unexpectedly removed")
97+ }
91 }98 }
92 99
93 func TestExecRunnerTransfersCredentialOnlyOnStdin(t *testing.T) {100 func TestExecRunnerTransfersCredentialOnlyOnStdin(t *testing.T) {
@@ -281,6 +288,40 @@ func TestDefaultChildArgsResumeOmitsTask(t *testing.T) {
281 }288 }
282 }289 }
283 290
291+func TestUpdateAgentFromEventIgnoresProviderToolLoopTurnEnd(t *testing.T) {
292+ a := &Agent{}
293+ a.setTurnState(TurnRunning, "turn-1")
294+ persisted := 0
295+ a.persistFn = func(*Agent) { persisted++ }
296+
297+ updateAgentFromEvent(a, NewEvent("turn_end", map[string]any{"stop": "tool_use"}))
298+
299+ if got := a.TurnState(); got != TurnRunning {
300+ t.Fatalf("turn state = %q, want %q", got, TurnRunning)
301+ }
302+ if got := a.CurrentTurnID(); got != "turn-1" {
303+ t.Fatalf("turn ID = %q, want turn-1", got)
304+ }
305+ if persisted != 0 {
306+ t.Fatalf("persist called %d times for provider/tool-loop turn_end, want 0", persisted)
307+ }
308+}
309+
310+func TestUpdateAgentFromEventPersistsTerminalTurnEndWithStep(t *testing.T) {
311+ a := &Agent{}
312+ persisted := 0
313+ a.persistFn = func(*Agent) { persisted++ }
314+
315+ updateAgentFromEvent(a, NewEvent("turn_end", map[string]any{"step": float64(2), "error": "boom"}))
316+
317+ if got := a.TurnState(); got != TurnFailed {
318+ t.Fatalf("turn state = %q, want %q", got, TurnFailed)
319+ }
320+ if persisted != 1 {
321+ t.Fatalf("persist called %d times for terminal turn_end, want 1", persisted)
322+ }
323+}
324+
284 func TestNotifyPromptTurnEndIgnoresProviderToolLoopTurnEnd(t *testing.T) {325 func TestNotifyPromptTurnEndIgnoresProviderToolLoopTurnEnd(t *testing.T) {
285 called := make(chan struct{}, 1)326 called := make(chan struct{}, 1)
286 a := &Agent{}327 a := &Agent{}
@@ -320,6 +361,31 @@ func TestNotifyPromptTurnEndFiresForDaemonPromptCompletion(t *testing.T) {
320 }361 }
321 }362 }
322 363
364+func TestSetOnTurnEndReplaysPendingNoticesInArrivalOrder(t *testing.T) {
365+ a := &Agent{}
366+ notifyPromptTurnEnd(a, NewEvent("turn_end", map[string]any{"step": float64(1)}))
367+ notifyPromptTurnEnd(a, NewEvent("turn_end", map[string]any{"step": float64(2), "error": "boom"}))
368+
369+ type notice struct {
370+ step int
371+ err string
372+ }
373+ var got []notice
374+ a.SetOnTurnEnd(func(step int, errMsg string) {
375+ got = append(got, notice{step: step, err: errMsg})
376+ })
377+
378+ want := []notice{{step: 1}, {step: 2, err: "boom"}}
379+ if len(got) != len(want) {
380+ t.Fatalf("replayed %d notices, want %d: %+v", len(got), len(want), got)
381+ }
382+ for i := range want {
383+ if got[i] != want[i] {
384+ t.Errorf("replayed notice %d = %+v, want %+v", i, got[i], want[i])
385+ }
386+ }
387+}
388+
323 func indexOf(xs []string, want string) int {389 func indexOf(xs []string, want string) int {
324 for i, x := range xs {390 for i, x := range xs {
325 if x == want {391 if x == want {
M packages/agent/subagents/scheduler.go +1 -1
@@ -35,7 +35,7 @@ func (f *Supervisor) validateSpawnRequest(req SpawnRequest) error {
3535 func (f *Supervisor) validateSpawnOptions(req SpawnRequest) error {
3636 p := f.cfg.Policy
3737 if req.MaxTurns < 0 || req.MaxTurns > p.MaxTurns {
38- return fmt.Errorf("subagents: max_turns must be between 1 and %d", p.MaxTurns)
38+ return fmt.Errorf("subagents: max_turns must be 0 through %d (zero uses the policy default)", p.MaxTurns)
3939 }
4040 if req.Timeout < 0 {
4141 return errorsInvalid("timeout must not be negative")
@@ -35,7 +35,7 @@ func (f *Supervisor) validateSpawnRequest(req SpawnRequest) error {
35 func (f *Supervisor) validateSpawnOptions(req SpawnRequest) error {35 func (f *Supervisor) validateSpawnOptions(req SpawnRequest) error {
36 p := f.cfg.Policy36 p := f.cfg.Policy
37 if req.MaxTurns < 0 || req.MaxTurns > p.MaxTurns {37 if req.MaxTurns < 0 || req.MaxTurns > p.MaxTurns {
38- return fmt.Errorf("subagents: max_turns must be between 1 and %d", p.MaxTurns)38+ return fmt.Errorf("subagents: max_turns must be 0 through %d (zero uses the policy default)", p.MaxTurns)
39 }39 }
40 if req.Timeout < 0 {40 if req.Timeout < 0 {
41 return errorsInvalid("timeout must not be negative")41 return errorsInvalid("timeout must not be negative")
M packages/agent/subagents/supervisor.go +194 -56
@@ -170,9 +170,9 @@ func New(cfg Config) *Supervisor {
170170 }
171171 }
172172
173-// SetRepoRoot updates the shared working directory for subsequently
174-// spawned agents. Existing agents keep the directory they were started
175-// with; callers use this when the host session changes cwd.
173+// workerContext derives a child worker context from the supervisor lifetime.
174+// A positive timeout limits the worker lifetime; otherwise it is cancellable
175+// only when the supervisor shuts down or the agent is stopped.
176176 func (f *Supervisor) workerContext(timeout time.Duration) (context.Context, context.CancelFunc) {
177177 base := f.lifetimeCtx
178178 if base == nil {
@@ -184,6 +184,9 @@ func (f *Supervisor) workerContext(timeout time.Duration) (context.Context, cont
184184 return context.WithCancel(base)
185185 }
186186
187+// SetRepoRoot updates the shared working directory for subsequently
188+// spawned agents. Existing agents keep the directory they were started
189+// with; callers use this when the host session changes cwd.
187190 func (f *Supervisor) SetRepoRoot(root string) {
188191 f.mu.Lock()
189192 defer f.mu.Unlock()
@@ -214,16 +217,18 @@ func (f *Supervisor) FastMode() bool {
214217 return f.cfg.FastMode
215218 }
216219
217-// SetProviderSettings updates the effective connection settings used by
218-// subsequently spawned children. Existing agents keep their persisted values
219-// so a model/provider switch cannot silently change a running or resumed
220-// worker's endpoint.
220+// SetProvider updates the provider used by subsequently spawned children.
221+// Existing agents keep their persisted provider selection.
221222 func (f *Supervisor) SetProvider(provider string) {
222223 f.mu.Lock()
223224 f.cfg.Provider = strings.TrimSpace(provider)
224225 f.mu.Unlock()
225226 }
226227
228+// SetProviderSettings updates the effective connection settings used by
229+// subsequently spawned children. Existing agents keep their persisted values
230+// so a model/provider switch cannot silently change a running or resumed
231+// worker's endpoint.
227232 func (f *Supervisor) SetProviderSettings(baseURL string, insecureTLS bool) {
228233 f.mu.Lock()
229234 f.cfg.BaseURL = strings.TrimSpace(baseURL)
@@ -681,11 +686,48 @@ func (f *Supervisor) Get(id string) *Agent {
681686 return nil
682687 }
683688
689+var errDetachedStopTimeout = errors.New("detached worker stop timeout")
690+
691+const detachedStopPollInterval = 20 * time.Millisecond
692+
693+// waitForDetachedWorker waits for the worker inbox to disappear, returning a
694+// sentinel when the grace period expires. Polling is interruptible so a
695+// caller's context is not made to wait for the next fixed sleep interval.
696+func waitForDetachedWorker(ctx context.Context, path string, timeout time.Duration) error {
697+ if ctx == nil {
698+ ctx = context.Background()
699+ }
700+ timer := time.NewTimer(timeout)
701+ defer timer.Stop()
702+ ticker := time.NewTicker(detachedStopPollInterval)
703+ defer ticker.Stop()
704+ for {
705+ if err := ctx.Err(); err != nil {
706+ return err
707+ }
708+ if !inboxLive(path) {
709+ return nil
710+ }
711+ select {
712+ case <-ctx.Done():
713+ return ctx.Err()
714+ case <-timer.C:
715+ if !inboxLive(path) {
716+ return nil
717+ }
718+ return errDetachedStopTimeout
719+ case <-ticker.C:
720+ }
721+ }
722+}
723+
684724 // Stop cancels the agent's context. The Runner should observe the
685725 // cancellation and return promptly; the goroutine then finalises the
686726 // agent in StatusKilled. Also closes the supervisor-side inbox handle
687727 // so any pending send retries fail fast instead of dialing a
688-// socket about to be unlinked.
728+// socket about to be unlinked. Stop uses a background context; callers
729+// that need cancellation while waiting for a detached worker should use
730+// StopContext.
689731 //
690732 // Stop is a no-op for any agent that's not in a live runnable state
691733 // — Done / Failed / Killed (already finalised). Reloaded Detached agents
@@ -693,10 +735,24 @@ func (f *Supervisor) Get(id string) *Agent {
693735 // shutdown command through the durable inbox instead of pretending no
694736 // process exists.
695737 func (f *Supervisor) Stop(id string) error {
738+ return f.stop(context.Background(), id)
739+}
740+
741+// StopContext is Stop with a context that can interrupt detached-worker
742+// shutdown and its graceful and force-stop waits. The context is not held
743+// under the supervisor operation lock while those waits run.
744+func (f *Supervisor) StopContext(ctx context.Context, id string) error {
745+ return f.stop(ctx, id)
746+}
747+
748+func (f *Supervisor) stop(ctx context.Context, id string) error {
749+ if ctx == nil {
750+ ctx = context.Background()
751+ }
696752 f.operationMu.Lock()
697- defer f.operationMu.Unlock()
698753 a := f.Get(id)
699754 if a == nil {
755+ f.operationMu.Unlock()
700756 return fmt.Errorf("no such agent %q", id)
701757 }
702758
@@ -711,6 +767,7 @@ func (f *Supervisor) Stop(id string) error {
711767 if status == StatusDone || status == StatusFailed || status == StatusKilled {
712768 a.mu.Unlock()
713769 f.mu.Unlock()
770+ f.operationMu.Unlock()
714771 return nil
715772 }
716773 if status == StatusDetached {
@@ -719,46 +776,10 @@ func (f *Supervisor) Stop(id string) error {
719776 pid := a.ProcessPIDValue()
720777 a.mu.Unlock()
721778 f.mu.Unlock()
722- if inbox == nil || !inboxLive(path) {
723- return nil
724- }
725- err := inbox.SendCommand(NewCommand(CommandAgentShutdown, a.ID, a.CurrentTurnID(), AgentShutdownPayload{}))
726- if err != nil {
727- return err
728- }
729- a.setActivity("shutdown requested")
730- grace := f.cfg.Policy.CancelGracePeriod
731- if grace <= 0 {
732- grace = 10 * time.Second
733- }
734- deadline := time.Now().Add(grace)
735- for inboxLive(path) {
736- if time.Now().After(deadline) {
737- if pid <= 0 {
738- return fmt.Errorf("agent %s live worker did not stop within %s and has no recorded pid", a.ID, grace)
739- }
740- if killErr := forceKillProcess(pid); killErr != nil {
741- return fmt.Errorf("agent %s force-stop: %w", a.ID, killErr)
742- }
743- forceDeadline := time.Now().Add(time.Second)
744- for inboxLive(path) && time.Now().Before(forceDeadline) {
745- time.Sleep(20 * time.Millisecond)
746- }
747- if inboxLive(path) {
748- return fmt.Errorf("agent %s live worker did not stop after force-stop", a.ID)
749- }
750- }
751- time.Sleep(20 * time.Millisecond)
752- }
753- a.mu.Lock()
754- a.status = StatusKilled
755- a.activity = "killed"
756- a.mu.Unlock()
757- a.setProcessState(ProcessKilled)
758- a.setTurnState(TurnCanceled, a.CurrentTurnID())
759- a.setProcessPID(0)
760- f.persistAgent(a)
761- return nil
779+ // The detached worker may take the full grace period to exit. Do not
780+ // serialize unrelated supervisor operations for that entire wait.
781+ f.operationMu.Unlock()
782+ return f.stopDetached(ctx, a, path, inbox, pid)
762783 }
763784 a.status = StatusKilled
764785 a.activity = "canceling"
@@ -773,6 +794,7 @@ func (f *Supervisor) Stop(id string) error {
773794 }
774795 a.mu.Unlock()
775796 f.mu.Unlock()
797+ f.operationMu.Unlock()
776798 a.setTurnState(TurnCanceling, a.CurrentTurnID())
777799
778800 if status == StatusPending {
@@ -790,12 +812,21 @@ func (f *Supervisor) Stop(id string) error {
790812 _ = a.releaseLease()
791813 a.closeDone()
792814 f.schedule()
815+ if err := ctx.Err(); err != nil {
816+ return err
817+ }
793818 return nil
794819 }
795820
796821 // Ask a live worker to shut down cleanly first. If the socket is not
797822 // ready, fall back immediately; a forceful context cancellation is
798823 // safer than leaving a supervisor entry running forever.
824+ if err := ctx.Err(); err != nil {
825+ if a.cancel != nil {
826+ a.cancel()
827+ }
828+ return err
829+ }
799830 graceful := false
800831 if a.inbox != nil {
801832 err := a.inbox.SendCommand(NewCommand(CommandAgentShutdown, a.ID, a.CurrentTurnID(), AgentShutdownPayload{}))
@@ -816,6 +847,13 @@ func (f *Supervisor) Stop(id string) error {
816847 defer timer.Stop()
817848 select {
818849 case <-a.done:
850+ case <-ctx.Done():
851+ if a.cancel != nil {
852+ a.cancel()
853+ }
854+ if a.inbox != nil {
855+ _ = a.inbox.Close()
856+ }
819857 case <-timer.C:
820858 if a.cancel != nil {
821859 a.cancel()
@@ -826,21 +864,120 @@ func (f *Supervisor) Stop(id string) error {
826864 }
827865 }()
828866 }
867+ if err := ctx.Err(); err != nil {
868+ if a.cancel != nil {
869+ a.cancel()
870+ }
871+ if a.inbox != nil {
872+ _ = a.inbox.Close()
873+ }
874+ return err
875+ }
876+ return nil
877+}
878+
879+func (f *Supervisor) stopDetached(ctx context.Context, a *Agent, path string, inbox *Inbox, pid int) error {
880+ if inbox == nil {
881+ return nil
882+ }
883+ if err := ctx.Err(); err != nil {
884+ return err
885+ }
886+ if !inboxLive(path) {
887+ return nil
888+ }
889+ if err := ctx.Err(); err != nil {
890+ return err
891+ }
892+ if err := inbox.SendCommand(NewCommand(CommandAgentShutdown, a.ID, a.CurrentTurnID(), AgentShutdownPayload{})); err != nil {
893+ if ctxErr := ctx.Err(); ctxErr != nil {
894+ return ctxErr
895+ }
896+ return err
897+ }
898+ a.setActivity("shutdown requested")
899+ grace := f.cfg.Policy.CancelGracePeriod
900+ if grace <= 0 {
901+ grace = 10 * time.Second
902+ }
903+ waitErr := waitForDetachedWorker(ctx, path, grace)
904+ if waitErr != nil && !errors.Is(waitErr, errDetachedStopTimeout) {
905+ return waitErr
906+ }
907+ if waitErr == nil {
908+ f.markDetachedKilled(a)
909+ return nil
910+ }
911+ if err := ctx.Err(); err != nil {
912+ return err
913+ }
914+ if pid <= 0 {
915+ return fmt.Errorf("agent %s live worker did not stop within %s and has no recorded pid", a.ID, grace)
916+ }
917+ if killErr := forceKillProcess(pid); killErr != nil {
918+ return fmt.Errorf("agent %s force-stop: %w", a.ID, killErr)
919+ }
920+ if waitErr := waitForDetachedWorker(ctx, path, time.Second); waitErr != nil {
921+ if errors.Is(waitErr, errDetachedStopTimeout) {
922+ return fmt.Errorf("agent %s live worker did not stop after force-stop", a.ID)
923+ }
924+ return waitErr
925+ }
926+ f.markDetachedKilled(a)
829927 return nil
830928 }
831929
930+func (f *Supervisor) markDetachedKilled(a *Agent) {
931+ f.operationMu.Lock()
932+ defer f.operationMu.Unlock()
933+ a.mu.Lock()
934+ a.status = StatusKilled
935+ a.activity = "killed"
936+ a.mu.Unlock()
937+ a.setProcessState(ProcessKilled)
938+ a.setTurnState(TurnCanceled, a.CurrentTurnID())
939+ a.setProcessPID(0)
940+ f.persistAgent(a)
941+}
942+
832943 // StopAll cancels every running agent. Used on shutdown.
833944 func (f *Supervisor) StopAll() {
945+ _ = f.stopAll(context.Background())
946+}
947+
948+// StopAllContext is the context-aware shutdown variant. It returns the
949+// context error when cancellation interrupts a detached-worker wait.
950+func (f *Supervisor) StopAllContext(ctx context.Context) error {
951+ return f.stopAll(ctx)
952+}
953+
954+func (f *Supervisor) stopAll(ctx context.Context) error {
955+ if ctx == nil {
956+ ctx = context.Background()
957+ }
834958 agents := f.List()
959+ var firstErr error
835960 for _, a := range agents {
836- _ = f.Stop(a.ID)
961+ if err := f.stop(ctx, a.ID); err != nil && firstErr == nil {
962+ firstErr = err
963+ }
837964 }
965+ defer func() {
966+ if f.lifetimeCancel != nil {
967+ f.lifetimeCancel()
968+ }
969+ }()
838970 for _, a := range agents {
839- a.Wait()
840- }
841- if f.lifetimeCancel != nil {
842- f.lifetimeCancel()
971+ select {
972+ case <-a.done:
973+ case <-ctx.Done():
974+ if firstErr == nil {
975+ firstErr = ctx.Err()
976+ }
977+ return firstErr
978+ }
843979 }
980+ return firstErr
844981 }
845982
846983 // Remove tears down the per-agent state for a terminated agent. It
@@ -1042,13 +1179,14 @@ func (s agentSink) userMessage(text string) { s.a.appendUserMessage(text) }
10421179 func (s agentSink) assistantMessage(text string) { s.a.appendAssistantMessage(text) }
10431180
10441181 func truncate(s string, n int) string {
1045- if len(s) <= n {
1182+ runes := []rune(s)
1183+ if len(runes) <= n {
10461184 return s
10471185 }
10481186 if n <= 3 {
10491187 return strings.Repeat(".", n)
10501188 }
1051- return s[:n-3] + "..."
1189+ return string(runes[:n-3]) + "..."
10521190 }
10531191
10541192 func lastN(lines []string, n int) []string {
@@ -170,9 +170,9 @@ func New(cfg Config) *Supervisor {
170 }170 }
171 }171 }
172 172
173-// SetRepoRoot updates the shared working directory for subsequently173+// workerContext derives a child worker context from the supervisor lifetime.
174-// spawned agents. Existing agents keep the directory they were started174+// A positive timeout limits the worker lifetime; otherwise it is cancellable
175-// with; callers use this when the host session changes cwd.175+// only when the supervisor shuts down or the agent is stopped.
176 func (f *Supervisor) workerContext(timeout time.Duration) (context.Context, context.CancelFunc) {176 func (f *Supervisor) workerContext(timeout time.Duration) (context.Context, context.CancelFunc) {
177 base := f.lifetimeCtx177 base := f.lifetimeCtx
178 if base == nil {178 if base == nil {
@@ -184,6 +184,9 @@ func (f *Supervisor) workerContext(timeout time.Duration) (context.Context, cont
184 return context.WithCancel(base)184 return context.WithCancel(base)
185 }185 }
186 186
187+// SetRepoRoot updates the shared working directory for subsequently
188+// spawned agents. Existing agents keep the directory they were started
189+// with; callers use this when the host session changes cwd.
187 func (f *Supervisor) SetRepoRoot(root string) {190 func (f *Supervisor) SetRepoRoot(root string) {
188 f.mu.Lock()191 f.mu.Lock()
189 defer f.mu.Unlock()192 defer f.mu.Unlock()
@@ -214,16 +217,18 @@ func (f *Supervisor) FastMode() bool {
214 return f.cfg.FastMode217 return f.cfg.FastMode
215 }218 }
216 219
217-// SetProviderSettings updates the effective connection settings used by220+// SetProvider updates the provider used by subsequently spawned children.
218-// subsequently spawned children. Existing agents keep their persisted values221+// Existing agents keep their persisted provider selection.
219-// so a model/provider switch cannot silently change a running or resumed
220-// worker's endpoint.
221 func (f *Supervisor) SetProvider(provider string) {222 func (f *Supervisor) SetProvider(provider string) {
222 f.mu.Lock()223 f.mu.Lock()
223 f.cfg.Provider = strings.TrimSpace(provider)224 f.cfg.Provider = strings.TrimSpace(provider)
224 f.mu.Unlock()225 f.mu.Unlock()
225 }226 }
226 227
228+// SetProviderSettings updates the effective connection settings used by
229+// subsequently spawned children. Existing agents keep their persisted values
230+// so a model/provider switch cannot silently change a running or resumed
231+// worker's endpoint.
227 func (f *Supervisor) SetProviderSettings(baseURL string, insecureTLS bool) {232 func (f *Supervisor) SetProviderSettings(baseURL string, insecureTLS bool) {
228 f.mu.Lock()233 f.mu.Lock()
229 f.cfg.BaseURL = strings.TrimSpace(baseURL)234 f.cfg.BaseURL = strings.TrimSpace(baseURL)
@@ -681,11 +686,48 @@ func (f *Supervisor) Get(id string) *Agent {
681 return nil686 return nil
682 }687 }
683 688
689+var errDetachedStopTimeout = errors.New("detached worker stop timeout")
690+
691+const detachedStopPollInterval = 20 * time.Millisecond
692+
693+// waitForDetachedWorker waits for the worker inbox to disappear, returning a
694+// sentinel when the grace period expires. Polling is interruptible so a
695+// caller's context is not made to wait for the next fixed sleep interval.
696+func waitForDetachedWorker(ctx context.Context, path string, timeout time.Duration) error {
697+ if ctx == nil {
698+ ctx = context.Background()
699+ }
700+ timer := time.NewTimer(timeout)
701+ defer timer.Stop()
702+ ticker := time.NewTicker(detachedStopPollInterval)
703+ defer ticker.Stop()
704+ for {
705+ if err := ctx.Err(); err != nil {
706+ return err
707+ }
708+ if !inboxLive(path) {
709+ return nil
710+ }
711+ select {
712+ case <-ctx.Done():
713+ return ctx.Err()
714+ case <-timer.C:
715+ if !inboxLive(path) {
716+ return nil
717+ }
718+ return errDetachedStopTimeout
719+ case <-ticker.C:
720+ }
721+ }
722+}
723+
684 // Stop cancels the agent's context. The Runner should observe the724 // Stop cancels the agent's context. The Runner should observe the
685 // cancellation and return promptly; the goroutine then finalises the725 // cancellation and return promptly; the goroutine then finalises the
686 // agent in StatusKilled. Also closes the supervisor-side inbox handle726 // agent in StatusKilled. Also closes the supervisor-side inbox handle
687 // so any pending send retries fail fast instead of dialing a727 // so any pending send retries fail fast instead of dialing a
688-// socket about to be unlinked.728+// socket about to be unlinked. Stop uses a background context; callers
729+// that need cancellation while waiting for a detached worker should use
730+// StopContext.
689 //731 //
690 // Stop is a no-op for any agent that's not in a live runnable state732 // Stop is a no-op for any agent that's not in a live runnable state
691 // — Done / Failed / Killed (already finalised). Reloaded Detached agents733 // — Done / Failed / Killed (already finalised). Reloaded Detached agents
@@ -693,10 +735,24 @@ func (f *Supervisor) Get(id string) *Agent {
693 // shutdown command through the durable inbox instead of pretending no735 // shutdown command through the durable inbox instead of pretending no
694 // process exists.736 // process exists.
695 func (f *Supervisor) Stop(id string) error {737 func (f *Supervisor) Stop(id string) error {
738+ return f.stop(context.Background(), id)
739+}
740+
741+// StopContext is Stop with a context that can interrupt detached-worker
742+// shutdown and its graceful and force-stop waits. The context is not held
743+// under the supervisor operation lock while those waits run.
744+func (f *Supervisor) StopContext(ctx context.Context, id string) error {
745+ return f.stop(ctx, id)
746+}
747+
748+func (f *Supervisor) stop(ctx context.Context, id string) error {
749+ if ctx == nil {
750+ ctx = context.Background()
751+ }
696 f.operationMu.Lock()752 f.operationMu.Lock()
697- defer f.operationMu.Unlock()
698 a := f.Get(id)753 a := f.Get(id)
699 if a == nil {754 if a == nil {
755+ f.operationMu.Unlock()
700 return fmt.Errorf("no such agent %q", id)756 return fmt.Errorf("no such agent %q", id)
701 }757 }
702 758
@@ -711,6 +767,7 @@ func (f *Supervisor) Stop(id string) error {
711 if status == StatusDone || status == StatusFailed || status == StatusKilled {767 if status == StatusDone || status == StatusFailed || status == StatusKilled {
712 a.mu.Unlock()768 a.mu.Unlock()
713 f.mu.Unlock()769 f.mu.Unlock()
770+ f.operationMu.Unlock()
714 return nil771 return nil
715 }772 }
716 if status == StatusDetached {773 if status == StatusDetached {
@@ -719,46 +776,10 @@ func (f *Supervisor) Stop(id string) error {
719 pid := a.ProcessPIDValue()776 pid := a.ProcessPIDValue()
720 a.mu.Unlock()777 a.mu.Unlock()
721 f.mu.Unlock()778 f.mu.Unlock()
722- if inbox == nil || !inboxLive(path) {779+ // The detached worker may take the full grace period to exit. Do not
723- return nil780+ // serialize unrelated supervisor operations for that entire wait.
724- }781+ f.operationMu.Unlock()
725- err := inbox.SendCommand(NewCommand(CommandAgentShutdown, a.ID, a.CurrentTurnID(), AgentShutdownPayload{}))782+ return f.stopDetached(ctx, a, path, inbox, pid)
726- if err != nil {
727- return err
728- }
729- a.setActivity("shutdown requested")
730- grace := f.cfg.Policy.CancelGracePeriod
731- if grace <= 0 {
732- grace = 10 * time.Second
733- }
734- deadline := time.Now().Add(grace)
735- for inboxLive(path) {
736- if time.Now().After(deadline) {
737- if pid <= 0 {
738- return fmt.Errorf("agent %s live worker did not stop within %s and has no recorded pid", a.ID, grace)
739- }
740- if killErr := forceKillProcess(pid); killErr != nil {
741- return fmt.Errorf("agent %s force-stop: %w", a.ID, killErr)
742- }
743- forceDeadline := time.Now().Add(time.Second)
744- for inboxLive(path) && time.Now().Before(forceDeadline) {
745- time.Sleep(20 * time.Millisecond)
746- }
747- if inboxLive(path) {
748- return fmt.Errorf("agent %s live worker did not stop after force-stop", a.ID)
749- }
750- }
751- time.Sleep(20 * time.Millisecond)
752- }
753- a.mu.Lock()
754- a.status = StatusKilled
755- a.activity = "killed"
756- a.mu.Unlock()
757- a.setProcessState(ProcessKilled)
758- a.setTurnState(TurnCanceled, a.CurrentTurnID())
759- a.setProcessPID(0)
760- f.persistAgent(a)
761- return nil
762 }783 }
763 a.status = StatusKilled784 a.status = StatusKilled
764 a.activity = "canceling"785 a.activity = "canceling"
@@ -773,6 +794,7 @@ func (f *Supervisor) Stop(id string) error {
773 }794 }
774 a.mu.Unlock()795 a.mu.Unlock()
775 f.mu.Unlock()796 f.mu.Unlock()
797+ f.operationMu.Unlock()
776 a.setTurnState(TurnCanceling, a.CurrentTurnID())798 a.setTurnState(TurnCanceling, a.CurrentTurnID())
777 799
778 if status == StatusPending {800 if status == StatusPending {
@@ -790,12 +812,21 @@ func (f *Supervisor) Stop(id string) error {
790 _ = a.releaseLease()812 _ = a.releaseLease()
791 a.closeDone()813 a.closeDone()
792 f.schedule()814 f.schedule()
815+ if err := ctx.Err(); err != nil {
816+ return err
817+ }
793 return nil818 return nil
794 }819 }
795 820
796 // Ask a live worker to shut down cleanly first. If the socket is not821 // Ask a live worker to shut down cleanly first. If the socket is not
797 // ready, fall back immediately; a forceful context cancellation is822 // ready, fall back immediately; a forceful context cancellation is
798 // safer than leaving a supervisor entry running forever.823 // safer than leaving a supervisor entry running forever.
824+ if err := ctx.Err(); err != nil {
825+ if a.cancel != nil {
826+ a.cancel()
827+ }
828+ return err
829+ }
799 graceful := false830 graceful := false
800 if a.inbox != nil {831 if a.inbox != nil {
801 err := a.inbox.SendCommand(NewCommand(CommandAgentShutdown, a.ID, a.CurrentTurnID(), AgentShutdownPayload{}))832 err := a.inbox.SendCommand(NewCommand(CommandAgentShutdown, a.ID, a.CurrentTurnID(), AgentShutdownPayload{}))
@@ -816,6 +847,13 @@ func (f *Supervisor) Stop(id string) error {
816 defer timer.Stop()847 defer timer.Stop()
817 select {848 select {
818 case <-a.done:849 case <-a.done:
850+ case <-ctx.Done():
851+ if a.cancel != nil {
852+ a.cancel()
853+ }
854+ if a.inbox != nil {
855+ _ = a.inbox.Close()
856+ }
819 case <-timer.C:857 case <-timer.C:
820 if a.cancel != nil {858 if a.cancel != nil {
821 a.cancel()859 a.cancel()
@@ -826,21 +864,120 @@ func (f *Supervisor) Stop(id string) error {
826 }864 }
827 }()865 }()
828 }866 }
867+ if err := ctx.Err(); err != nil {
868+ if a.cancel != nil {
869+ a.cancel()
870+ }
871+ if a.inbox != nil {
872+ _ = a.inbox.Close()
873+ }
874+ return err
875+ }
876+ return nil
877+}
878+
879+func (f *Supervisor) stopDetached(ctx context.Context, a *Agent, path string, inbox *Inbox, pid int) error {
880+ if inbox == nil {
881+ return nil
882+ }
883+ if err := ctx.Err(); err != nil {
884+ return err
885+ }
886+ if !inboxLive(path) {
887+ return nil
888+ }
889+ if err := ctx.Err(); err != nil {
890+ return err
891+ }
892+ if err := inbox.SendCommand(NewCommand(CommandAgentShutdown, a.ID, a.CurrentTurnID(), AgentShutdownPayload{})); err != nil {
893+ if ctxErr := ctx.Err(); ctxErr != nil {
894+ return ctxErr
895+ }
896+ return err
897+ }
898+ a.setActivity("shutdown requested")
899+ grace := f.cfg.Policy.CancelGracePeriod
900+ if grace <= 0 {
901+ grace = 10 * time.Second
902+ }
903+ waitErr := waitForDetachedWorker(ctx, path, grace)
904+ if waitErr != nil && !errors.Is(waitErr, errDetachedStopTimeout) {
905+ return waitErr
906+ }
907+ if waitErr == nil {
908+ f.markDetachedKilled(a)
909+ return nil
910+ }
911+ if err := ctx.Err(); err != nil {
912+ return err
913+ }
914+ if pid <= 0 {
915+ return fmt.Errorf("agent %s live worker did not stop within %s and has no recorded pid", a.ID, grace)
916+ }
917+ if killErr := forceKillProcess(pid); killErr != nil {
918+ return fmt.Errorf("agent %s force-stop: %w", a.ID, killErr)
919+ }
920+ if waitErr := waitForDetachedWorker(ctx, path, time.Second); waitErr != nil {
921+ if errors.Is(waitErr, errDetachedStopTimeout) {
922+ return fmt.Errorf("agent %s live worker did not stop after force-stop", a.ID)
923+ }
924+ return waitErr
925+ }
926+ f.markDetachedKilled(a)
829 return nil927 return nil
830 }928 }
831 929
930+func (f *Supervisor) markDetachedKilled(a *Agent) {
931+ f.operationMu.Lock()
932+ defer f.operationMu.Unlock()
933+ a.mu.Lock()
934+ a.status = StatusKilled
935+ a.activity = "killed"
936+ a.mu.Unlock()
937+ a.setProcessState(ProcessKilled)
938+ a.setTurnState(TurnCanceled, a.CurrentTurnID())
939+ a.setProcessPID(0)
940+ f.persistAgent(a)
941+}
942+
832 // StopAll cancels every running agent. Used on shutdown.943 // StopAll cancels every running agent. Used on shutdown.
833 func (f *Supervisor) StopAll() {944 func (f *Supervisor) StopAll() {
945+ _ = f.stopAll(context.Background())
946+}
947+
948+// StopAllContext is the context-aware shutdown variant. It returns the
949+// context error when cancellation interrupts a detached-worker wait.
950+func (f *Supervisor) StopAllContext(ctx context.Context) error {
951+ return f.stopAll(ctx)
952+}
953+
954+func (f *Supervisor) stopAll(ctx context.Context) error {
955+ if ctx == nil {
956+ ctx = context.Background()
957+ }
834 agents := f.List()958 agents := f.List()
959+ var firstErr error
835 for _, a := range agents {960 for _, a := range agents {
836- _ = f.Stop(a.ID)961+ if err := f.stop(ctx, a.ID); err != nil && firstErr == nil {
962+ firstErr = err
963+ }
837 }964 }
965+ defer func() {
966+ if f.lifetimeCancel != nil {
967+ f.lifetimeCancel()
968+ }
969+ }()
838 for _, a := range agents {970 for _, a := range agents {
839- a.Wait()971+ select {
840- }972+ case <-a.done:
841- if f.lifetimeCancel != nil {973+ case <-ctx.Done():
842- f.lifetimeCancel()974+ if firstErr == nil {
975+ firstErr = ctx.Err()
976+ }
977+ return firstErr
978+ }
843 }979 }
980+ return firstErr
844 }981 }
845 982
846 // Remove tears down the per-agent state for a terminated agent. It983 // Remove tears down the per-agent state for a terminated agent. It
@@ -1042,13 +1179,14 @@ func (s agentSink) userMessage(text string) { s.a.appendUserMessage(text) }
1042 func (s agentSink) assistantMessage(text string) { s.a.appendAssistantMessage(text) }1179 func (s agentSink) assistantMessage(text string) { s.a.appendAssistantMessage(text) }
1043 1180
1044 func truncate(s string, n int) string {1181 func truncate(s string, n int) string {
1045- if len(s) <= n {1182+ runes := []rune(s)
1183+ if len(runes) <= n {
1046 return s1184 return s
1047 }1185 }
1048 if n <= 3 {1186 if n <= 3 {
1049 return strings.Repeat(".", n)1187 return strings.Repeat(".", n)
1050 }1188 }
1051- return s[:n-3] + "..."1189+ return string(runes[:n-3]) + "..."
1052 }1190 }
1053 1191
1054 func lastN(lines []string, n int) []string {1192 func lastN(lines []string, n int) []string {
M packages/agent/subagents/supervisor_test.go +75 -4
@@ -3,16 +3,16 @@ package subagents
33 import (
44 "context"
55 "errors"
6+ "path/filepath"
7+ "runtime"
68 "strings"
79 "sync"
810 "testing"
911 "time"
1012 )
1113
12-// newTestSupervisor builds a Supervisor rooted in t.TempDir with the in-memory
13-// and a Runner factory the test controls. Returns the subagent plus a
14-// slice of runners keyed by spawn order so tests can assert they
15-// were actually invoked.
14+// newTestSupervisor builds a Supervisor rooted in t.TempDir and configured
15+// with the Runner factory controlled by the test.
1616 func newTestSupervisor(t *testing.T, mk func(a *Agent) Runner) *Supervisor {
1717 t.Helper()
1818 root := t.TempDir()
@@ -141,6 +141,64 @@ func TestStopCancelsRunningAgent(t *testing.T) {
141141 }
142142 }
143143
144+func TestStopContextCancelsDetachedWaitWithoutHoldingOperationLock(t *testing.T) {
145+ if runtime.GOOS == "windows" {
146+ t.Skip("subagent inbox transport uses Unix-domain sockets")
147+ }
148+ path := filepath.Join(t.TempDir(), "agent.sock")
149+ listener, err := Listen(path)
150+ if err != nil {
151+ t.Fatal(err)
152+ }
153+ defer listener.Close()
154+
155+ f := New(Config{
156+ Root: t.TempDir(),
157+ Policy: SubagentPolicy{
158+ CancelGracePeriod: time.Hour,
159+ },
160+ })
161+ a := &Agent{
162+ ID: "detached-agent",
163+ InboxPath: path,
164+ inbox: NewInbox(path),
165+ status: StatusDetached,
166+ done: make(chan struct{}),
167+ }
168+ defer a.inbox.Close()
169+ close(a.done)
170+ f.mu.Lock()
171+ f.agents[a.ID] = a
172+ f.order = append(f.order, a.ID)
173+ f.mu.Unlock()
174+
175+ ctx, cancel := context.WithCancel(context.Background())
176+ defer cancel()
177+ stopDone := make(chan error, 1)
178+ go func() { stopDone <- f.StopContext(ctx, a.ID) }()
179+
180+ // The listener notification proves Stop reached its shutdown wait. The
181+ // operation lock must already be available while that wait is in progress.
182+ if msg := <-listener.Lines(); msg == "" {
183+ t.Fatal("shutdown command was empty")
184+ }
185+ operationReleased := make(chan struct{})
186+ go func() {
187+ f.operationMu.Lock()
188+ close(operationReleased)
189+ f.operationMu.Unlock()
190+ }()
191+ <-operationReleased
192+
193+ cancel()
194+ if err := <-stopDone; !errors.Is(err, context.Canceled) {
195+ t.Fatalf("StopContext error = %v; want context.Canceled", err)
196+ }
197+ if got := a.Status(); got != StatusDetached {
198+ t.Fatalf("status after canceled stop = %s; want detached", got)
199+ }
200+}
201+
144202 func TestStopAfterDoneIsNoop(t *testing.T) {
145203 f := newTestSupervisor(t, func(a *Agent) Runner {
146204 return RunnerFunc(func(ctx context.Context, sink Sink) error { return nil })
@@ -236,6 +294,19 @@ func TestSnapshotIsStableAcrossAccess(t *testing.T) {
236294 }
237295 }
238296
297+func TestTruncateIsRuneAware(t *testing.T) {
298+ input := "αβγδεζη"
299+ if got := truncate(input, 6); got != "αβγ..." {
300+ t.Fatalf("truncate(%q, 6) = %q; want %q", input, got, "αβγ...")
301+ }
302+ if got := truncate(input, 3); got != "..." {
303+ t.Fatalf("truncate(%q, 3) = %q; want %q", input, got, "...")
304+ }
305+ if got := truncate(input, 2); got != ".." {
306+ t.Fatalf("truncate(%q, 2) = %q; want %q", input, got, "..")
307+ }
308+}
309+
239310 func TestTaskSlug(t *testing.T) {
240311 cases := map[string]string{
241312 "fix the login form": "fix-the-login-form",
@@ -3,16 +3,16 @@ package subagents
3 import (3 import (
4 "context"4 "context"
5 "errors"5 "errors"
6+ "path/filepath"
7+ "runtime"
6 "strings"8 "strings"
7 "sync"9 "sync"
8 "testing"10 "testing"
9 "time"11 "time"
10 )12 )
11 13
12-// newTestSupervisor builds a Supervisor rooted in t.TempDir with the in-memory14+// newTestSupervisor builds a Supervisor rooted in t.TempDir and configured
13-// and a Runner factory the test controls. Returns the subagent plus a15+// with the Runner factory controlled by the test.
14-// slice of runners keyed by spawn order so tests can assert they
15-// were actually invoked.
16 func newTestSupervisor(t *testing.T, mk func(a *Agent) Runner) *Supervisor {16 func newTestSupervisor(t *testing.T, mk func(a *Agent) Runner) *Supervisor {
17 t.Helper()17 t.Helper()
18 root := t.TempDir()18 root := t.TempDir()
@@ -141,6 +141,64 @@ func TestStopCancelsRunningAgent(t *testing.T) {
141 }141 }
142 }142 }
143 143
144+func TestStopContextCancelsDetachedWaitWithoutHoldingOperationLock(t *testing.T) {
145+ if runtime.GOOS == "windows" {
146+ t.Skip("subagent inbox transport uses Unix-domain sockets")
147+ }
148+ path := filepath.Join(t.TempDir(), "agent.sock")
149+ listener, err := Listen(path)
150+ if err != nil {
151+ t.Fatal(err)
152+ }
153+ defer listener.Close()
154+
155+ f := New(Config{
156+ Root: t.TempDir(),
157+ Policy: SubagentPolicy{
158+ CancelGracePeriod: time.Hour,
159+ },
160+ })
161+ a := &Agent{
162+ ID: "detached-agent",
163+ InboxPath: path,
164+ inbox: NewInbox(path),
165+ status: StatusDetached,
166+ done: make(chan struct{}),
167+ }
168+ defer a.inbox.Close()
169+ close(a.done)
170+ f.mu.Lock()
171+ f.agents[a.ID] = a
172+ f.order = append(f.order, a.ID)
173+ f.mu.Unlock()
174+
175+ ctx, cancel := context.WithCancel(context.Background())
176+ defer cancel()
177+ stopDone := make(chan error, 1)
178+ go func() { stopDone <- f.StopContext(ctx, a.ID) }()
179+
180+ // The listener notification proves Stop reached its shutdown wait. The
181+ // operation lock must already be available while that wait is in progress.
182+ if msg := <-listener.Lines(); msg == "" {
183+ t.Fatal("shutdown command was empty")
184+ }
185+ operationReleased := make(chan struct{})
186+ go func() {
187+ f.operationMu.Lock()
188+ close(operationReleased)
189+ f.operationMu.Unlock()
190+ }()
191+ <-operationReleased
192+
193+ cancel()
194+ if err := <-stopDone; !errors.Is(err, context.Canceled) {
195+ t.Fatalf("StopContext error = %v; want context.Canceled", err)
196+ }
197+ if got := a.Status(); got != StatusDetached {
198+ t.Fatalf("status after canceled stop = %s; want detached", got)
199+ }
200+}
201+
144 func TestStopAfterDoneIsNoop(t *testing.T) {202 func TestStopAfterDoneIsNoop(t *testing.T) {
145 f := newTestSupervisor(t, func(a *Agent) Runner {203 f := newTestSupervisor(t, func(a *Agent) Runner {
146 return RunnerFunc(func(ctx context.Context, sink Sink) error { return nil })204 return RunnerFunc(func(ctx context.Context, sink Sink) error { return nil })
@@ -236,6 +294,19 @@ func TestSnapshotIsStableAcrossAccess(t *testing.T) {
236 }294 }
237 }295 }
238 296
297+func TestTruncateIsRuneAware(t *testing.T) {
298+ input := "αβγδεζη"
299+ if got := truncate(input, 6); got != "αβγ..." {
300+ t.Fatalf("truncate(%q, 6) = %q; want %q", input, got, "αβγ...")
301+ }
302+ if got := truncate(input, 3); got != "..." {
303+ t.Fatalf("truncate(%q, 3) = %q; want %q", input, got, "...")
304+ }
305+ if got := truncate(input, 2); got != ".." {
306+ t.Fatalf("truncate(%q, 2) = %q; want %q", input, got, "..")
307+ }
308+}
309+
239 func TestTaskSlug(t *testing.T) {310 func TestTaskSlug(t *testing.T) {
240 cases := map[string]string{311 cases := map[string]string{
241 "fix the login form": "fix-the-login-form",312 "fix the login form": "fix-the-login-form",
M packages/agent/subagents/workspace.go +28 -14
@@ -133,6 +133,7 @@ func (GitWorktreeWorkspace) Prepare(ctx context.Context, req WorkspaceRequest) (
133133 }
134134 if !reuse {
135135 _ = os.RemoveAll(worktreeDir)
136+ _, _ = gitOutput(ctx, actualRoot, "worktree", "prune")
136137 if _, err := gitOutput(ctx, actualRoot, "worktree", "add", "--detach", worktreeDir, base); err != nil {
137138 _ = os.RemoveAll(worktreeDir)
138139 _, _ = gitOutput(ctx, actualRoot, "worktree", "prune")
@@ -220,11 +221,11 @@ func (w *gitWorktreeHandle) Capture(ctx context.Context) (WorkspaceCapture, erro
220221 if ctx == nil {
221222 ctx = context.Background()
222223 }
223- statusBytes, err := gitOutputBytes(ctx, w.dir, "status", "--porcelain")
224+ statusBytes, err := gitOutputBytes(ctx, w.dir, "status", "--porcelain", "-z")
224225 if err != nil {
225226 return WorkspaceCapture{}, fmt.Errorf("subagents worktree status: %w", err)
226227 }
227- changed := changedFilesFromStatus(string(statusBytes))
228+ changed := changedFilesFromStatus(statusBytes)
228229 args := []string{"diff"}
229230 if w.capture == CapturePatch || w.capture == "" {
230231 args = append(args, "--binary")
@@ -317,7 +318,7 @@ func gitUntrackedDiff(ctx context.Context, dir, name string, binary bool) ([]byt
317318 if binary {
318319 args = append(args, "--binary")
319320 }
320- args = append(args, "/dev/null", "--", name)
321+ args = append(args, os.DevNull, "--", name)
321322 cmd := exec.CommandContext(ctx, "git", args...)
322323 var stdout, stderr bytes.Buffer
323324 cmd.Stdout = &stdout
@@ -348,23 +349,36 @@ func splitNUL(data []byte) []string {
348349 return out
349350 }
350351
351-func changedFilesFromStatus(status string) []string {
352+func changedFilesFromStatus(status []byte) []string {
352353 var files []string
353- for _, line := range strings.Split(status, "\n") {
354- line = strings.TrimRight(line, "\r")
355- if len(line) < 4 {
354+ for i := 0; i < len(status); {
355+ end := bytes.IndexByte(status[i:], 0)
356+ if end < 0 {
357+ end = len(status) - i
358+ }
359+ record := status[i : i+end]
360+ i += end + 1
361+ if len(record) < 4 {
356362 continue
357363 }
358- path := strings.TrimSpace(line[3:])
359- if path == "" {
364+ // With `git status --porcelain -z`, rename and copy records contain
365+ // the new path in the status record, followed by the old path as the
366+ // next NUL-delimited field.
367+ path := record[3:]
368+ if len(path) == 0 {
360369 continue
361370 }
362- // Porcelain rename entries are `old -> new`; the new path is the
363- // useful reference to callers.
364- if idx := strings.LastIndex(path, " -> "); idx >= 0 {
365- path = strings.TrimSpace(path[idx+4:])
371+ files = append(files, string(path))
372+ isRename := record[0] == 'R' || record[0] == 'C' || record[1] == 'R' || record[1] == 'C'
373+ if isRename {
374+ if i < len(status) {
375+ if end := bytes.IndexByte(status[i:], 0); end >= 0 {
376+ i += end + 1
377+ } else {
378+ i = len(status)
379+ }
380+ }
366381 }
367- files = append(files, strings.Trim(path, "\""))
368382 }
369383 return files
370384 }
@@ -133,6 +133,7 @@ func (GitWorktreeWorkspace) Prepare(ctx context.Context, req WorkspaceRequest) (
133 }133 }
134 if !reuse {134 if !reuse {
135 _ = os.RemoveAll(worktreeDir)135 _ = os.RemoveAll(worktreeDir)
136+ _, _ = gitOutput(ctx, actualRoot, "worktree", "prune")
136 if _, err := gitOutput(ctx, actualRoot, "worktree", "add", "--detach", worktreeDir, base); err != nil {137 if _, err := gitOutput(ctx, actualRoot, "worktree", "add", "--detach", worktreeDir, base); err != nil {
137 _ = os.RemoveAll(worktreeDir)138 _ = os.RemoveAll(worktreeDir)
138 _, _ = gitOutput(ctx, actualRoot, "worktree", "prune")139 _, _ = gitOutput(ctx, actualRoot, "worktree", "prune")
@@ -220,11 +221,11 @@ func (w *gitWorktreeHandle) Capture(ctx context.Context) (WorkspaceCapture, erro
220 if ctx == nil {221 if ctx == nil {
221 ctx = context.Background()222 ctx = context.Background()
222 }223 }
223- statusBytes, err := gitOutputBytes(ctx, w.dir, "status", "--porcelain")224+ statusBytes, err := gitOutputBytes(ctx, w.dir, "status", "--porcelain", "-z")
224 if err != nil {225 if err != nil {
225 return WorkspaceCapture{}, fmt.Errorf("subagents worktree status: %w", err)226 return WorkspaceCapture{}, fmt.Errorf("subagents worktree status: %w", err)
226 }227 }
227- changed := changedFilesFromStatus(string(statusBytes))228+ changed := changedFilesFromStatus(statusBytes)
228 args := []string{"diff"}229 args := []string{"diff"}
229 if w.capture == CapturePatch || w.capture == "" {230 if w.capture == CapturePatch || w.capture == "" {
230 args = append(args, "--binary")231 args = append(args, "--binary")
@@ -317,7 +318,7 @@ func gitUntrackedDiff(ctx context.Context, dir, name string, binary bool) ([]byt
317 if binary {318 if binary {
318 args = append(args, "--binary")319 args = append(args, "--binary")
319 }320 }
320- args = append(args, "/dev/null", "--", name)321+ args = append(args, os.DevNull, "--", name)
321 cmd := exec.CommandContext(ctx, "git", args...)322 cmd := exec.CommandContext(ctx, "git", args...)
322 var stdout, stderr bytes.Buffer323 var stdout, stderr bytes.Buffer
323 cmd.Stdout = &stdout324 cmd.Stdout = &stdout
@@ -348,23 +349,36 @@ func splitNUL(data []byte) []string {
348 return out349 return out
349 }350 }
350 351
351-func changedFilesFromStatus(status string) []string {352+func changedFilesFromStatus(status []byte) []string {
352 var files []string353 var files []string
353- for _, line := range strings.Split(status, "\n") {354+ for i := 0; i < len(status); {
354- line = strings.TrimRight(line, "\r")355+ end := bytes.IndexByte(status[i:], 0)
355- if len(line) < 4 {356+ if end < 0 {
357+ end = len(status) - i
358+ }
359+ record := status[i : i+end]
360+ i += end + 1
361+ if len(record) < 4 {
356 continue362 continue
357 }363 }
358- path := strings.TrimSpace(line[3:])364+ // With `git status --porcelain -z`, rename and copy records contain
359- if path == "" {365+ // the new path in the status record, followed by the old path as the
366+ // next NUL-delimited field.
367+ path := record[3:]
368+ if len(path) == 0 {
360 continue369 continue
361 }370 }
362- // Porcelain rename entries are `old -> new`; the new path is the371+ files = append(files, string(path))
363- // useful reference to callers.372+ isRename := record[0] == 'R' || record[0] == 'C' || record[1] == 'R' || record[1] == 'C'
364- if idx := strings.LastIndex(path, " -> "); idx >= 0 {373+ if isRename {
365- path = strings.TrimSpace(path[idx+4:])374+ if i < len(status) {
375+ if end := bytes.IndexByte(status[i:], 0); end >= 0 {
376+ i += end + 1
377+ } else {
378+ i = len(status)
379+ }
380+ }
366 }381 }
367- files = append(files, strings.Trim(path, "\""))
368 }382 }
369 return files383 return files
370 }384 }
M packages/agent/subagents/workspace_manager_test.go +1 -4
@@ -13,10 +13,7 @@ func TestSpawnWorktreeCapturesBeforeCleanup(t *testing.T) {
1313 if _, err := exec.LookPath("git"); err != nil {
1414 t.Skip("git is required")
1515 }
16- repo := t.TempDir()
17- runGit(t, repo, "init")
18- runGit(t, repo, "config", "user.email", "test@example.invalid")
19- runGit(t, repo, "config", "user.name", "Test User")
16+ repo := initTestRepo(t)
2017 if err := os.WriteFile(filepath.Join(repo, "main.txt"), []byte("host\n"), 0o600); err != nil {
2118 t.Fatal(err)
2219 }
@@ -13,10 +13,7 @@ func TestSpawnWorktreeCapturesBeforeCleanup(t *testing.T) {
13 if _, err := exec.LookPath("git"); err != nil {13 if _, err := exec.LookPath("git"); err != nil {
14 t.Skip("git is required")14 t.Skip("git is required")
15 }15 }
16- repo := t.TempDir()16+ repo := initTestRepo(t)
17- runGit(t, repo, "init")
18- runGit(t, repo, "config", "user.email", "test@example.invalid")
19- runGit(t, repo, "config", "user.name", "Test User")
20 if err := os.WriteFile(filepath.Join(repo, "main.txt"), []byte("host\n"), 0o600); err != nil {17 if err := os.WriteFile(filepath.Join(repo, "main.txt"), []byte("host\n"), 0o600); err != nil {
21 t.Fatal(err)18 t.Fatal(err)
22 }19 }
M packages/agent/subagents/workspace_test.go +84 -18
@@ -13,15 +13,7 @@ func TestGitWorktreeWorkspaceIsolatesAndCapturesPatch(t *testing.T) {
1313 if _, err := exec.LookPath("git"); err != nil {
1414 t.Skip("git is required")
1515 }
16- repo := t.TempDir()
17- runGit(t, repo, "init")
18- runGit(t, repo, "config", "user.email", "test@example.invalid")
19- runGit(t, repo, "config", "user.name", "Test User")
20- if err := os.WriteFile(filepath.Join(repo, "README.md"), []byte("before\n"), 0o600); err != nil {
21- t.Fatal(err)
22- }
23- runGit(t, repo, "add", "README.md")
24- runGit(t, repo, "commit", "-m", "initial")
16+ repo := initTestRepo(t)
2517
2618 state := t.TempDir()
2719 handle, err := PrepareWorkspace(context.Background(), WorkspaceRequest{
@@ -65,15 +57,7 @@ func TestGitWorktreeWorkspaceReusesDetachedCheckout(t *testing.T) {
6557 if _, err := exec.LookPath("git"); err != nil {
6658 t.Skip("git is required")
6759 }
68- repo := t.TempDir()
69- runGit(t, repo, "init")
70- runGit(t, repo, "config", "user.email", "test@example.invalid")
71- runGit(t, repo, "config", "user.name", "Test User")
72- if err := os.WriteFile(filepath.Join(repo, "README.md"), []byte("before\n"), 0o600); err != nil {
73- t.Fatal(err)
74- }
75- runGit(t, repo, "add", "README.md")
76- runGit(t, repo, "commit", "-m", "initial")
60+ repo := initTestRepo(t)
7761
7862 state := t.TempDir()
7963 first, err := PrepareWorkspace(context.Background(), WorkspaceRequest{
@@ -103,6 +87,50 @@ func TestGitWorktreeWorkspaceReusesDetachedCheckout(t *testing.T) {
10387 }
10488 }
10589
90+func TestGitWorktreeWorkspaceCaptureIncludesUntrackedFile(t *testing.T) {
91+ if _, err := exec.LookPath("git"); err != nil {
92+ t.Skip("git is required")
93+ }
94+ repo := initTestRepo(t)
95+ state := t.TempDir()
96+ handle, err := PrepareWorkspace(context.Background(), WorkspaceRequest{
97+ Mode: WorkspaceWorktree, RepositoryRoot: repo, StateDir: state, Base: "HEAD", Capture: CapturePatch,
98+ })
99+ if err != nil {
100+ t.Fatal(err)
101+ }
102+ defer func() { _ = handle.Cleanup(context.Background()) }()
103+
104+ name := "new file – 日本語.txt"
105+ if err := os.WriteFile(filepath.Join(handle.Dir(), name), []byte("created\n"), 0o600); err != nil {
106+ t.Fatal(err)
107+ }
108+ capture, err := handle.Capture(context.Background())
109+ if err != nil {
110+ t.Fatal(err)
111+ }
112+ if len(capture.ChangedFiles) != 1 || capture.ChangedFiles[0] != name {
113+ t.Fatalf("changed files = %#v, want [%q]", capture.ChangedFiles, name)
114+ }
115+ if !strings.Contains(string(capture.Patch), "created") {
116+ t.Fatalf("patch = %q", capture.Patch)
117+ }
118+}
119+
120+func TestChangedFilesFromStatus(t *testing.T) {
121+ status := []byte(" M path with spaces.txt\x00R renamed – 新.txt\x00original name – старый.txt\x00?? untracked – 文件.txt\x00")
122+ got := changedFilesFromStatus(status)
123+ want := []string{"path with spaces.txt", "renamed – 新.txt", "untracked – 文件.txt"}
124+ if len(got) != len(want) {
125+ t.Fatalf("changed files = %#v, want %#v", got, want)
126+ }
127+ for i := range want {
128+ if got[i] != want[i] {
129+ t.Errorf("changed files[%d] = %q, want %q", i, got[i], want[i])
130+ }
131+ }
132+}
133+
106134 func TestSharedWorkspaceUsesRepositoryRoot(t *testing.T) {
107135 root := t.TempDir()
108136 handle, err := PrepareWorkspace(context.Background(), WorkspaceRequest{Mode: WorkspaceShared, RepositoryRoot: root})
@@ -117,6 +145,44 @@ func TestSharedWorkspaceUsesRepositoryRoot(t *testing.T) {
117145 }
118146 }
119147
148+func initTestRepo(t *testing.T) string {
149+ t.Helper()
150+ home := t.TempDir()
151+ t.Setenv("HOME", home)
152+ t.Setenv("XDG_CONFIG_HOME", filepath.Join(home, "xdg-config"))
153+ t.Setenv("GIT_CONFIG_GLOBAL", os.DevNull)
154+ t.Setenv("GIT_CONFIG_SYSTEM", os.DevNull)
155+ unsetenvForTest(t, "GIT_DIR")
156+ unsetenvForTest(t, "GIT_WORK_TREE")
157+
158+ repo := t.TempDir()
159+ runGit(t, repo, "init")
160+ runGit(t, repo, "config", "user.email", "test@example.invalid")
161+ runGit(t, repo, "config", "user.name", "Test User")
162+ runGit(t, repo, "config", "commit.gpgsign", "false")
163+ if err := os.WriteFile(filepath.Join(repo, "README.md"), []byte("before\n"), 0o600); err != nil {
164+ t.Fatal(err)
165+ }
166+ runGit(t, repo, "add", "README.md")
167+ runGit(t, repo, "commit", "-m", "initial")
168+ return repo
169+}
170+
171+func unsetenvForTest(t *testing.T, name string) {
172+ t.Helper()
173+ value, ok := os.LookupEnv(name)
174+ if err := os.Unsetenv(name); err != nil {
175+ t.Fatal(err)
176+ }
177+ t.Cleanup(func() {
178+ if ok {
179+ _ = os.Setenv(name, value)
180+ } else {
181+ _ = os.Unsetenv(name)
182+ }
183+ })
184+}
185+
120186 func runGit(t *testing.T, dir string, args ...string) {
121187 t.Helper()
122188 cmd := exec.Command("git", append([]string{"-C", dir}, args...)...)
@@ -13,15 +13,7 @@ func TestGitWorktreeWorkspaceIsolatesAndCapturesPatch(t *testing.T) {
13 if _, err := exec.LookPath("git"); err != nil {13 if _, err := exec.LookPath("git"); err != nil {
14 t.Skip("git is required")14 t.Skip("git is required")
15 }15 }
16- repo := t.TempDir()16+ repo := initTestRepo(t)
17- runGit(t, repo, "init")
18- runGit(t, repo, "config", "user.email", "test@example.invalid")
19- runGit(t, repo, "config", "user.name", "Test User")
20- if err := os.WriteFile(filepath.Join(repo, "README.md"), []byte("before\n"), 0o600); err != nil {
21- t.Fatal(err)
22- }
23- runGit(t, repo, "add", "README.md")
24- runGit(t, repo, "commit", "-m", "initial")
25 17
26 state := t.TempDir()18 state := t.TempDir()
27 handle, err := PrepareWorkspace(context.Background(), WorkspaceRequest{19 handle, err := PrepareWorkspace(context.Background(), WorkspaceRequest{
@@ -65,15 +57,7 @@ func TestGitWorktreeWorkspaceReusesDetachedCheckout(t *testing.T) {
65 if _, err := exec.LookPath("git"); err != nil {57 if _, err := exec.LookPath("git"); err != nil {
66 t.Skip("git is required")58 t.Skip("git is required")
67 }59 }
68- repo := t.TempDir()60+ repo := initTestRepo(t)
69- runGit(t, repo, "init")
70- runGit(t, repo, "config", "user.email", "test@example.invalid")
71- runGit(t, repo, "config", "user.name", "Test User")
72- if err := os.WriteFile(filepath.Join(repo, "README.md"), []byte("before\n"), 0o600); err != nil {
73- t.Fatal(err)
74- }
75- runGit(t, repo, "add", "README.md")
76- runGit(t, repo, "commit", "-m", "initial")
77 61
78 state := t.TempDir()62 state := t.TempDir()
79 first, err := PrepareWorkspace(context.Background(), WorkspaceRequest{63 first, err := PrepareWorkspace(context.Background(), WorkspaceRequest{
@@ -103,6 +87,50 @@ func TestGitWorktreeWorkspaceReusesDetachedCheckout(t *testing.T) {
103 }87 }
104 }88 }
105 89
90+func TestGitWorktreeWorkspaceCaptureIncludesUntrackedFile(t *testing.T) {
91+ if _, err := exec.LookPath("git"); err != nil {
92+ t.Skip("git is required")
93+ }
94+ repo := initTestRepo(t)
95+ state := t.TempDir()
96+ handle, err := PrepareWorkspace(context.Background(), WorkspaceRequest{
97+ Mode: WorkspaceWorktree, RepositoryRoot: repo, StateDir: state, Base: "HEAD", Capture: CapturePatch,
98+ })
99+ if err != nil {
100+ t.Fatal(err)
101+ }
102+ defer func() { _ = handle.Cleanup(context.Background()) }()
103+
104+ name := "new file – 日本語.txt"
105+ if err := os.WriteFile(filepath.Join(handle.Dir(), name), []byte("created\n"), 0o600); err != nil {
106+ t.Fatal(err)
107+ }
108+ capture, err := handle.Capture(context.Background())
109+ if err != nil {
110+ t.Fatal(err)
111+ }
112+ if len(capture.ChangedFiles) != 1 || capture.ChangedFiles[0] != name {
113+ t.Fatalf("changed files = %#v, want [%q]", capture.ChangedFiles, name)
114+ }
115+ if !strings.Contains(string(capture.Patch), "created") {
116+ t.Fatalf("patch = %q", capture.Patch)
117+ }
118+}
119+
120+func TestChangedFilesFromStatus(t *testing.T) {
121+ status := []byte(" M path with spaces.txt\x00R renamed – 新.txt\x00original name – старый.txt\x00?? untracked – 文件.txt\x00")
122+ got := changedFilesFromStatus(status)
123+ want := []string{"path with spaces.txt", "renamed – 新.txt", "untracked – 文件.txt"}
124+ if len(got) != len(want) {
125+ t.Fatalf("changed files = %#v, want %#v", got, want)
126+ }
127+ for i := range want {
128+ if got[i] != want[i] {
129+ t.Errorf("changed files[%d] = %q, want %q", i, got[i], want[i])
130+ }
131+ }
132+}
133+
106 func TestSharedWorkspaceUsesRepositoryRoot(t *testing.T) {134 func TestSharedWorkspaceUsesRepositoryRoot(t *testing.T) {
107 root := t.TempDir()135 root := t.TempDir()
108 handle, err := PrepareWorkspace(context.Background(), WorkspaceRequest{Mode: WorkspaceShared, RepositoryRoot: root})136 handle, err := PrepareWorkspace(context.Background(), WorkspaceRequest{Mode: WorkspaceShared, RepositoryRoot: root})
@@ -117,6 +145,44 @@ func TestSharedWorkspaceUsesRepositoryRoot(t *testing.T) {
117 }145 }
118 }146 }
119 147
148+func initTestRepo(t *testing.T) string {
149+ t.Helper()
150+ home := t.TempDir()
151+ t.Setenv("HOME", home)
152+ t.Setenv("XDG_CONFIG_HOME", filepath.Join(home, "xdg-config"))
153+ t.Setenv("GIT_CONFIG_GLOBAL", os.DevNull)
154+ t.Setenv("GIT_CONFIG_SYSTEM", os.DevNull)
155+ unsetenvForTest(t, "GIT_DIR")
156+ unsetenvForTest(t, "GIT_WORK_TREE")
157+
158+ repo := t.TempDir()
159+ runGit(t, repo, "init")
160+ runGit(t, repo, "config", "user.email", "test@example.invalid")
161+ runGit(t, repo, "config", "user.name", "Test User")
162+ runGit(t, repo, "config", "commit.gpgsign", "false")
163+ if err := os.WriteFile(filepath.Join(repo, "README.md"), []byte("before\n"), 0o600); err != nil {
164+ t.Fatal(err)
165+ }
166+ runGit(t, repo, "add", "README.md")
167+ runGit(t, repo, "commit", "-m", "initial")
168+ return repo
169+}
170+
171+func unsetenvForTest(t *testing.T, name string) {
172+ t.Helper()
173+ value, ok := os.LookupEnv(name)
174+ if err := os.Unsetenv(name); err != nil {
175+ t.Fatal(err)
176+ }
177+ t.Cleanup(func() {
178+ if ok {
179+ _ = os.Setenv(name, value)
180+ } else {
181+ _ = os.Unsetenv(name)
182+ }
183+ })
184+}
185+
120 func runGit(t *testing.T, dir string, args ...string) {186 func runGit(t *testing.T, dir string, args ...string) {
121 t.Helper()187 t.Helper()
122 cmd := exec.Command("git", append([]string{"-C", dir}, args...)...)188 cmd := exec.Command("git", append([]string{"-C", dir}, args...)...)
M packages/agent/tools/subagent_spawn.go +7 -3
@@ -60,7 +60,7 @@ type subagentSpawnArgs struct {
6060 Thinking string `json:"thinking,omitempty"`
6161 Isolation string `json:"isolation,omitempty"`
6262 Timeout string `json:"timeout,omitempty"`
63- MaxTurns int `json:"max_turns,omitempty"`
63+ MaxTurns *int `json:"max_turns,omitempty"`
6464 }
6565
6666 const subagentSpawnSchema = `{
@@ -149,7 +149,7 @@ func (t *SubagentSpawnTool) Execute(ctx context.Context, raw json.RawMessage, pr
149149 }
150150 timeout = parsed
151151 }
152- if a.MaxTurns < 0 {
152+ if a.MaxTurns != nil && *a.MaxTurns < 1 {
153153 return protocolToolError(prefix + ": max_turns must be positive")
154154 }
155155
@@ -217,6 +217,10 @@ func (t *SubagentSpawnTool) Execute(ctx context.Context, raw json.RawMessage, pr
217217 }
218218 }
219219
220+ maxTurns := 0
221+ if a.MaxTurns != nil {
222+ maxTurns = *a.MaxTurns
223+ }
220224 agent, err := t.Supervisor.SpawnReq(ctx, subagents.SpawnRequest{
221225 Task: task,
222226 Model: model,
@@ -225,7 +229,7 @@ func (t *SubagentSpawnTool) Execute(ctx context.Context, raw json.RawMessage, pr
225229 FastMode: fastModeOverride,
226230 Subagent: agentName,
227231 Timeout: timeout,
228- MaxTurns: a.MaxTurns,
232+ MaxTurns: maxTurns,
229233 WorkspaceMode: workspaceMode,
230234 Tools: profileTools,
231235 })
@@ -60,7 +60,7 @@ type subagentSpawnArgs struct {
60 Thinking string `json:"thinking,omitempty"`60 Thinking string `json:"thinking,omitempty"`
61 Isolation string `json:"isolation,omitempty"`61 Isolation string `json:"isolation,omitempty"`
62 Timeout string `json:"timeout,omitempty"`62 Timeout string `json:"timeout,omitempty"`
63- MaxTurns int `json:"max_turns,omitempty"`63+ MaxTurns *int `json:"max_turns,omitempty"`
64 }64 }
65 65
66 const subagentSpawnSchema = `{66 const subagentSpawnSchema = `{
@@ -149,7 +149,7 @@ func (t *SubagentSpawnTool) Execute(ctx context.Context, raw json.RawMessage, pr
149 }149 }
150 timeout = parsed150 timeout = parsed
151 }151 }
152- if a.MaxTurns < 0 {152+ if a.MaxTurns != nil && *a.MaxTurns < 1 {
153 return protocolToolError(prefix + ": max_turns must be positive")153 return protocolToolError(prefix + ": max_turns must be positive")
154 }154 }
155 155
@@ -217,6 +217,10 @@ func (t *SubagentSpawnTool) Execute(ctx context.Context, raw json.RawMessage, pr
217 }217 }
218 }218 }
219 219
220+ maxTurns := 0
221+ if a.MaxTurns != nil {
222+ maxTurns = *a.MaxTurns
223+ }
220 agent, err := t.Supervisor.SpawnReq(ctx, subagents.SpawnRequest{224 agent, err := t.Supervisor.SpawnReq(ctx, subagents.SpawnRequest{
221 Task: task,225 Task: task,
222 Model: model,226 Model: model,
@@ -225,7 +229,7 @@ func (t *SubagentSpawnTool) Execute(ctx context.Context, raw json.RawMessage, pr
225 FastMode: fastModeOverride,229 FastMode: fastModeOverride,
226 Subagent: agentName,230 Subagent: agentName,
227 Timeout: timeout,231 Timeout: timeout,
228- MaxTurns: a.MaxTurns,232+ MaxTurns: maxTurns,
229 WorkspaceMode: workspaceMode,233 WorkspaceMode: workspaceMode,
230 Tools: profileTools,234 Tools: profileTools,
231 })235 })
M packages/agent/tools/subagent_spawn_test.go +21 -0
@@ -70,6 +70,27 @@ func TestSubagentSpawnInheritsHostModelAndProviderWhenOmitted(t *testing.T) {
7070 if agents[0].Model != "gpt-5" || agents[0].Provider != "openai-codex" || agents[0].Reasoning != "medium" {
7171 t.Fatalf("agent model/provider/reasoning = %q/%q/%q, want gpt-5/openai-codex/medium", agents[0].Model, agents[0].Provider, agents[0].Reasoning)
7272 }
73+ if agents[0].MaxTurns != 3 {
74+ t.Fatalf("omitted max_turns = %d, want default 3", agents[0].MaxTurns)
75+ }
76+}
77+
78+func TestSubagentSpawnRejectsExplicitZeroMaxTurns(t *testing.T) {
79+ tool := &SubagentSpawnTool{
80+ Supervisor: newTestSupervisor(t),
81+ Enabled: func() bool { return true },
82+ }
83+
84+ res, err := tool.Execute(context.Background(), json.RawMessage(`{"task":"research docs","max_turns":0}`), nil)
85+ if err != nil {
86+ t.Fatal(err)
87+ }
88+ if !res.IsError || !strings.Contains(textResult(res.Content), "max_turns must be positive") {
89+ t.Fatalf("result = %#v, want max_turns validation error", res)
90+ }
91+ if got := len(tool.Supervisor.List()); got != 0 {
92+ t.Fatalf("spawned agents = %d, want 0", got)
93+ }
7394 }
7495
7596 func TestSubagentSpawnSelectsProfileAndReasoning(t *testing.T) {
@@ -70,6 +70,27 @@ func TestSubagentSpawnInheritsHostModelAndProviderWhenOmitted(t *testing.T) {
70 if agents[0].Model != "gpt-5" || agents[0].Provider != "openai-codex" || agents[0].Reasoning != "medium" {70 if agents[0].Model != "gpt-5" || agents[0].Provider != "openai-codex" || agents[0].Reasoning != "medium" {
71 t.Fatalf("agent model/provider/reasoning = %q/%q/%q, want gpt-5/openai-codex/medium", agents[0].Model, agents[0].Provider, agents[0].Reasoning)71 t.Fatalf("agent model/provider/reasoning = %q/%q/%q, want gpt-5/openai-codex/medium", agents[0].Model, agents[0].Provider, agents[0].Reasoning)
72 }72 }
73+ if agents[0].MaxTurns != 3 {
74+ t.Fatalf("omitted max_turns = %d, want default 3", agents[0].MaxTurns)
75+ }
76+}
77+
78+func TestSubagentSpawnRejectsExplicitZeroMaxTurns(t *testing.T) {
79+ tool := &SubagentSpawnTool{
80+ Supervisor: newTestSupervisor(t),
81+ Enabled: func() bool { return true },
82+ }
83+
84+ res, err := tool.Execute(context.Background(), json.RawMessage(`{"task":"research docs","max_turns":0}`), nil)
85+ if err != nil {
86+ t.Fatal(err)
87+ }
88+ if !res.IsError || !strings.Contains(textResult(res.Content), "max_turns must be positive") {
89+ t.Fatalf("result = %#v, want max_turns validation error", res)
90+ }
91+ if got := len(tool.Supervisor.List()); got != 0 {
92+ t.Fatalf("spawned agents = %d, want 0", got)
93+ }
73 }94 }
74 95
75 func TestSubagentSpawnSelectsProfileAndReasoning(t *testing.T) {96 func TestSubagentSpawnSelectsProfileAndReasoning(t *testing.T) {
M packages/core/agent.go +36 -4
@@ -264,6 +264,36 @@ func (a *Agent) SetTools(reg Registry) {
264264 a.mu.Unlock()
265265 }
266266
267+// SetSystemPrompt replaces the system prompt used for subsequent turns.
268+func (a *Agent) SetSystemPrompt(system string) {
269+ a.mu.Lock()
270+ a.System = system
271+ a.mu.Unlock()
272+}
273+
274+// PromptConfig returns a consistent snapshot of the system prompt and tools
275+// used to construct a provider request.
276+func (a *Agent) PromptConfig() (string, Registry) {
277+ a.mu.Lock()
278+ defer a.mu.Unlock()
279+ tools := make(Registry, len(a.Tools))
280+ for name, tool := range a.Tools {
281+ tools[name] = tool
282+ }
283+ return a.System, tools
284+}
285+
286+// ToolsSnapshot returns a copy of the current tool registry.
287+func (a *Agent) ToolsSnapshot() Registry {
288+ a.mu.Lock()
289+ defer a.mu.Unlock()
290+ tools := make(Registry, len(a.Tools))
291+ for name, tool := range a.Tools {
292+ tools[name] = tool
293+ }
294+ return tools
295+}
296+
267297 // SetMessages replaces the transcript (used when resuming a session).
268298 func (a *Agent) SetMessages(msgs []provider.Message) {
269299 a.mu.Lock()
@@ -594,7 +624,7 @@ func (a *Agent) oneTurn(ctx context.Context, sink func(AgentEvent), turnContext
594624 if err := provider.ValidateFastMode(a.Client.Name(), fastMode); err != nil {
595625 return provider.StopError, provider.Message{}, err
596626 }
597- system := a.System
627+ system, tools := a.PromptConfig()
598628 if contextText := boundedTurnContext(turnContext); contextText != "" {
599629 if system != "" {
600630 system += "\n\n"
@@ -613,7 +643,7 @@ func (a *Agent) oneTurn(ctx context.Context, sink func(AgentEvent), turnContext
613643 // with "tool_use ids were found without tool_result blocks". The
614644 // repair is pure and a no-op on already-valid transcripts.
615645 Messages: repairToolUseResultPairs(a.Messages()),
616- Tools: a.Tools.Specs(),
646+ Tools: tools.Specs(),
617647 Reasoning: a.Reasoning,
618648 FastMode: fastMode,
619649 MaxTokens: a.MaxTokens,
@@ -723,6 +753,7 @@ func (a *Agent) executeTools(ctx context.Context, msg provider.Message, sink fun
723753 var results []provider.Content
724754 var addedTools []string
725755 hadError := false
756+ tools := a.ToolsSnapshot()
726757
727758 for _, c := range msg.Content {
728759 tc, ok := c.(provider.ToolCallBlock)
@@ -739,7 +770,7 @@ func (a *Agent) executeTools(ctx context.Context, msg provider.Message, sink fun
739770 IsError: res.IsError,
740771 })
741772 for _, name := range res.ActivateTools {
742- if _, err := a.Tools.Get(name); err == nil && !containsString(addedTools, name) {
773+ if _, err := tools.Get(name); err == nil && !containsString(addedTools, name) {
743774 addedTools = append(addedTools, name)
744775 }
745776 }
@@ -758,7 +789,8 @@ func (a *Agent) executeTools(ctx context.Context, msg provider.Message, sink fun
758789 }
759790
760791 func (a *Agent) runOneTool(ctx context.Context, tc provider.ToolCallBlock, sink func(AgentEvent)) ToolResult {
761- tool, err := a.Tools.Get(tc.Name)
792+ tools := a.ToolsSnapshot()
793+ tool, err := tools.Get(tc.Name)
762794 if err != nil {
763795 return ToolResult{
764796 Content: []provider.Content{provider.TextBlock{Text: err.Error()}},
@@ -264,6 +264,36 @@ func (a *Agent) SetTools(reg Registry) {
264 a.mu.Unlock()264 a.mu.Unlock()
265 }265 }
266 266
267+// SetSystemPrompt replaces the system prompt used for subsequent turns.
268+func (a *Agent) SetSystemPrompt(system string) {
269+ a.mu.Lock()
270+ a.System = system
271+ a.mu.Unlock()
272+}
273+
274+// PromptConfig returns a consistent snapshot of the system prompt and tools
275+// used to construct a provider request.
276+func (a *Agent) PromptConfig() (string, Registry) {
277+ a.mu.Lock()
278+ defer a.mu.Unlock()
279+ tools := make(Registry, len(a.Tools))
280+ for name, tool := range a.Tools {
281+ tools[name] = tool
282+ }
283+ return a.System, tools
284+}
285+
286+// ToolsSnapshot returns a copy of the current tool registry.
287+func (a *Agent) ToolsSnapshot() Registry {
288+ a.mu.Lock()
289+ defer a.mu.Unlock()
290+ tools := make(Registry, len(a.Tools))
291+ for name, tool := range a.Tools {
292+ tools[name] = tool
293+ }
294+ return tools
295+}
296+
267 // SetMessages replaces the transcript (used when resuming a session).297 // SetMessages replaces the transcript (used when resuming a session).
268 func (a *Agent) SetMessages(msgs []provider.Message) {298 func (a *Agent) SetMessages(msgs []provider.Message) {
269 a.mu.Lock()299 a.mu.Lock()
@@ -594,7 +624,7 @@ func (a *Agent) oneTurn(ctx context.Context, sink func(AgentEvent), turnContext
594 if err := provider.ValidateFastMode(a.Client.Name(), fastMode); err != nil {624 if err := provider.ValidateFastMode(a.Client.Name(), fastMode); err != nil {
595 return provider.StopError, provider.Message{}, err625 return provider.StopError, provider.Message{}, err
596 }626 }
597- system := a.System627+ system, tools := a.PromptConfig()
598 if contextText := boundedTurnContext(turnContext); contextText != "" {628 if contextText := boundedTurnContext(turnContext); contextText != "" {
599 if system != "" {629 if system != "" {
600 system += "\n\n"630 system += "\n\n"
@@ -613,7 +643,7 @@ func (a *Agent) oneTurn(ctx context.Context, sink func(AgentEvent), turnContext
613 // with "tool_use ids were found without tool_result blocks". The643 // with "tool_use ids were found without tool_result blocks". The
614 // repair is pure and a no-op on already-valid transcripts.644 // repair is pure and a no-op on already-valid transcripts.
615 Messages: repairToolUseResultPairs(a.Messages()),645 Messages: repairToolUseResultPairs(a.Messages()),
616- Tools: a.Tools.Specs(),646+ Tools: tools.Specs(),
617 Reasoning: a.Reasoning,647 Reasoning: a.Reasoning,
618 FastMode: fastMode,648 FastMode: fastMode,
619 MaxTokens: a.MaxTokens,649 MaxTokens: a.MaxTokens,
@@ -723,6 +753,7 @@ func (a *Agent) executeTools(ctx context.Context, msg provider.Message, sink fun
723 var results []provider.Content753 var results []provider.Content
724 var addedTools []string754 var addedTools []string
725 hadError := false755 hadError := false
756+ tools := a.ToolsSnapshot()
726 757
727 for _, c := range msg.Content {758 for _, c := range msg.Content {
728 tc, ok := c.(provider.ToolCallBlock)759 tc, ok := c.(provider.ToolCallBlock)
@@ -739,7 +770,7 @@ func (a *Agent) executeTools(ctx context.Context, msg provider.Message, sink fun
739 IsError: res.IsError,770 IsError: res.IsError,
740 })771 })
741 for _, name := range res.ActivateTools {772 for _, name := range res.ActivateTools {
742- if _, err := a.Tools.Get(name); err == nil && !containsString(addedTools, name) {773+ if _, err := tools.Get(name); err == nil && !containsString(addedTools, name) {
743 addedTools = append(addedTools, name)774 addedTools = append(addedTools, name)
744 }775 }
745 }776 }
@@ -758,7 +789,8 @@ func (a *Agent) executeTools(ctx context.Context, msg provider.Message, sink fun
758 }789 }
759 790
760 func (a *Agent) runOneTool(ctx context.Context, tc provider.ToolCallBlock, sink func(AgentEvent)) ToolResult {791 func (a *Agent) runOneTool(ctx context.Context, tc provider.ToolCallBlock, sink func(AgentEvent)) ToolResult {
761- tool, err := a.Tools.Get(tc.Name)792+ tools := a.ToolsSnapshot()
793+ tool, err := tools.Get(tc.Name)
762 if err != nil {794 if err != nil {
763 return ToolResult{795 return ToolResult{
764 Content: []provider.Content{provider.TextBlock{Text: err.Error()}},796 Content: []provider.Content{provider.TextBlock{Text: err.Error()}},

Keyboard shortcuts

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

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

Branches 1

Tags 476

↑↓ moveenter openesc close