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 Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· 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 | |||
| 352 | 352 | ||
| 353 | 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 | 356 | /subagents # open the dashboard | |
| 357 | 357 | /subagents new <task> # spawn an agent (shared mode) | |
| 358 | 358 | /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 dashboard | 356 | /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 profile | 358 | /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 { | |||
| 458 | 458 | ||
| 459 | 459 | mu sync.Mutex | |
| 460 | 460 | 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 | 464 | // managedAutoSubagentsAddenda records exact prompt blocks appended by | |
| 462 | 465 | // auto-subagents. Disable only removes these owned occurrences, leaving | |
| 463 | 466 | // identical text that came from the user's base prompt untouched. | |
| @@ -782,7 +785,7 @@ func NewInteractive(cfg InteractiveConfig) *Interactive { | |||
| 782 | 785 | baseURL, _, err := cfg.LlamaCPPConfig() | |
| 783 | 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 | 789 | i.managedAutoSubagentsAddenda = autoSubagentsAddenda(cfg) | |
| 787 | 790 | } | |
| 788 | 791 | if cfg.Agent != nil { | |
| @@ -3504,7 +3507,7 @@ func (i *Interactive) applyChangedCWD(ag *core.Agent, provider, model, cwd strin | |||
| 3504 | 3507 | i.agent = ag | |
| 3505 | 3508 | i.cfg.CWD = cwd | |
| 3506 | 3509 | i.cfg.SubagentsSystemAddendum = subagentsAddendum | |
| 3507 | - | if i.autoSubagentsEnabled() { | |
| 3510 | + | if i.autoSubagentsEnabledLocked() { | |
| 3508 | 3511 | i.managedAutoSubagentsAddenda = autoSubagentsAddenda(i.cfg) | |
| 3509 | 3512 | } else { | |
| 3510 | 3513 | i.managedAutoSubagentsAddenda = nil | |
| @@ -3855,11 +3858,10 @@ func (i *Interactive) openSettingsDialog() { | |||
| 3855 | 3858 | if i.cfg.AutoSubagentsEnabled != nil { | |
| 3856 | 3859 | autoSubagents = *i.cfg.AutoSubagentsEnabled | |
| 3857 | 3860 | } | |
| 3858 | - | autoSubagentsDisabled := i.cfg.Supervisor == nil | |
| 3859 | - | autoSubagentsHint := "" | |
| 3861 | + | autoSubagentsDisabled := !i.autoSubagentsAvailable() | |
| 3862 | + | autoSubagentsHint := i.autoSubagentsUnavailableHint() | |
| 3860 | 3863 | if autoSubagentsDisabled { | |
| 3861 | 3864 | autoSubagents = false | |
| 3862 | - | autoSubagentsHint = "subagent supervisor not available in this mode" | |
| 3863 | 3865 | } | |
| 3864 | 3866 | ||
| 3865 | 3867 | fastMode := i.cfg.FastMode != nil && *i.cfg.FastMode | |
| @@ -4393,8 +4395,17 @@ func (i *Interactive) applySettingToggle(key string, value bool) { | |||
| 4393 | 4395 | i.statusErr = "" | |
| 4394 | 4396 | i.mu.Unlock() | |
| 4395 | 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 | 4405 | val := value | |
| 4406 | + | i.mu.Lock() | |
| 4397 | 4407 | i.cfg.AutoSubagentsEnabled = &val | |
| 4408 | + | i.mu.Unlock() | |
| 4398 | 4409 | if i.cfg.SettingsStore != nil { | |
| 4399 | 4410 | if err := i.cfg.SettingsStore.SetAutoSubagents(value); err != nil { | |
| 4400 | 4411 | i.mu.Lock() | |
| @@ -5415,7 +5426,7 @@ func (i *Interactive) doLogout(target string) { | |||
| 5415 | 5426 | // it so prompts can't go out with the stale client, and hint at | |
| 5416 | 5427 | // /login. Close any LSP processes owned by its tools first. | |
| 5417 | 5428 | if i.agent != nil { | |
| 5418 | - | _ = tools.CloseLSPManagers(i.agent.Tools) | |
| 5429 | + | _ = tools.CloseLSPManagers(i.agent.ToolsSnapshot()) | |
| 5419 | 5430 | } | |
| 5420 | 5431 | i.agent = nil | |
| 5421 | 5432 | i.statusOK = "logged out of " + strings.Join(providers, ", ") + ". type /login to sign back in." | |
| @@ -5625,14 +5636,29 @@ func (i *Interactive) cancelAndWaitForIdle() { | |||
| 5625 | 5636 | // submitted as the first question, so '/btw does X work?' fires the | |
| 5626 | 5637 | // model call immediately instead of just opening an empty dialog. | |
| 5627 | 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 | 5655 | i.mu.Lock() | |
| 5630 | 5656 | i.statusErr = "not logged in. type /login first." | |
| 5631 | 5657 | i.mu.Unlock() | |
| 5632 | 5658 | return | |
| 5633 | 5659 | } | |
| 5634 | 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 | 5662 | i.invalidate() | |
| 5637 | 5663 | } | |
| 5638 | 5664 | ||
| @@ -6009,7 +6035,7 @@ func (i *Interactive) swapModelUnserialized(prov, model string, builder func(str | |||
| 6009 | 6035 | ag.SeedCost(carryCost) | |
| 6010 | 6036 | ||
| 6011 | 6037 | if i.agent != nil && i.agent != ag { | |
| 6012 | - | _ = tools.CloseLSPManagers(i.agent.Tools) | |
| 6038 | + | _ = tools.CloseLSPManagers(i.agent.ToolsSnapshot()) | |
| 6013 | 6039 | } | |
| 6014 | 6040 | i.mu.Lock() | |
| 6015 | 6041 | i.agent = ag | |
| @@ -6061,7 +6087,7 @@ func (i *Interactive) handleAuthEvent(ev auth.Event) { | |||
| 6061 | 6087 | i.statusOK = "logged in to " + ev.Provider + " via " + ev.Method | |
| 6062 | 6088 | i.mu.Unlock() | |
| 6063 | 6089 | if oldAgent != nil { | |
| 6064 | - | _ = tools.CloseLSPManagers(oldAgent.Tools) | |
| 6090 | + | _ = tools.CloseLSPManagers(oldAgent.ToolsSnapshot()) | |
| 6065 | 6091 | } | |
| 6066 | 6092 | i.applyAutoSubagentsTool(i.autoSubagentsEnabled()) | |
| 6067 | 6093 | i.applyTelegramTools(i.telegramBridge != nil && i.telegramBridge.Active()) | |
| @@ -7374,12 +7400,14 @@ func removeLastAutoSubagentsAddendum(system, addendum string) (string, bool) { | |||
| 7374 | 7400 | // The profile manifest and delegation guidance are managed together so | |
| 7375 | 7401 | // toggling auto-subagents never leaves the model with names but no tool. | |
| 7376 | 7402 | func (i *Interactive) applyAutoSubagentsSystemPrompt(active bool) { | |
| 7403 | + | i.mu.Lock() | |
| 7404 | + | defer i.mu.Unlock() | |
| 7377 | 7405 | if i.agent == nil { | |
| 7378 | 7406 | return | |
| 7379 | 7407 | } | |
| 7380 | 7408 | addenda := autoSubagentsAddenda(i.cfg) | |
| 7381 | 7409 | if active { | |
| 7382 | - | sys := i.agent.System | |
| 7410 | + | sys, _ := i.agent.PromptConfig() | |
| 7383 | 7411 | for _, addendum := range addenda { | |
| 7384 | 7412 | if containsAutoSubagentsAddendum(i.managedAutoSubagentsAddenda, addendum) { | |
| 7385 | 7413 | continue | |
| @@ -7390,11 +7418,11 @@ func (i *Interactive) applyAutoSubagentsSystemPrompt(active bool) { | |||
| 7390 | 7418 | sys += addendum | |
| 7391 | 7419 | i.managedAutoSubagentsAddenda = append(i.managedAutoSubagentsAddenda, addendum) | |
| 7392 | 7420 | } | |
| 7393 | - | i.agent.System = sys | |
| 7421 | + | i.agent.SetSystemPrompt(sys) | |
| 7394 | 7422 | return | |
| 7395 | 7423 | } | |
| 7396 | 7424 | ||
| 7397 | - | sys := i.agent.System | |
| 7425 | + | sys, _ := i.agent.PromptConfig() | |
| 7398 | 7426 | changed := false | |
| 7399 | 7427 | for idx := len(i.managedAutoSubagentsAddenda) - 1; idx >= 0; idx-- { | |
| 7400 | 7428 | var removed bool | |
| @@ -7403,7 +7431,7 @@ func (i *Interactive) applyAutoSubagentsSystemPrompt(active bool) { | |||
| 7403 | 7431 | } | |
| 7404 | 7432 | i.managedAutoSubagentsAddenda = nil | |
| 7405 | 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 | 7440 | // when /settings -> auto-subagents is enabled. Mirrors applyTelegramTools' | |
| 7413 | 7441 | // snapshot+mutate pattern so extension tools and /reload-ext additions | |
| 7414 | 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 | 7462 | 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() | |
| 7417 | 7470 | } | |
| 7418 | 7471 | ||
| 7419 | 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 | 7477 | if i.agent == nil { | |
| 7421 | 7478 | return | |
| 7422 | 7479 | } | |
| 7423 | - | current := i.agent.Tools | |
| 7480 | + | current := i.agent.ToolsSnapshot() | |
| 7424 | 7481 | next := core.Registry{} | |
| 7425 | 7482 | for name, t := range current { | |
| 7426 | 7483 | if name == "subagent_spawn" { | |
| @@ -7428,11 +7485,10 @@ func (i *Interactive) applyAutoSubagentsTool(active bool) { | |||
| 7428 | 7485 | } | |
| 7429 | 7486 | next[name] = t | |
| 7430 | 7487 | } | |
| 7431 | - | toolAllowed := i.cfg.AutoSubagentsToolAllowed == nil || *i.cfg.AutoSubagentsToolAllowed | |
| 7432 | - | if active && toolAllowed && i.cfg.Supervisor != nil { | |
| 7488 | + | if active && i.autoSubagentsAvailable() { | |
| 7433 | 7489 | canonical := &tools.SubagentSpawnTool{ | |
| 7434 | 7490 | Supervisor: i.cfg.Supervisor, | |
| 7435 | - | Enabled: func() bool { return true }, | |
| 7491 | + | Enabled: func() bool { return i.autoSubagentsEnabled() }, | |
| 7436 | 7492 | DefaultModel: func() string { return i.cfg.Model }, | |
| 7437 | 7493 | DefaultProvider: func() string { return i.cfg.Provider }, | |
| 7438 | 7494 | DefaultReasoning: func() string { return i.cfg.Reasoning }, | |
| @@ -7452,10 +7508,14 @@ func (i *Interactive) applyAutoSubagentsTool(active bool) { | |||
| 7452 | 7508 | // later /telegram disconnect (we only add or strip the two telegram | |
| 7453 | 7509 | // entries, never the rest). | |
| 7454 | 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 | 7515 | if i.agent == nil { | |
| 7456 | 7516 | return | |
| 7457 | 7517 | } | |
| 7458 | - | current := i.agent.Tools | |
| 7518 | + | current := i.agent.ToolsSnapshot() | |
| 7459 | 7519 | next := core.Registry{} | |
| 7460 | 7520 | for name, t := range current { | |
| 7461 | 7521 | if name == "telegram_send_image" || name == "telegram_send_file" { | |
| @@ -458,6 +458,9 @@ type Interactive struct { | |||||
| 458 | 458 | ||||
| 459 | mu sync.Mutex | 459 | mu sync.Mutex | ||
| 460 | agent *core.Agent | 460 | 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 by | 464 | // managedAutoSubagentsAddenda records exact prompt blocks appended by | ||
| 462 | // auto-subagents. Disable only removes these owned occurrences, leaving | 465 | // 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 = ag | 3507 | i.agent = ag | ||
| 3505 | i.cfg.CWD = cwd | 3508 | i.cfg.CWD = cwd | ||
| 3506 | i.cfg.SubagentsSystemAddendum = subagentsAddendum | 3509 | 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 = nil | 3513 | 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.AutoSubagentsEnabled | 3859 | autoSubagents = *i.cfg.AutoSubagentsEnabled | ||
| 3857 | } | 3860 | } | ||
| 3858 | - | autoSubagentsDisabled := i.cfg.Supervisor == nil | 3861 | + | autoSubagentsDisabled := !i.autoSubagentsAvailable() |
| 3859 | - | autoSubagentsHint := "" | 3862 | + | autoSubagentsHint := i.autoSubagentsUnavailableHint() |
| 3860 | if autoSubagentsDisabled { | 3863 | if autoSubagentsDisabled { | ||
| 3861 | autoSubagents = false | 3864 | autoSubagents = false | ||
| 3862 | - | autoSubagentsHint = "subagent supervisor not available in this mode" | |||
| 3863 | } | 3865 | } | ||
| 3864 | 3866 | ||||
| 3865 | fastMode := i.cfg.FastMode != nil && *i.cfg.FastMode | 3867 | 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 := value | 4405 | val := value | ||
| 4406 | + | i.mu.Lock() | |||
| 4397 | i.cfg.AutoSubagentsEnabled = &val | 4407 | 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 at | 5426 | // 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 = nil | 5431 | 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 the | 5636 | // 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 | return | 5658 | 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 = ag | 6041 | i.agent = ag | ||
| @@ -6061,7 +6087,7 @@ func (i *Interactive) handleAuthEvent(ev auth.Event) { | |||||
| 6061 | i.statusOK = "logged in to " + ev.Provider + " via " + ev.Method | 6087 | 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 so | 7400 | // 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 | return | 7406 | return | ||
| 7379 | } | 7407 | } | ||
| 7380 | addenda := autoSubagentsAddenda(i.cfg) | 7408 | addenda := autoSubagentsAddenda(i.cfg) | ||
| 7381 | if active { | 7409 | if active { | ||
| 7382 | - | sys := i.agent.System | 7410 | + | 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 | continue | 7413 | continue | ||
| @@ -7390,11 +7418,11 @@ func (i *Interactive) applyAutoSubagentsSystemPrompt(active bool) { | |||||
| 7390 | sys += addendum | 7418 | sys += addendum | ||
| 7391 | i.managedAutoSubagentsAddenda = append(i.managedAutoSubagentsAddenda, addendum) | 7419 | i.managedAutoSubagentsAddenda = append(i.managedAutoSubagentsAddenda, addendum) | ||
| 7392 | } | 7420 | } | ||
| 7393 | - | i.agent.System = sys | 7421 | + | i.agent.SetSystemPrompt(sys) |
| 7394 | return | 7422 | return | ||
| 7395 | } | 7423 | } | ||
| 7396 | 7424 | ||||
| 7397 | - | sys := i.agent.System | 7425 | + | sys, _ := i.agent.PromptConfig() |
| 7398 | changed := false | 7426 | 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 bool | 7428 | var removed bool | ||
| @@ -7403,7 +7431,7 @@ func (i *Interactive) applyAutoSubagentsSystemPrompt(active bool) { | |||||
| 7403 | } | 7431 | } | ||
| 7404 | i.managedAutoSubagentsAddenda = nil | 7432 | 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 additions | 7441 | // 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.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() | |||
| 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 | return | 7478 | return | ||
| 7422 | } | 7479 | } | ||
| 7423 | - | current := i.agent.Tools | 7480 | + | 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] = t | 7486 | next[name] = t | ||
| 7430 | } | 7487 | } | ||
| 7431 | - | toolAllowed := i.cfg.AutoSubagentsToolAllowed == nil || *i.cfg.AutoSubagentsToolAllowed | 7488 | + | 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 telegram | 7508 | // 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 | return | 7516 | return | ||
| 7457 | } | 7517 | } | ||
| 7458 | - | current := i.agent.Tools | 7518 | + | 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 { | |||
| 115 | 115 | ||
| 116 | 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 | 118 | func (d *subagentsDialog) SetCompactMode(enabled bool) { | |
| 125 | 119 | d.compactMode = enabled | |
| 126 | 120 | } | |
| @@ -136,6 +130,12 @@ func (d *subagentsDialog) editorPrompt(th tui.Theme) string { | |||
| 136 | 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 | 139 | func (d *subagentsDialog) SetCurrentModel(model, providerID string) { | |
| 140 | 140 | d.pendingModel = model | |
| 141 | 141 | d.pendingProvider = providerID | |
| @@ -1067,7 +1067,7 @@ func (d *subagentsDialog) Render(th tui.Theme, width int) []string { | |||
| 1067 | 1067 | } | |
| 1068 | 1068 | ||
| 1069 | 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 | 1071 | out = append(out, th.FG256(th.Muted, header)) | |
| 1072 | 1072 | ||
| 1073 | 1073 | 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 = enabled | 119 | 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 = model | 140 | d.pendingModel = model | ||
| 141 | d.pendingProvider = providerID | 141 | 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 ( | |||
| 17 | 17 | // /subagents new [--agent A] [--model M] [--provider P] [--reasoning L] <task...> | |
| 18 | 18 | // -> spawn an agent with optional profile/model overrides | |
| 19 | 19 | // /subagents kill <id> -> stop a running agent | |
| 20 | + | // /subagents cancel <id> -> cancel the active turn | |
| 20 | 21 | // /subagents remove <id> -> tear down a terminated agent | |
| 21 | 22 | // /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 | 25 | // /subagents send <id> <text...> -> send a follow-up user turn to <id> | |
| 23 | 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 | 28 | // /subagents attach <id> -> (planned) drop into the agent's TUI | |
| 25 | 29 | // | |
| 26 | 30 | // 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 | 244 | return | |
| 241 | 245 | } | |
| 242 | 246 | 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 | + | } | |
| 245 | 256 | }() | |
| 246 | 257 | i.subagentsStatus("waiting for "+a.ID, "") | |
| 247 | 258 | case "resume-session", "resume", "reattach", "reopen": | |
| @@ -311,7 +322,7 @@ func (i *Interactive) runSubagents(ctx context.Context, args []string) { | |||
| 311 | 322 | // feature. Point the user at /subagents logs in the meantime. | |
| 312 | 323 | i.subagentsStatus("", "/subagents attach: not implemented yet (needs PTY reparenting). Use /subagents logs "+firstWord(rest)+" to watch its transcript.") | |
| 313 | 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 | ||
| @@ -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 overrides | 18 | // -> spawn an agent with optional profile/model overrides | ||
| 19 | // /subagents kill <id> -> stop a running agent | 19 | // /subagents kill <id> -> stop a running agent | ||
| 20 | + | // /subagents cancel <id> -> cancel the active turn | |||
| 20 | // /subagents remove <id> -> tear down a terminated agent | 21 | // /subagents remove <id> -> tear down a terminated agent | ||
| 21 | // /subagents logs <id> -> open the scrollable transcript view | 22 | // /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 TUI | 28 | // /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 is | 30 | // 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 | return | 244 | 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) { | |||
| 81 | 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 | 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 | 299 | func TestNormalizeSpawnReasoning(t *testing.T) { | |
| 234 | 300 | cases := []struct { | |
| 235 | 301 | 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 string | 301 | in, want string | ||
M packages/agent/settings_store.go +5 -3
| @@ -213,7 +213,9 @@ func (configSettingsStore) SetTheme(name string) error { | |||
| 213 | 213 | } | |
| 214 | 214 | ||
| 215 | 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 | 219 | func AutoSubagentsEnabled() bool { | |
| 218 | 220 | cfg, err := LoadConfig() | |
| 219 | 221 | if err != nil { | |
| @@ -228,8 +230,8 @@ func AutoSubagentsEnabled() bool { | |||
| 228 | 230 | // by name. Kept short so it doesn't bloat the cached prompt prefix. | |
| 229 | 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 | 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.` | |
| @@ -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 | |||
| 235 | 235 | }) | |
| 236 | 236 | em.emit("agent.idle", map[string]any{"turn_id": turnID}) | |
| 237 | 237 | ||
| 238 | + | cancel() | |
| 238 | 239 | mu.Lock() | |
| 239 | 240 | busyTurn = false | |
| 240 | 241 | cancelFn = nil | |
| @@ -538,7 +539,3 @@ func truncateForLog(s string, n int) string { | |||
| 538 | 539 | } | |
| 539 | 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{} | |
| @@ -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 = false | 240 | busyTurn = false | ||
| 240 | cancelFn = nil | 241 | 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) { | |||
| 99 | 99 | } | |
| 100 | 100 | } | |
| 101 | 101 | ||
| 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. | |
| 106 | 104 | func TestSupervisorEmitterLargeResultIsNotDuplicatedOnWire(t *testing.T) { | |
| 107 | 105 | path := filepath.Join(t.TempDir(), "events.jsonl") | |
| 108 | 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 | 153 | func TestSupervisorEmitterStdoutShapeMatchesSupervisorParser(t *testing.T) { | |
| 152 | 154 | // Pipe so we can read what the emitter wrote. | |
| 153 | 155 | r, w, err := os.Pipe() | |
| @@ -99,10 +99,8 @@ func TestSupervisorEmitterMirrorDormantUntilStdoutBreaks(t *testing.T) { | |||||
| 99 | } | 99 | } | ||
| 100 | } | 100 | } | ||
| 101 | 101 | ||||
| 102 | - | // TestSupervisorEmitterStdoutShapeMatchesSupervisorParser pins the | 102 | + | // TestSupervisorEmitterLargeResultIsNotDuplicatedOnWire regresses the |
| 103 | - | // wire-format contract: each emitted event lands on stdout as one | 103 | + | // 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 { | |||
| 202 | 202 | 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 | 212 | // ProcessPIDValue returns the current worker pid, when known. | |
| 206 | 213 | func (a *Agent) ProcessPIDValue() int { | |
| 207 | 214 | a.lifecycleMu.Lock() | |
| @@ -304,6 +311,19 @@ func (a *Agent) Err() error { | |||
| 304 | 311 | // and by /subagents wait <id>. | |
| 305 | 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 | 327 | func (a *Agent) waitForTurnResult(ctx context.Context) (*TurnResult, error) { | |
| 308 | 328 | if ctx == nil { | |
| 309 | 329 | ctx = context.Background() | |
| @@ -345,7 +365,7 @@ func (a *Agent) SetOnTurnEnd(fn func(step int, errMsg string)) { | |||
| 345 | 365 | a.mu.Unlock() | |
| 346 | 366 | if fn != nil { | |
| 347 | 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 | 407 | a.mu.Lock() | |
| 388 | 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 | 414 | if assistant { | |
| 392 | 415 | a.lastAssistant = boundInlineText(message, a.maxOutputBytes, a.maxOutputLines) | |
| @@ -401,26 +424,22 @@ func (a *Agent) appendTranscriptLocked(chunk, linePrefix string, assistant bool) | |||
| 401 | 424 | if byteCap <= 0 { | |
| 402 | 425 | byteCap = 500_000 | |
| 403 | 426 | } | |
| 404 | - | for len(a.transcript) > lineCap { | |
| 427 | + | for a.outputLines > lineCap { | |
| 428 | + | line := a.transcript[0] | |
| 405 | 429 | a.transcript = a.transcript[1:] | |
| 430 | + | a.outputBytes -= len(line) + 1 | |
| 431 | + | a.outputLines-- | |
| 406 | 432 | a.outputTruncated = true | |
| 407 | 433 | } | |
| 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] | |
| 418 | 436 | a.transcript = a.transcript[1:] | |
| 437 | + | a.outputBytes -= len(line) + 1 | |
| 438 | + | a.outputLines-- | |
| 419 | 439 | a.outputTruncated = true | |
| 420 | 440 | } | |
| 421 | - | if len(a.transcript) == 0 { | |
| 441 | + | if a.outputLines == 0 { | |
| 422 | 442 | a.outputBytes = 0 | |
| 423 | - | a.outputLines = 0 | |
| 424 | 443 | } | |
| 425 | 444 | a.mu.Unlock() | |
| 426 | 445 | a.markActivity(time.Now()) | |
| @@ -430,27 +449,6 @@ func (a *Agent) appendTranscriptLocked(chunk, linePrefix string, assistant bool) | |||
| 430 | 449 | // form "<slug>-<nano>". The slug is derived from the task text so | |
| 431 | 450 | // dashboards stay readable; the nano suffix guarantees uniqueness | |
| 432 | 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 | 452 | func newAgentID(task string, now time.Time) string { | |
| 455 | 453 | slug := taskSlug(task) | |
| 456 | 454 | return fmt.Sprintf("%s-%d", slug, now.UnixNano()%1_000_000) | |
| @@ -202,6 +202,13 @@ func (a *Agent) CurrentTurnID() string { | |||||
| 202 | return a.currentTurnID | 202 | 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_000 | 425 | 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 = true | 432 | a.outputTruncated = true | ||
| 407 | } | 433 | } | ||
| 408 | - | for len(a.transcript) > 0 { | 434 | + | for a.outputLines > 0 && a.outputBytes > byteCap { |
| 409 | - | bytes := 0 | 435 | + | 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 = true | 439 | a.outputTruncated = true | ||
| 420 | } | 440 | } | ||
| 421 | - | if len(a.transcript) == 0 { | 441 | + | if a.outputLines == 0 { |
| 422 | a.outputBytes = 0 | 442 | 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 so | 449 | // form "<slug>-<nano>". The slug is derived from the task text so | ||
| 431 | // dashboards stay readable; the nano suffix guarantees uniqueness | 450 | // 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 { | |||
| 157 | 157 | return errors.New("event log: closed") | |
| 158 | 158 | } | |
| 159 | 159 | if ev.Time.IsZero() { | |
| 160 | - | ev.Time = time.Now() | |
| 160 | + | ev.Time = time.Now().UTC() | |
| 161 | 161 | } | |
| 162 | 162 | b, err := json.Marshal(ev) | |
| 163 | 163 | if err != nil { | |
| @@ -209,22 +209,63 @@ func ReadEventLog(path string) ([]Event, error) { | |||
| 209 | 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 | 260 | func readEvents(r io.Reader) ([]Event, error) { | |
| 213 | 261 | br := bufio.NewReader(r) | |
| 214 | - | out := make([]Event, 0, 256) | |
| 262 | + | ring := newEventRing() | |
| 215 | 263 | for { | |
| 216 | 264 | line, err, truncated := readBoundedLine(br, maxEventLogLineBytes) | |
| 217 | 265 | if len(line) > 0 && !truncated { | |
| 218 | 266 | 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) | |
| 228 | 269 | } | |
| 229 | 270 | // Malformed and oversized lines are skipped silently; the | |
| 230 | 271 | // dashboard renders only well-formed events and the child is | |
| @@ -232,9 +273,9 @@ func readEvents(r io.Reader) ([]Event, error) { | |||
| 232 | 273 | } | |
| 233 | 274 | if err != nil { | |
| 234 | 275 | if errors.Is(err, io.EOF) { | |
| 235 | - | return out, nil | |
| 276 | + | return ring.materialize(), nil | |
| 236 | 277 | } | |
| 237 | - | return out, err | |
| 278 | + | return ring.materialize(), err | |
| 238 | 279 | } | |
| 239 | 280 | } | |
| 240 | 281 | } | |
| @@ -245,7 +286,7 @@ func readEvents(r io.Reader) ([]Event, error) { | |||
| 245 | 286 | // make the completed event invisible on the next poll. | |
| 246 | 287 | func readFollowerEvents(r io.Reader, offset int64) ([]Event, int64) { | |
| 247 | 288 | br := bufio.NewReader(r) | |
| 248 | - | out := make([]Event, 0, 256) | |
| 289 | + | ring := newEventRing() | |
| 249 | 290 | committed := offset | |
| 250 | 291 | for { | |
| 251 | 292 | line, err, truncated, consumed, complete := readBoundedLineStats(br, maxEventLogLineBytes) | |
| @@ -255,45 +296,35 @@ func readFollowerEvents(r io.Reader, offset int64) ([]Event, int64) { | |||
| 255 | 296 | committed += consumed | |
| 256 | 297 | if len(line) > 0 && !truncated { | |
| 257 | 298 | 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) | |
| 265 | 301 | } | |
| 266 | 302 | } | |
| 267 | 303 | if err != nil { | |
| 268 | 304 | break | |
| 269 | 305 | } | |
| 270 | 306 | } | |
| 271 | - | return out, committed | |
| 307 | + | return ring.materialize(), committed | |
| 272 | 308 | } | |
| 273 | 309 | ||
| 274 | 310 | // 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. | |
| 283 | 318 | // | |
| 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 { | |
| 293 | 324 | return false | |
| 294 | 325 | } | |
| 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) | |
| 297 | 328 | if dt < 0 { | |
| 298 | 329 | dt = -dt | |
| 299 | 330 | } | |
| @@ -301,7 +332,7 @@ func isLikelyDoubleWrite(ev Event, tail []Event) bool { | |||
| 301 | 332 | 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 | 338 | // 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 Event | 266 | 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; the | 270 | // Malformed and oversized lines are skipped silently; the | ||
| 230 | // dashboard renders only well-formed events and the child is | 271 | // 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, nil | 276 | + | return ring.materialize(), nil |
| 236 | } | 277 | } | ||
| 237 | - | return out, err | 278 | + | 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 := offset | 290 | 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 += consumed | 296 | committed += consumed | ||
| 256 | if len(line) > 0 && !truncated { | 297 | if len(line) > 0 && !truncated { | ||
| 257 | var ev Event | 298 | 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 | break | 304 | break | ||
| 269 | } | 305 | } | ||
| 270 | } | 306 | } | ||
| 271 | - | return out, committed | 307 | + | return ring.materialize(), committed |
| 272 | } | 308 | } | ||
| 273 | 309 | ||||
| 274 | // isLikelyDoubleWrite reports whether ev is a back-to-back duplicate | 310 | // isLikelyDoubleWrite reports whether ev is a back-to-back duplicate | ||
| 275 | - | // of the most recent event in `tail` — same type and identical Data | 311 | + | // of previous — same type and identical Data payload, with timestamps |
| 276 | - | // payload, with timestamps within a small window (the supervisor and | 312 | + | // 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, landing | 313 | + | // BOTH write each event to disk, landing the same content twice in quick |
| 278 | - | // the same content twice in quick succession in events.jsonl). The | 314 | + | // succession in events.jsonl). The historical behaviour was fixed at write |
| 279 | - | // historical behaviour was fixed at write time (the child's mirror | 315 | + | // 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 files | 316 | + | // 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 defensively | 317 | + | // defensively at read time too. |
| 282 | - | // at read time too. | |||
| 283 | // | 318 | // | ||
| 284 | - | // We deliberately bound by time (250ms) so two genuinely identical | 319 | + | // We deliberately bound by time (250ms) so two genuinely identical adjacent |
| 285 | - | // adjacent events that happen seconds apart — e.g. an agent that | 320 | + | // 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 false | 324 | 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 = -dt | 329 | dt = -dt | ||
| 299 | } | 330 | } | ||
| @@ -301,7 +332,7 @@ func isLikelyDoubleWrite(ev Event, tail []Event) bool { | |||||
| 301 | return false | 332 | 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 the | 338 | // 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) { | |||
| 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 | 76 | // TestReadEventLogIgnoresGarbage documents that one malformed line | |
| 52 | 77 | // doesn't break the whole replay. The runner's responsibility is to | |
| 53 | 78 | // 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 | 163 | func TestReadEventLogIgnoresGarbage(t *testing.T) { | |
| 95 | 164 | path := filepath.Join(t.TempDir(), "events.jsonl") | |
| 96 | 165 | 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 line | 76 | // TestReadEventLogIgnoresGarbage documents that one malformed line | ||
| 52 | // doesn't break the whole replay. The runner's responsibility is to | 77 | // doesn't break the whole replay. The runner's responsibility is to | ||
| 53 | // only write well-formed events, but a partial crash could leave | 78 | // 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 { | |||
| 178 | 178 | out chan string | |
| 179 | 179 | done chan struct{} | |
| 180 | 180 | wg sync.WaitGroup | |
| 181 | + | closed bool | |
| 181 | 182 | closeOnce sync.Once | |
| 182 | 183 | } | |
| 183 | 184 | ||
| @@ -186,9 +187,13 @@ type Listener struct { | |||
| 186 | 187 | // A live endpoint is preserved and rejected; only a refused probe | |
| 187 | 188 | // is treated as a stale socket eligible for removal. | |
| 188 | 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 | 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 | 197 | // Probe an existing endpoint before removing it. Unlinking an active | |
| 193 | 198 | // Unix socket does not stop its listener, so blindly removing the path | |
| 194 | 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 | 232 | // the socket file, and closes Lines. Idempotent. | |
| 228 | 233 | func (l *Listener) Close() error { | |
| 229 | 234 | l.closeOnce.Do(func() { | |
| 230 | - | close(l.done) | |
| 231 | 235 | l.mu.Lock() | |
| 236 | + | l.closed = true | |
| 237 | + | close(l.done) | |
| 232 | 238 | for conn := range l.conns { | |
| 233 | 239 | _ = conn.Close() | |
| 234 | 240 | } | |
| @@ -257,9 +263,14 @@ func (l *Listener) acceptLoop() { | |||
| 257 | 263 | } | |
| 258 | 264 | } | |
| 259 | 265 | l.mu.Lock() | |
| 266 | + | if l.closed { | |
| 267 | + | l.mu.Unlock() | |
| 268 | + | _ = c.Close() | |
| 269 | + | return | |
| 270 | + | } | |
| 260 | 271 | l.conns[c] = struct{}{} | |
| 261 | - | l.mu.Unlock() | |
| 262 | 272 | l.wg.Add(1) | |
| 273 | + | l.mu.Unlock() | |
| 263 | 274 | go func() { | |
| 264 | 275 | defer l.wg.Done() | |
| 265 | 276 | l.readLoop(c) | |
| @@ -178,6 +178,7 @@ type Listener struct { | |||||
| 178 | out chan string | 178 | out chan string | ||
| 179 | done chan struct{} | 179 | done chan struct{} | ||
| 180 | wg sync.WaitGroup | 180 | wg sync.WaitGroup | ||
| 181 | + | closed bool | |||
| 181 | closeOnce sync.Once | 182 | 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 probe | 187 | // 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 active | 197 | // Probe an existing endpoint before removing it. Unlinking an active | ||
| 193 | // Unix socket does not stop its listener, so blindly removing the path | 198 | // 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) { | |||
| 41 | 41 | } | |
| 42 | 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 | 45 | inbox := NewInbox(path) | |
| 46 | 46 | defer inbox.Close() | |
| 47 | 47 | ||
| @@ -51,16 +51,30 @@ func TestInboxRoundTrip(t *testing.T) { | |||
| 51 | 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 | 58 | 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) | |
| 58 | 61 | } | |
| 59 | 62 | for i, w := range want { | |
| 60 | 63 | if gotLines[i] != w { | |
| 61 | 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 | 80 | // 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 := <-got | 58 | 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't | 80 | // 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) { | |||
| 14 | 14 | return nil, err | |
| 15 | 15 | } | |
| 16 | 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 | 19 | _ = file.Close() | |
| 19 | 20 | if errors.Is(err, windows.ERROR_LOCK_VIOLATION) { | |
| 20 | 21 | return nil, ErrAgentLeaseHeld | |
| @@ -28,7 +29,8 @@ func (l *agentLease) Close() error { | |||
| 28 | 29 | if l == nil || l.file == nil { | |
| 29 | 30 | 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 | 34 | closeErr := l.file.Close() | |
| 33 | 35 | l.file = nil | |
| 34 | 36 | if unlockErr != nil { | |
| @@ -14,7 +14,8 @@ func acquireAgentLease(stateDir string) (*agentLease, error) { | |||||
| 14 | return nil, err | 14 | 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, ErrAgentLeaseHeld | 21 | 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 nil | 30 | 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 = nil | 35 | l.file = nil | ||
| 34 | if unlockErr != nil { | 36 | if unlockErr != nil { | ||
M packages/agent/subagents/model.go +4 -3
| @@ -46,9 +46,10 @@ const ( | |||
| 46 | 46 | CaptureDiff CaptureMode = "diff" | |
| 47 | 47 | ) | |
| 48 | 48 | ||
| 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. | |
| 52 | 53 | type SubagentPolicy struct { | |
| 53 | 54 | MaxConcurrent int | |
| 54 | 55 | 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. Zero | 49 | + | // 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 int | 54 | MaxConcurrent int | ||
| 54 | MaxConcurrentPerParent int | 55 | MaxConcurrentPerParent int | ||
M packages/agent/subagents/persist.go +16 -16
| @@ -180,22 +180,6 @@ func readAgentMeta(stateDir string) (agentMeta, error) { | |||
| 180 | 180 | 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 | 183 | func safeAgentID(id string) bool { | |
| 200 | 184 | return id != "" && id != "." && id != ".." && filepath.Base(id) == id && !strings.ContainsAny(id, `/\\`) | |
| 201 | 185 | } | |
| @@ -258,6 +242,22 @@ func firstNonEmpty(values ...string) string { | |||
| 258 | 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 | 261 | func (f *Supervisor) Reload() (loaded int, errs []error) { | |
| 262 | 262 | root := filepath.Clean(f.cfg.Root) | |
| 263 | 263 | if root == "." { | |
| @@ -180,22 +180,6 @@ func readAgentMeta(stateDir string) (agentMeta, error) { | |||||
| 180 | return m, nil | 180 | 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) { | |||
| 152 | 152 | first.StopAll() | |
| 153 | 153 | a.Wait() | |
| 154 | 154 | ||
| 155 | - | var observed time.Duration | |
| 155 | + | observedCh := make(chan time.Duration, 1) | |
| 156 | 156 | second := New(Config{ | |
| 157 | 157 | Root: root, RepoRoot: root, | |
| 158 | 158 | Policy: SubagentPolicy{DefaultTimeout: time.Hour}, | |
| 159 | 159 | NewRunner: func(a *Agent) Runner { | |
| 160 | - | observed = a.Timeout | |
| 160 | + | observedCh <- a.Timeout | |
| 161 | 161 | return RunnerFunc(func(ctx context.Context, _ Sink) error { | |
| 162 | 162 | <-ctx.Done() | |
| 163 | 163 | return ctx.Err() | |
| @@ -171,6 +171,7 @@ func TestResumePreservesPerAgentTimeout(t *testing.T) { | |||
| 171 | 171 | if err != nil { | |
| 172 | 172 | t.Fatal(err) | |
| 173 | 173 | } | |
| 174 | + | observed := <-observedCh | |
| 174 | 175 | if resumed.Timeout != wantTimeout || observed != wantTimeout { | |
| 175 | 176 | t.Fatalf("resumed timeout = %s, runner observed %s; want %s", resumed.Timeout, observed, wantTimeout) | |
| 176 | 177 | } | |
| @@ -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.Duration | 155 | + | 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.Timeout | 160 | + | 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 @@ | |||
| 3 | 3 | package subagents | |
| 4 | 4 | ||
| 5 | 5 | import ( | |
| 6 | + | "io" | |
| 6 | 7 | "os/exec" | |
| 8 | + | "sync" | |
| 7 | 9 | "syscall" | |
| 10 | + | "time" | |
| 8 | 11 | ) | |
| 9 | 12 | ||
| 10 | 13 | // configureWorkerProcess makes cancellation target the worker's process | |
| 11 | 14 | // group rather than only the worker itself. CommandContext calls cmd.Cancel | |
| 12 | 15 | // from its context watcher, so descendants that inherited the worker's group | |
| 13 | 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 | 18 | setProcessGroup(cmd) | |
| 19 | + | cmd.WaitDelay = workerProcessWaitDelay | |
| 20 | + | var closePipes sync.Once | |
| 16 | 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 | ||
| @@ -3,18 +3,38 @@ | |||||
| 3 | package subagents | 3 | 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 process | 13 | // configureWorkerProcess makes cancellation target the worker's process | ||
| 11 | // group rather than only the worker itself. CommandContext calls cmd.Cancel | 14 | // group rather than only the worker itself. CommandContext calls cmd.Cancel | ||
| 12 | // from its context watcher, so descendants that inherited the worker's group | 15 | // 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 ( | |||
| 6 | 6 | "context" | |
| 7 | 7 | "errors" | |
| 8 | 8 | "fmt" | |
| 9 | + | "io" | |
| 9 | 10 | "os" | |
| 10 | 11 | "os/exec" | |
| 11 | 12 | "strconv" | |
| @@ -27,6 +28,9 @@ func TestWorkerCancellationKillsProcessGroup(t *testing.T) { | |||
| 27 | 28 | ||
| 28 | 29 | cmd := exec.CommandContext(ctx, shell, "-c", fmt.Sprintf("sleep 30 & echo $! > %q; wait", pidPath)) | |
| 29 | 30 | configureWorkerProcess(cmd) | |
| 31 | + | if cmd.WaitDelay != workerProcessWaitDelay { | |
| 32 | + | t.Fatalf("WaitDelay = %s, want %s", cmd.WaitDelay, workerProcessWaitDelay) | |
| 33 | + | } | |
| 30 | 34 | if err := cmd.Start(); err != nil { | |
| 31 | 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 | 86 | func waitForWorkerChildPID(t *testing.T, path string) int { | |
| 58 | 87 | t.Helper() | |
| 59 | 88 | 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 @@ | |||
| 3 | 3 | package subagents | |
| 4 | 4 | ||
| 5 | 5 | import ( | |
| 6 | + | "errors" | |
| 7 | + | "fmt" | |
| 8 | + | "io" | |
| 9 | + | "os" | |
| 6 | 10 | "os/exec" | |
| 7 | 11 | "strconv" | |
| 12 | + | "strings" | |
| 8 | 13 | "syscall" | |
| 14 | + | "time" | |
| 9 | 15 | ) | |
| 10 | 16 | ||
| 11 | 17 | // configureWorkerProcess makes CommandContext cancellation terminate the | |
| 12 | 18 | // worker and its descendants on Windows. Windows does not provide a Unix-like | |
| 13 | 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 | 21 | setProcessGroup(cmd) | |
| 22 | + | cmd.WaitDelay = workerProcessWaitDelay | |
| 16 | 23 | cmd.Cancel = func() error { | |
| 17 | 24 | return killProcessGroup(cmd) | |
| 18 | 25 | } | |
| 19 | 26 | } | |
| 20 | 27 | ||
| 28 | + | const workerProcessWaitDelay = 5 * time.Second | |
| 29 | + | ||
| 21 | 30 | // setProcessGroup gives the worker its own Windows process group. The group | |
| 22 | 31 | // flag prevents it from being attached to the supervisor's console group; | |
| 23 | 32 | // taskkill /T below is still needed because Windows group creation alone does | |
| @@ -35,12 +44,44 @@ func killProcessGroup(cmd *exec.Cmd) error { | |||
| 35 | 44 | if cmd == nil || cmd.Process == nil { | |
| 36 | 45 | 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 | 55 | func forceKillProcess(pid int) error { | |
| 42 | 56 | if pid <= 0 { | |
| 43 | 57 | 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 | } | |
| @@ -3,21 +3,30 @@ | |||||
| 3 | package subagents | 3 | 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 the | 17 | // configureWorkerProcess makes CommandContext cancellation terminate the | ||
| 12 | // worker and its descendants on Windows. Windows does not provide a Unix-like | 18 | // 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 group | 30 | // 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 does | 32 | // 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 nil | 45 | 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 nil | 57 | 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 ( | |||
| 39 | 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 | 42 | var ( | |
| 62 | 43 | // ErrEmptyProtocolLine is returned for a blank JSONL line or command. | |
| 63 | 44 | ErrEmptyProtocolLine = errors.New("subagent protocol: empty line") | |
| @@ -107,11 +88,6 @@ func IsEventName(name string) bool { | |||
| 107 | 88 | 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 | 91 | // MessageID returns a fresh opaque id suitable for correlating a command and | |
| 116 | 92 | // its result. UUIDs are used rather than a counter so ids remain unique when | |
| 117 | 93 | // several supervisors or workers are running at once. | |
| @@ -152,15 +128,12 @@ type Envelope struct { | |||
| 152 | 128 | // Unknown contains unrecognised top-level fields from a versioned | |
| 153 | 129 | // envelope. Payload itself retains unrecognised payload fields. | |
| 154 | 130 | Unknown map[string]json.RawMessage `json:"-"` | |
| 155 | - | } | |
| 156 | 131 | ||
| 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 | + | } | |
| 164 | 137 | ||
| 165 | 138 | // NewEnvelope creates a current-protocol envelope and assigns a message id | |
| 166 | 139 | // 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 | 147 | if err == nil { | |
| 175 | 148 | return e | |
| 176 | 149 | } | |
| 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. | |
| 181 | 153 | 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), | |
| 189 | 162 | } | |
| 190 | 163 | } | |
| 191 | 164 | ||
| @@ -216,27 +189,11 @@ func NewCommand(name, agentID, turnID string, payload ...any) Envelope { | |||
| 216 | 189 | } | |
| 217 | 190 | ||
| 218 | 191 | // 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 | 193 | func NewEventEnvelope(name, agentID, turnID string, payload ...any) Envelope { | |
| 221 | 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 | 197 | func firstPayload(payload []any) any { | |
| 241 | 198 | if len(payload) == 0 || payload[0] == nil { | |
| 242 | 199 | return map[string]any{} | |
| @@ -259,6 +216,9 @@ func marshalPayload(payload any) (json.RawMessage, error) { | |||
| 259 | 216 | // intentionally separate from ParseEnvelope: parsing an unknown future event | |
| 260 | 217 | // should succeed, while a sender can opt into strict validation before write. | |
| 261 | 218 | func (e Envelope) Validate() error { | |
| 219 | + | if e.constructionErr != nil { | |
| 220 | + | return e.constructionErr | |
| 221 | + | } | |
| 262 | 222 | if e.Version != ProtocolVersion { | |
| 263 | 223 | return fmt.Errorf("%w: %d", ErrUnsupportedProtocolVersion, e.Version) | |
| 264 | 224 | } | |
| @@ -357,19 +317,10 @@ func ParseEnvelope(data []byte) (Envelope, error) { | |||
| 357 | 317 | 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 | 320 | // ParseJSONL parses one newline-delimited JSON message. It accepts a final | |
| 365 | 321 | // line without a newline, as is customary for a stream after a clean EOF. | |
| 366 | 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 | 324 | // ParseEvent parses a current-protocol JSON event. A known command is | |
| 374 | 325 | // rejected; unknown types are accepted because an older peer cannot know | |
| 375 | 326 | // whether a future event name is present in its event registry. | |
| @@ -384,12 +335,6 @@ func ParseEvent(data []byte) (Envelope, error) { | |||
| 384 | 335 | 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 | 338 | // ParseCommand parses one current-protocol JSON command. | |
| 394 | 339 | func ParseCommand(line string) (Envelope, error) { | |
| 395 | 340 | raw := strings.TrimSpace(line) | |
| @@ -406,9 +351,6 @@ func ParseCommand(line string) (Envelope, error) { | |||
| 406 | 351 | 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 | 354 | // MarshalEnvelope returns one JSON object without a trailing newline. | |
| 413 | 355 | func MarshalEnvelope(e Envelope) ([]byte, error) { | |
| 414 | 356 | return json.Marshal(e) | |
| @@ -423,9 +365,6 @@ func MarshalJSONL(e Envelope) ([]byte, error) { | |||
| 423 | 365 | 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 | 368 | // WriteEnvelope writes one complete newline-terminated JSONL message. | |
| 430 | 369 | func WriteEnvelope(w io.Writer, e Envelope) error { | |
| 431 | 370 | if w == nil { | |
| @@ -467,7 +406,13 @@ func (e Envelope) MarshalJSON() ([]byte, error) { | |||
| 467 | 406 | } | |
| 468 | 407 | ||
| 469 | 408 | func (e Envelope) marshalVersionedJSON() ([]byte, error) { | |
| 409 | + | if e.constructionErr != nil { | |
| 410 | + | return nil, e.constructionErr | |
| 411 | + | } | |
| 470 | 412 | version := e.Version | |
| 413 | + | if version == 0 { | |
| 414 | + | version = ProtocolVersion | |
| 415 | + | } | |
| 471 | 416 | messageID := e.MessageID | |
| 472 | 417 | if messageID == "" { | |
| 473 | 418 | 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 ok | 88 | 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 and | 91 | // 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 when | 92 | // 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 versioned | 128 | // 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 a | 132 | + | // 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 = Envelope | 135 | + | constructionErr error |
| 161 | - | type ProtocolEvent = Envelope | 136 | + | } |
| 162 | - | type EventEnvelope = Envelope | |||
| 163 | - | type CommandEnvelope = Envelope | |||
| 164 | 137 | ||||
| 165 | // NewEnvelope creates a current-protocol envelope and assigns a message id | 138 | // NewEnvelope creates a current-protocol envelope and assigns a message id | ||
| 166 | // and timestamp. payload may be any JSON-marshalable value. A nil payload is | 139 | // 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 e | 148 | return e | ||
| 176 | } | 149 | } | ||
| 177 | - | // Keep the constructor useful in error-free call sites. MarshalJSON will | 150 | + | // Preserve the value-returning API used by command/event call sites, but |
| 178 | - | // still succeed with a valid JSON null; callers which need the original | 151 | + | // retain the construction failure so validation and wire encoding fail |
| 179 | - | // error should use NewEnvelopeWithPayload. Preserve the metadata even | 152 | + | // 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 checked | 191 | // 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 event | 216 | // 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, nil | 317 | 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 final | 320 | // 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 is | 324 | // ParseEvent parses a current-protocol JSON event. A known command is | ||
| 374 | // rejected; unknown types are accepted because an older peer cannot know | 325 | // 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, nil | 335 | 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, nil | 351 | 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'), nil | 365 | 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.Version | 412 | version := e.Version | ||
| 413 | + | if version == 0 { | |||
| 414 | + | version = ProtocolVersion | |||
| 415 | + | } | |||
| 471 | messageID := e.MessageID | 416 | 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) { | |||
| 108 | 108 | if err != nil { | |
| 109 | 109 | t.Fatalf("MarshalJSONL: %v", err) | |
| 110 | 110 | } | |
| 111 | - | got, err := ParseCommandLine(string(line)) | |
| 111 | + | got, err := ParseCommand(string(line)) | |
| 112 | 112 | if err != nil { | |
| 113 | - | t.Fatalf("ParseCommandLine: %v", err) | |
| 113 | + | t.Fatalf("ParseCommand: %v", err) | |
| 114 | 114 | } | |
| 115 | 115 | if !got.IsCommand() || got.IsEvent() || got.Type != CommandTurnStart { | |
| 116 | 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 | 124 | if err != nil { | |
| 125 | 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 | 128 | t.Fatalf("ParseCommand(event) = %v, want ErrNotCommand", err) | |
| 129 | 129 | } | |
| 130 | 130 | } | |
| @@ -200,6 +200,37 @@ func TestProtocolEmptyAndMalformedInputs(t *testing.T) { | |||
| 200 | 200 | if _, err := ParseEnvelope([]byte(`{"version":1,"type":"x","payload":`)); err == nil { | |
| 201 | 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 | 236 | 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 { | |||
| 93 | 93 | if out == nil { | |
| 94 | 94 | return nil | |
| 95 | 95 | } | |
| 96 | - | const marker = "...[output truncated]" | |
| 97 | 96 | if len([]byte(out.Summary)) > 4*1024 { | |
| 98 | 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 | 107 | if maxLines > 0 { | |
| 101 | - | lines := strings.Split(out.Output, "\n") | |
| 108 | + | lines := strings.Split(value, "\n") | |
| 102 | 109 | if len(lines) > maxLines { | |
| 103 | 110 | if maxLines == 1 { | |
| 104 | - | out.Output = marker | |
| 111 | + | value = marker | |
| 105 | 112 | } else { | |
| 106 | - | out.Output = strings.Join(lines[:maxLines-1], "\n") + "\n" + marker | |
| 113 | + | 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 | 118 | 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) | |
| 115 | 120 | } | |
| 121 | + | return truncateUTF8(value, maxBytes-len(marker)) + marker | |
| 116 | 122 | } | |
| 117 | - | return out | |
| 123 | + | return value | |
| 118 | 124 | } | |
| 119 | 125 | ||
| 120 | 126 | func truncateUTF8(value string, maxBytes int) string { | |
| @@ -125,11 +131,78 @@ func truncateUTF8(value string, maxBytes int) string { | |||
| 125 | 131 | if len(data) <= maxBytes { | |
| 126 | 132 | return value | |
| 127 | 133 | } | |
| 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 | |
| 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 | 208 | func validateTurnResultAgent(result *TurnResult, agentID string) error { | |
| @@ -211,10 +284,52 @@ func PatchRef(id string) string { return AgentRef(id) + "/patch" } | |||
| 211 | 284 | ||
| 212 | 285 | const maxResultFileBytes = 8 * 1024 * 1024 | |
| 213 | 286 | ||
| 287 | + | const ( | |
| 288 | + | workspaceCaptureErrorCode = "workspace_capture_failed" | |
| 289 | + | workspaceCaptureErrorMessage = "workspace capture failed" | |
| 290 | + | ) | |
| 291 | + | ||
| 214 | 292 | func resultPath(stateDir string) string { return filepath.Join(stateDir, "result.json") } | |
| 215 | 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 | 333 | if result == nil { | |
| 219 | 334 | return errors.New("subagents: nil result") | |
| 220 | 335 | } | |
| @@ -238,11 +353,15 @@ func writeTurnResult(stateDir string, result *TurnResult) error { | |||
| 238 | 353 | return fmt.Errorf("subagents result dir: %w", err) | |
| 239 | 354 | } | |
| 240 | 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 | 362 | return fmt.Errorf("subagents result write: %w", err) | |
| 243 | 363 | } | |
| 244 | 364 | if err := os.Rename(tmp, resultPath(stateDir)); err != nil { | |
| 245 | - | _ = os.Remove(tmp) | |
| 246 | 365 | return fmt.Errorf("subagents result rename: %w", err) | |
| 247 | 366 | } | |
| 248 | 367 | return nil | |
| @@ -254,10 +373,12 @@ func (f *Supervisor) captureWorkspace(a *Agent) { | |||
| 254 | 373 | } | |
| 255 | 374 | stateDir := a.stateDirectory(f.cfg.Root) | |
| 256 | 375 | if _, err := os.Stat(stateDir); err != nil { | |
| 376 | + | recordWorkspaceCaptureFailure(a) | |
| 257 | 377 | return | |
| 258 | 378 | } | |
| 259 | 379 | capture, err := a.workspaceCapture() | |
| 260 | 380 | if err != nil { | |
| 381 | + | recordWorkspaceCaptureFailure(a) | |
| 261 | 382 | return | |
| 262 | 383 | } | |
| 263 | 384 | a.lifecycleMu.Lock() | |
| @@ -266,13 +387,16 @@ func (f *Supervisor) captureWorkspace(a *Agent) { | |||
| 266 | 387 | if len(capture.Patch) == 0 { | |
| 267 | 388 | return | |
| 268 | 389 | } | |
| 269 | - | path := patchPath(a.stateDirectory(f.cfg.Root)) | |
| 390 | + | path := patchPath(stateDir) | |
| 270 | 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 | 395 | return | |
| 273 | 396 | } | |
| 274 | 397 | if err := os.Rename(tmp, path); err != nil { | |
| 275 | 398 | _ = os.Remove(tmp) | |
| 399 | + | recordWorkspaceCaptureFailure(a) | |
| 276 | 400 | return | |
| 277 | 401 | } | |
| 278 | 402 | a.lifecycleMu.Lock() | |
| @@ -280,10 +404,45 @@ func (f *Supervisor) captureWorkspace(a *Agent) { | |||
| 280 | 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 | 441 | func (f *Supervisor) ensureResult(a *Agent, status Status, runErr error) { | |
| 284 | 442 | if a == nil { | |
| 285 | 443 | return | |
| 286 | 444 | } | |
| 445 | + | attempt := a.AttemptValue() | |
| 287 | 446 | result := a.Result() | |
| 288 | 447 | if result != nil { | |
| 289 | 448 | if err := validateTurnResultAgent(result, a.ID); err != nil { | |
| @@ -297,7 +456,7 @@ func (f *Supervisor) ensureResult(a *Agent, status Status, runErr error) { | |||
| 297 | 456 | result.AgentID = a.ID | |
| 298 | 457 | } | |
| 299 | 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 | 461 | if result.Version == 0 { | |
| 303 | 462 | result.Version = ProtocolVersion | |
| @@ -312,7 +471,7 @@ func (f *Supervisor) ensureResult(a *Agent, status Status, runErr error) { | |||
| 312 | 471 | if result == nil { | |
| 313 | 472 | turnID := a.CurrentTurnID() | |
| 314 | 473 | if turnID == "" { | |
| 315 | - | turnID = fmt.Sprintf("turn-%d", a.Attempt) | |
| 474 | + | turnID = fmt.Sprintf("turn-%d", attempt) | |
| 316 | 475 | } | |
| 317 | 476 | a.mu.Lock() | |
| 318 | 477 | output := a.lastAssistant | |
| @@ -339,7 +498,7 @@ func (f *Supervisor) ensureResult(a *Agent, status Status, runErr error) { | |||
| 339 | 498 | result.AgentID = a.ID | |
| 340 | 499 | } | |
| 341 | 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 | 503 | if result.Version == 0 { | |
| 345 | 504 | result.Version = ProtocolVersion | |
| @@ -347,8 +506,15 @@ func (f *Supervisor) ensureResult(a *Agent, status Status, runErr error) { | |||
| 347 | 506 | if result.CreatedAt.IsZero() { | |
| 348 | 507 | result.CreatedAt = time.Now().UTC() | |
| 349 | 508 | } | |
| 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 | + | } | |
| 352 | 518 | } | |
| 353 | 519 | if len(result.ChangedFiles) == 0 { | |
| 354 | 520 | a.lifecycleMu.Lock() | |
| @@ -361,12 +527,10 @@ func (f *Supervisor) ensureResult(a *Agent, status Status, runErr error) { | |||
| 361 | 527 | result = result.Bounded(a.maxOutputBytes, a.maxOutputLines) | |
| 362 | 528 | a.setResult(result) | |
| 363 | 529 | 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() | |
| 370 | 534 | } | |
| 371 | 535 | } | |
| 372 | 536 | ||
| @@ -93,28 +93,34 @@ func (r *TurnResult) Bounded(maxBytes, maxLines int) *TurnResult { | |||||
| 93 | if out == nil { | 93 | if out == nil { | ||
| 94 | return nil | 94 | 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 = marker | 111 | + | value = marker |
| 105 | } else { | 112 | } else { | ||
| 106 | - | out.Output = strings.Join(lines[:maxLines-1], "\n") + "\n" + marker | 113 | + | 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 out | 123 | + | 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 value | 132 | 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 * 1024 | 285 | 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 nil | 367 | 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 | return | 377 | 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 | return | 382 | 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 | return | 388 | 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 | return | 395 | 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 | return | 400 | 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 | return | 443 | 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.ID | 456 | 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 = ProtocolVersion | 462 | 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.lastAssistant | 477 | output := a.lastAssistant | ||
| @@ -339,7 +498,7 @@ func (f *Supervisor) ensureResult(a *Agent, status Status, runErr error) { | |||||
| 339 | result.AgentID = a.ID | 498 | 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 = ProtocolVersion | 504 | 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 @@ | |||
| 1 | 1 | package subagents | |
| 2 | 2 | ||
| 3 | 3 | import ( | |
| 4 | + | "context" | |
| 4 | 5 | "encoding/json" | |
| 6 | + | "errors" | |
| 5 | 7 | "os" | |
| 6 | 8 | "path/filepath" | |
| 7 | 9 | "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 | 94 | func TestDecodeTurnResultEventValidatesIdentityAndStatus(t *testing.T) { | |
| 51 | 95 | invalid := NewEvent(EventTurnResult, map[string]any{ | |
| 52 | 96 | "status": "unknown", | |
| @@ -105,3 +149,160 @@ func TestTurnResultReferencesAndPersistence(t *testing.T) { | |||
| 105 | 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 | + | } | |
| @@ -1,7 +1,9 @@ | |||||
| 1 | package subagents | 1 | package subagents | ||
| 2 | 2 | ||||
| 3 | import ( | 3 | import ( | ||
| 4 | + | "context" | |||
| 4 | "encoding/json" | 5 | "encoding/json" | ||
| 6 | + | "errors" | |||
| 5 | "os" | 7 | "os" | ||
| 6 | "path/filepath" | 8 | "path/filepath" | ||
| 7 | "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 { | |||
| 213 | 213 | } | |
| 214 | 214 | ||
| 215 | 215 | cmd := exec.CommandContext(ctx, args[0], args[1:]...) | |
| 216 | - | configureWorkerProcess(cmd) | |
| 217 | 216 | cmd.Dir = r.agent.Dir | |
| 218 | 217 | cmd.Env = append(workerEnvironment(r.agent.Provider), | |
| 219 | 218 | "ZOT_SUBAGENT_AGENT_ID="+r.agent.ID, | |
| @@ -251,6 +250,7 @@ func (r *execRunner) Run(ctx context.Context, sink Sink) error { | |||
| 251 | 250 | if err != nil { | |
| 252 | 251 | return err | |
| 253 | 252 | } | |
| 253 | + | configureWorkerProcess(cmd, stdout, stderr) | |
| 254 | 254 | ||
| 255 | 255 | // "spawning" is briefly shown until the first event arrives; | |
| 256 | 256 | // the child's "spawned" lifecycle event then overwrites it. | |
| @@ -422,13 +422,13 @@ func isBedrockCredentialEnv(name string) bool { | |||
| 422 | 422 | ||
| 423 | 423 | func workerSecretEnvName(name string) bool { | |
| 424 | 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 | 426 | if strings.HasSuffix(name, suffix) { | |
| 427 | 427 | return true | |
| 428 | 428 | } | |
| 429 | 429 | } | |
| 430 | 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 | 432 | return true | |
| 433 | 433 | default: | |
| 434 | 434 | return false | |
| @@ -568,6 +568,13 @@ func updateAgentFromEvent(a *Agent, ev Event) { | |||
| 568 | 568 | a.setProcessState(ProcessExited) | |
| 569 | 569 | persist = true | |
| 570 | 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 | 578 | if message, _ := ev.Data["error"].(string); message != "" { | |
| 572 | 579 | a.setTurnState(TurnFailed, ev.TurnID) | |
| 573 | 580 | } 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.Dir | 216 | 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 err | 251 | 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 true | 427 | 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 true | 432 | return true | ||
| 433 | default: | 433 | default: | ||
| 434 | return false | 434 | return false | ||
| @@ -568,6 +568,13 @@ func updateAgentFromEvent(a *Agent, ev Event) { | |||||
| 568 | a.setProcessState(ProcessExited) | 568 | a.setProcessState(ProcessExited) | ||
| 569 | persist = true | 569 | 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) { | |||
| 64 | 64 | t.Setenv("HF_TOKEN", "hf-secret") | |
| 65 | 65 | t.Setenv("GITHUB_TOKEN", "github-secret") | |
| 66 | 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 | 69 | t.Setenv("GOOGLE_APPLICATION_CREDENTIALS", "/tmp/service-account.json") | |
| 68 | 70 | t.Setenv("ZOT_SUBAGENT_TEST_VALUE", "not-secret") | |
| 69 | 71 | env := strings.Join(workerEnvironment("openai"), "\n") | |
| @@ -73,6 +75,8 @@ func TestWorkerEnvironmentRedactsProviderSecrets(t *testing.T) { | |||
| 73 | 75 | "HF_TOKEN=hf-secret", | |
| 74 | 76 | "GITHUB_TOKEN=github-secret", | |
| 75 | 77 | "CUSTOM_PROVIDER_TOKEN=token-secret", | |
| 78 | + | "CUSTOM_PROVIDER_KEY=key-secret", | |
| 79 | + | "AWS_ACCESS_KEY_ID=aws-access-secret", | |
| 76 | 80 | "GOOGLE_APPLICATION_CREDENTIALS=/tmp/service-account.json", | |
| 77 | 81 | } { | |
| 78 | 82 | if strings.Contains(env, secret) { | |
| @@ -88,6 +92,9 @@ func TestWorkerEnvironmentRedactsProviderSecrets(t *testing.T) { | |||
| 88 | 92 | if !strings.Contains(bedrockEnv, "AWS_BEARER_TOKEN_BEDROCK=bedrock-secret") { | |
| 89 | 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 | 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 | 325 | func TestNotifyPromptTurnEndIgnoresProviderToolLoopTurnEnd(t *testing.T) { | |
| 285 | 326 | called := make(chan struct{}, 1) | |
| 286 | 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 | 389 | func indexOf(xs []string, want string) int { | |
| 324 | 390 | for i, x := range xs { | |
| 325 | 391 | 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 { | |||
| 35 | 35 | func (f *Supervisor) validateSpawnOptions(req SpawnRequest) error { | |
| 36 | 36 | p := f.cfg.Policy | |
| 37 | 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 | 40 | if req.Timeout < 0 { | |
| 41 | 41 | 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.Policy | 36 | 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 { | |||
| 170 | 170 | } | |
| 171 | 171 | } | |
| 172 | 172 | ||
| 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. | |
| 176 | 176 | func (f *Supervisor) workerContext(timeout time.Duration) (context.Context, context.CancelFunc) { | |
| 177 | 177 | base := f.lifetimeCtx | |
| 178 | 178 | if base == nil { | |
| @@ -184,6 +184,9 @@ func (f *Supervisor) workerContext(timeout time.Duration) (context.Context, cont | |||
| 184 | 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 | 190 | func (f *Supervisor) SetRepoRoot(root string) { | |
| 188 | 191 | f.mu.Lock() | |
| 189 | 192 | defer f.mu.Unlock() | |
| @@ -214,16 +217,18 @@ func (f *Supervisor) FastMode() bool { | |||
| 214 | 217 | return f.cfg.FastMode | |
| 215 | 218 | } | |
| 216 | 219 | ||
| 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. | |
| 221 | 222 | func (f *Supervisor) SetProvider(provider string) { | |
| 222 | 223 | f.mu.Lock() | |
| 223 | 224 | f.cfg.Provider = strings.TrimSpace(provider) | |
| 224 | 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 | 232 | func (f *Supervisor) SetProviderSettings(baseURL string, insecureTLS bool) { | |
| 228 | 233 | f.mu.Lock() | |
| 229 | 234 | f.cfg.BaseURL = strings.TrimSpace(baseURL) | |
| @@ -681,11 +686,48 @@ func (f *Supervisor) Get(id string) *Agent { | |||
| 681 | 686 | 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 | 724 | // Stop cancels the agent's context. The Runner should observe the | |
| 685 | 725 | // cancellation and return promptly; the goroutine then finalises the | |
| 686 | 726 | // agent in StatusKilled. Also closes the supervisor-side inbox handle | |
| 687 | 727 | // 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 | 732 | // Stop is a no-op for any agent that's not in a live runnable state | |
| 691 | 733 | // — Done / Failed / Killed (already finalised). Reloaded Detached agents | |
| @@ -693,10 +735,24 @@ func (f *Supervisor) Get(id string) *Agent { | |||
| 693 | 735 | // shutdown command through the durable inbox instead of pretending no | |
| 694 | 736 | // process exists. | |
| 695 | 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 | 752 | f.operationMu.Lock() | |
| 697 | - | defer f.operationMu.Unlock() | |
| 698 | 753 | a := f.Get(id) | |
| 699 | 754 | if a == nil { | |
| 755 | + | f.operationMu.Unlock() | |
| 700 | 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 | 767 | if status == StatusDone || status == StatusFailed || status == StatusKilled { | |
| 712 | 768 | a.mu.Unlock() | |
| 713 | 769 | f.mu.Unlock() | |
| 770 | + | f.operationMu.Unlock() | |
| 714 | 771 | return nil | |
| 715 | 772 | } | |
| 716 | 773 | if status == StatusDetached { | |
| @@ -719,46 +776,10 @@ func (f *Supervisor) Stop(id string) error { | |||
| 719 | 776 | pid := a.ProcessPIDValue() | |
| 720 | 777 | a.mu.Unlock() | |
| 721 | 778 | 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) | |
| 762 | 783 | } | |
| 763 | 784 | a.status = StatusKilled | |
| 764 | 785 | a.activity = "canceling" | |
| @@ -773,6 +794,7 @@ func (f *Supervisor) Stop(id string) error { | |||
| 773 | 794 | } | |
| 774 | 795 | a.mu.Unlock() | |
| 775 | 796 | f.mu.Unlock() | |
| 797 | + | f.operationMu.Unlock() | |
| 776 | 798 | a.setTurnState(TurnCanceling, a.CurrentTurnID()) | |
| 777 | 799 | ||
| 778 | 800 | if status == StatusPending { | |
| @@ -790,12 +812,21 @@ func (f *Supervisor) Stop(id string) error { | |||
| 790 | 812 | _ = a.releaseLease() | |
| 791 | 813 | a.closeDone() | |
| 792 | 814 | f.schedule() | |
| 815 | + | if err := ctx.Err(); err != nil { | |
| 816 | + | return err | |
| 817 | + | } | |
| 793 | 818 | return nil | |
| 794 | 819 | } | |
| 795 | 820 | ||
| 796 | 821 | // Ask a live worker to shut down cleanly first. If the socket is not | |
| 797 | 822 | // ready, fall back immediately; a forceful context cancellation is | |
| 798 | 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 | 830 | graceful := false | |
| 800 | 831 | if a.inbox != nil { | |
| 801 | 832 | err := a.inbox.SendCommand(NewCommand(CommandAgentShutdown, a.ID, a.CurrentTurnID(), AgentShutdownPayload{})) | |
| @@ -816,6 +847,13 @@ func (f *Supervisor) Stop(id string) error { | |||
| 816 | 847 | defer timer.Stop() | |
| 817 | 848 | select { | |
| 818 | 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 | 857 | case <-timer.C: | |
| 820 | 858 | if a.cancel != nil { | |
| 821 | 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 | 927 | 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 | 943 | // StopAll cancels every running agent. Used on shutdown. | |
| 833 | 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 | 958 | agents := f.List() | |
| 959 | + | var firstErr error | |
| 835 | 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 | 970 | 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 | + | } | |
| 843 | 979 | } | |
| 980 | + | return firstErr | |
| 844 | 981 | } | |
| 845 | 982 | ||
| 846 | 983 | // 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 | 1179 | func (s agentSink) assistantMessage(text string) { s.a.appendAssistantMessage(text) } | |
| 1043 | 1180 | ||
| 1044 | 1181 | func truncate(s string, n int) string { | |
| 1045 | - | if len(s) <= n { | |
| 1182 | + | runes := []rune(s) | |
| 1183 | + | if len(runes) <= n { | |
| 1046 | 1184 | return s | |
| 1047 | 1185 | } | |
| 1048 | 1186 | if n <= 3 { | |
| 1049 | 1187 | return strings.Repeat(".", n) | |
| 1050 | 1188 | } | |
| 1051 | - | return s[:n-3] + "..." | |
| 1189 | + | return string(runes[:n-3]) + "..." | |
| 1052 | 1190 | } | |
| 1053 | 1191 | ||
| 1054 | 1192 | 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 subsequently | 173 | + | // workerContext derives a child worker context from the supervisor lifetime. |
| 174 | - | // spawned agents. Existing agents keep the directory they were started | 174 | + | // 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.lifetimeCtx | 177 | 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.FastMode | 217 | return f.cfg.FastMode | ||
| 215 | } | 218 | } | ||
| 216 | 219 | ||||
| 217 | - | // SetProviderSettings updates the effective connection settings used by | 220 | + | // SetProvider updates the provider used by subsequently spawned children. |
| 218 | - | // subsequently spawned children. Existing agents keep their persisted values | 221 | + | // 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 nil | 686 | 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 the | 724 | // Stop cancels the agent's context. The Runner should observe the | ||
| 685 | // cancellation and return promptly; the goroutine then finalises the | 725 | // cancellation and return promptly; the goroutine then finalises the | ||
| 686 | // agent in StatusKilled. Also closes the supervisor-side inbox handle | 726 | // agent in StatusKilled. Also closes the supervisor-side inbox handle | ||
| 687 | // so any pending send retries fail fast instead of dialing a | 727 | // 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 state | 732 | // Stop is a no-op for any agent that's not in a live runnable state | ||
| 691 | // — Done / Failed / Killed (already finalised). Reloaded Detached agents | 733 | // — 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 no | 735 | // 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 nil | 771 | 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 nil | 780 | + | // 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 = StatusKilled | 784 | 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 nil | 818 | return nil | ||
| 794 | } | 819 | } | ||
| 795 | 820 | ||||
| 796 | // Ask a live worker to shut down cleanly first. If the socket is not | 821 | // Ask a live worker to shut down cleanly first. If the socket is not | ||
| 797 | // ready, fall back immediately; a forceful context cancellation is | 822 | // 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 := false | 830 | 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 nil | 927 | 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. It | 983 | // 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 s | 1184 | 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 | |||
| 3 | 3 | import ( | |
| 4 | 4 | "context" | |
| 5 | 5 | "errors" | |
| 6 | + | "path/filepath" | |
| 7 | + | "runtime" | |
| 6 | 8 | "strings" | |
| 7 | 9 | "sync" | |
| 8 | 10 | "testing" | |
| 9 | 11 | "time" | |
| 10 | 12 | ) | |
| 11 | 13 | ||
| 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. | |
| 16 | 16 | func newTestSupervisor(t *testing.T, mk func(a *Agent) Runner) *Supervisor { | |
| 17 | 17 | t.Helper() | |
| 18 | 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 | 202 | func TestStopAfterDoneIsNoop(t *testing.T) { | |
| 145 | 203 | f := newTestSupervisor(t, func(a *Agent) Runner { | |
| 146 | 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 | 310 | func TestTaskSlug(t *testing.T) { | |
| 240 | 311 | cases := map[string]string{ | |
| 241 | 312 | "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-memory | 14 | + | // newTestSupervisor builds a Supervisor rooted in t.TempDir and configured |
| 13 | - | // and a Runner factory the test controls. Returns the subagent plus a | 15 | + | // 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) ( | |||
| 133 | 133 | } | |
| 134 | 134 | if !reuse { | |
| 135 | 135 | _ = os.RemoveAll(worktreeDir) | |
| 136 | + | _, _ = gitOutput(ctx, actualRoot, "worktree", "prune") | |
| 136 | 137 | if _, err := gitOutput(ctx, actualRoot, "worktree", "add", "--detach", worktreeDir, base); err != nil { | |
| 137 | 138 | _ = os.RemoveAll(worktreeDir) | |
| 138 | 139 | _, _ = gitOutput(ctx, actualRoot, "worktree", "prune") | |
| @@ -220,11 +221,11 @@ func (w *gitWorktreeHandle) Capture(ctx context.Context) (WorkspaceCapture, erro | |||
| 220 | 221 | if ctx == nil { | |
| 221 | 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 | 225 | if err != nil { | |
| 225 | 226 | return WorkspaceCapture{}, fmt.Errorf("subagents worktree status: %w", err) | |
| 226 | 227 | } | |
| 227 | - | changed := changedFilesFromStatus(string(statusBytes)) | |
| 228 | + | changed := changedFilesFromStatus(statusBytes) | |
| 228 | 229 | args := []string{"diff"} | |
| 229 | 230 | if w.capture == CapturePatch || w.capture == "" { | |
| 230 | 231 | args = append(args, "--binary") | |
| @@ -317,7 +318,7 @@ func gitUntrackedDiff(ctx context.Context, dir, name string, binary bool) ([]byt | |||
| 317 | 318 | if binary { | |
| 318 | 319 | args = append(args, "--binary") | |
| 319 | 320 | } | |
| 320 | - | args = append(args, "/dev/null", "--", name) | |
| 321 | + | args = append(args, os.DevNull, "--", name) | |
| 321 | 322 | cmd := exec.CommandContext(ctx, "git", args...) | |
| 322 | 323 | var stdout, stderr bytes.Buffer | |
| 323 | 324 | cmd.Stdout = &stdout | |
| @@ -348,23 +349,36 @@ func splitNUL(data []byte) []string { | |||
| 348 | 349 | return out | |
| 349 | 350 | } | |
| 350 | 351 | ||
| 351 | - | func changedFilesFromStatus(status string) []string { | |
| 352 | + | func changedFilesFromStatus(status []byte) []string { | |
| 352 | 353 | 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 { | |
| 356 | 362 | continue | |
| 357 | 363 | } | |
| 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 { | |
| 360 | 369 | continue | |
| 361 | 370 | } | |
| 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 | + | } | |
| 366 | 381 | } | |
| 367 | - | files = append(files, strings.Trim(path, "\"")) | |
| 368 | 382 | } | |
| 369 | 383 | return files | |
| 370 | 384 | } | |
| @@ -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.Buffer | 323 | var stdout, stderr bytes.Buffer | ||
| 323 | cmd.Stdout = &stdout | 324 | cmd.Stdout = &stdout | ||
| @@ -348,23 +349,36 @@ func splitNUL(data []byte) []string { | |||||
| 348 | return out | 349 | return out | ||
| 349 | } | 350 | } | ||
| 350 | 351 | ||||
| 351 | - | func changedFilesFromStatus(status string) []string { | 352 | + | func changedFilesFromStatus(status []byte) []string { |
| 352 | var files []string | 353 | 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 | continue | 362 | 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 | continue | 369 | continue | ||
| 361 | } | 370 | } | ||
| 362 | - | // Porcelain rename entries are `old -> new`; the new path is the | 371 | + | 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 files | 383 | return files | ||
| 370 | } | 384 | } | ||
M packages/agent/subagents/workspace_manager_test.go +1 -4
| @@ -13,10 +13,7 @@ func TestSpawnWorktreeCapturesBeforeCleanup(t *testing.T) { | |||
| 13 | 13 | if _, err := exec.LookPath("git"); err != nil { | |
| 14 | 14 | t.Skip("git is required") | |
| 15 | 15 | } | |
| 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) | |
| 20 | 17 | if err := os.WriteFile(filepath.Join(repo, "main.txt"), []byte("host\n"), 0o600); err != nil { | |
| 21 | 18 | t.Fatal(err) | |
| 22 | 19 | } | |
| @@ -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) { | |||
| 13 | 13 | if _, err := exec.LookPath("git"); err != nil { | |
| 14 | 14 | t.Skip("git is required") | |
| 15 | 15 | } | |
| 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) | |
| 25 | 17 | ||
| 26 | 18 | state := t.TempDir() | |
| 27 | 19 | handle, err := PrepareWorkspace(context.Background(), WorkspaceRequest{ | |
| @@ -65,15 +57,7 @@ func TestGitWorktreeWorkspaceReusesDetachedCheckout(t *testing.T) { | |||
| 65 | 57 | if _, err := exec.LookPath("git"); err != nil { | |
| 66 | 58 | t.Skip("git is required") | |
| 67 | 59 | } | |
| 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) | |
| 77 | 61 | ||
| 78 | 62 | state := t.TempDir() | |
| 79 | 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 | 134 | func TestSharedWorkspaceUsesRepositoryRoot(t *testing.T) { | |
| 107 | 135 | root := t.TempDir() | |
| 108 | 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 | 186 | func runGit(t *testing.T, dir string, args ...string) { | |
| 121 | 187 | t.Helper() | |
| 122 | 188 | 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 { | |||
| 60 | 60 | Thinking string `json:"thinking,omitempty"` | |
| 61 | 61 | Isolation string `json:"isolation,omitempty"` | |
| 62 | 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 | 66 | const subagentSpawnSchema = `{ | |
| @@ -149,7 +149,7 @@ func (t *SubagentSpawnTool) Execute(ctx context.Context, raw json.RawMessage, pr | |||
| 149 | 149 | } | |
| 150 | 150 | timeout = parsed | |
| 151 | 151 | } | |
| 152 | - | if a.MaxTurns < 0 { | |
| 152 | + | if a.MaxTurns != nil && *a.MaxTurns < 1 { | |
| 153 | 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 | 224 | agent, err := t.Supervisor.SpawnReq(ctx, subagents.SpawnRequest{ | |
| 221 | 225 | Task: task, | |
| 222 | 226 | Model: model, | |
| @@ -225,7 +229,7 @@ func (t *SubagentSpawnTool) Execute(ctx context.Context, raw json.RawMessage, pr | |||
| 225 | 229 | FastMode: fastModeOverride, | |
| 226 | 230 | Subagent: agentName, | |
| 227 | 231 | Timeout: timeout, | |
| 228 | - | MaxTurns: a.MaxTurns, | |
| 232 | + | MaxTurns: maxTurns, | |
| 229 | 233 | WorkspaceMode: workspaceMode, | |
| 230 | 234 | Tools: profileTools, | |
| 231 | 235 | }) | |
| @@ -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 = parsed | 150 | 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) { | |||
| 70 | 70 | if agents[0].Model != "gpt-5" || agents[0].Provider != "openai-codex" || agents[0].Reasoning != "medium" { | |
| 71 | 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 | 96 | 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) { | |||
| 264 | 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 | 297 | // SetMessages replaces the transcript (used when resuming a session). | |
| 268 | 298 | func (a *Agent) SetMessages(msgs []provider.Message) { | |
| 269 | 299 | a.mu.Lock() | |
| @@ -594,7 +624,7 @@ func (a *Agent) oneTurn(ctx context.Context, sink func(AgentEvent), turnContext | |||
| 594 | 624 | if err := provider.ValidateFastMode(a.Client.Name(), fastMode); err != nil { | |
| 595 | 625 | return provider.StopError, provider.Message{}, err | |
| 596 | 626 | } | |
| 597 | - | system := a.System | |
| 627 | + | system, tools := a.PromptConfig() | |
| 598 | 628 | if contextText := boundedTurnContext(turnContext); contextText != "" { | |
| 599 | 629 | if system != "" { | |
| 600 | 630 | system += "\n\n" | |
| @@ -613,7 +643,7 @@ func (a *Agent) oneTurn(ctx context.Context, sink func(AgentEvent), turnContext | |||
| 613 | 643 | // with "tool_use ids were found without tool_result blocks". The | |
| 614 | 644 | // repair is pure and a no-op on already-valid transcripts. | |
| 615 | 645 | Messages: repairToolUseResultPairs(a.Messages()), | |
| 616 | - | Tools: a.Tools.Specs(), | |
| 646 | + | Tools: tools.Specs(), | |
| 617 | 647 | Reasoning: a.Reasoning, | |
| 618 | 648 | FastMode: fastMode, | |
| 619 | 649 | MaxTokens: a.MaxTokens, | |
| @@ -723,6 +753,7 @@ func (a *Agent) executeTools(ctx context.Context, msg provider.Message, sink fun | |||
| 723 | 753 | var results []provider.Content | |
| 724 | 754 | var addedTools []string | |
| 725 | 755 | hadError := false | |
| 756 | + | tools := a.ToolsSnapshot() | |
| 726 | 757 | ||
| 727 | 758 | for _, c := range msg.Content { | |
| 728 | 759 | tc, ok := c.(provider.ToolCallBlock) | |
| @@ -739,7 +770,7 @@ func (a *Agent) executeTools(ctx context.Context, msg provider.Message, sink fun | |||
| 739 | 770 | IsError: res.IsError, | |
| 740 | 771 | }) | |
| 741 | 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 | 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 | 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 | 794 | if err != nil { | |
| 763 | 795 | return ToolResult{ | |
| 764 | 796 | 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{}, err | 625 | return provider.StopError, provider.Message{}, err | ||
| 596 | } | 626 | } | ||
| 597 | - | system := a.System | 627 | + | 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". The | 643 | // 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.Content | 753 | var results []provider.Content | ||
| 724 | var addedTools []string | 754 | var addedTools []string | ||
| 725 | hadError := false | 755 | 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()}}, | ||