feat(subagents): add manager lifecycle actions
- commit
- 4206363e9376b5384526deeca7764f88ce5312a6
- author
- brice · · 2026-08-07 11:20 UTC
- committer
- brice · · 2026-08-07 12:40 UTC
- parent
- 8a8e2b3
- signature
- GPG Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· arch-gpg Verified for the committer email
23 files · +1633 -108
M docs/subagents.md +3 -1
| @@ -125,7 +125,9 @@ subagent://<id>/patch | |||
| 125 | 125 | ||
| 126 | 126 | Use `/subagents resume-session <id>` to continue the existing session without replaying its original task. Use `/subagents restart-task <id>` only when intentionally starting the stored task again. Cancellation requests graceful shutdown first, then forcefully cancels after the configured grace period. | |
| 127 | 127 | ||
| 128 | - | When auto-subagents is enabled and permitted by launch-time policy, the model also receives the read-only `subagent_status` tool. Call it with `{}` to list workers visible to the active supervisor session, or pass `{"agent_id":"<id-or-unique-prefix>"}` to query one worker. Queries use in-process snapshots and never wait for a worker turn or process to finish; repeated calls or state inspection are not completion signals. The JSON response contains the worker id, a normalized lifecycle state (`starting`, `running`, `completed`, `failed`, `cancelled`, or `detached`), the underlying `process_state` and `turn_state`, start/update/finish timestamps, a bounded first-line task summary, and terminal-result metadata/reference when available. It intentionally omits prompts, transcripts, result output, credentials, provider settings, and filesystem paths. An unknown or ambiguous id is returned as a model-visible tool error; the existing `/subagents` dashboard and final-result notifications remain unchanged. An explicit `--tools` allowlist must include `subagent_status` when the status tool is needed, but it must not be polled solely to wait for completion. | |
| 128 | + | When auto-subagents is enabled and permitted by launch-time policy, the model can manage workers with `subagent_status`, `subagent_stop`, and `subagent_resume`. Call `subagent_status` with `{}` to list workers visible to the active supervisor session, or pass `{"agent_id":"<id-or-unique-prefix>"}` to query one worker. Queries use in-process snapshots and never wait for a worker turn or process to finish; repeated calls or state inspection are not completion signals. The JSON response contains the worker id, a normalized lifecycle state (`starting`, `running`, `completed`, `failed`, `cancelled`, or `detached`), the underlying `process_state` and `turn_state`, start/update/finish timestamps, a bounded first-line task summary, and terminal-result metadata/reference when available. It intentionally omits prompts, transcripts, result output, credentials, provider settings, and filesystem paths. | |
| 129 | + | ||
| 130 | + | Use `subagent_stop` with an `agent_id` to request termination of a stuck worker through the normal graceful-then-forceful supervisor lifecycle. Its `stop_requested` result confirms that shutdown has started; wait for the host completion update before attempting a restart. Use `subagent_resume` with an `agent_id` and `prompt` to send a new follow-up to an idle worker or to restart a terminal worker from its retained session with that follow-up as the initial turn. It never replays the original task, and its completion is delivered to the manager through the normal auto-subagent update. An unknown or ambiguous id is returned as a model-visible tool error; the existing `/subagents` dashboard and final-result notifications remain unchanged. An explicit `--tools` allowlist must include each manager tool that the model needs, and `subagent_status` must not be polled solely to wait for completion. | |
| 129 | 131 | ||
| 130 | 132 | ## Resource policy | |
| 131 | 133 | ||
| @@ -125,7 +125,9 @@ subagent://<id>/patch | |||||
| 125 | 125 | ||||
| 126 | Use `/subagents resume-session <id>` to continue the existing session without replaying its original task. Use `/subagents restart-task <id>` only when intentionally starting the stored task again. Cancellation requests graceful shutdown first, then forcefully cancels after the configured grace period. | 126 | Use `/subagents resume-session <id>` to continue the existing session without replaying its original task. Use `/subagents restart-task <id>` only when intentionally starting the stored task again. Cancellation requests graceful shutdown first, then forcefully cancels after the configured grace period. | ||
| 127 | 127 | ||||
| 128 | - | When auto-subagents is enabled and permitted by launch-time policy, the model also receives the read-only `subagent_status` tool. Call it with `{}` to list workers visible to the active supervisor session, or pass `{"agent_id":"<id-or-unique-prefix>"}` to query one worker. Queries use in-process snapshots and never wait for a worker turn or process to finish; repeated calls or state inspection are not completion signals. The JSON response contains the worker id, a normalized lifecycle state (`starting`, `running`, `completed`, `failed`, `cancelled`, or `detached`), the underlying `process_state` and `turn_state`, start/update/finish timestamps, a bounded first-line task summary, and terminal-result metadata/reference when available. It intentionally omits prompts, transcripts, result output, credentials, provider settings, and filesystem paths. An unknown or ambiguous id is returned as a model-visible tool error; the existing `/subagents` dashboard and final-result notifications remain unchanged. An explicit `--tools` allowlist must include `subagent_status` when the status tool is needed, but it must not be polled solely to wait for completion. | 128 | + | When auto-subagents is enabled and permitted by launch-time policy, the model can manage workers with `subagent_status`, `subagent_stop`, and `subagent_resume`. Call `subagent_status` with `{}` to list workers visible to the active supervisor session, or pass `{"agent_id":"<id-or-unique-prefix>"}` to query one worker. Queries use in-process snapshots and never wait for a worker turn or process to finish; repeated calls or state inspection are not completion signals. The JSON response contains the worker id, a normalized lifecycle state (`starting`, `running`, `completed`, `failed`, `cancelled`, or `detached`), the underlying `process_state` and `turn_state`, start/update/finish timestamps, a bounded first-line task summary, and terminal-result metadata/reference when available. It intentionally omits prompts, transcripts, result output, credentials, provider settings, and filesystem paths. |
| 129 | + | ||||
| 130 | + | Use `subagent_stop` with an `agent_id` to request termination of a stuck worker through the normal graceful-then-forceful supervisor lifecycle. Its `stop_requested` result confirms that shutdown has started; wait for the host completion update before attempting a restart. Use `subagent_resume` with an `agent_id` and `prompt` to send a new follow-up to an idle worker or to restart a terminal worker from its retained session with that follow-up as the initial turn. It never replays the original task, and its completion is delivered to the manager through the normal auto-subagent update. An unknown or ambiguous id is returned as a model-visible tool error; the existing `/subagents` dashboard and final-result notifications remain unchanged. An explicit `--tools` allowlist must include each manager tool that the model needs, and `subagent_status` must not be polled solely to wait for completion. | |||
| 129 | 131 | ||||
| 130 | ## Resource policy | 132 | ## Resource policy | ||
| 131 | 133 | ||||
M packages/agent/build.go +17 -2
| @@ -858,7 +858,11 @@ func Resolve(args Args, requireCred bool) (Resolved, error) { | |||
| 858 | 858 | append_ = append(append_, subagentsAddendum) | |
| 859 | 859 | } | |
| 860 | 860 | } | |
| 861 | - | append_ = append(append_, AutoSubagentsSystemAddendumFor(autoSubagentsToolAllowed(args))) | |
| 861 | + | append_ = append(append_, AutoSubagentsSystemAddendumFor( | |
| 862 | + | autoSubagentsToolAllowed(args), | |
| 863 | + | autoSubagentsStopToolAllowed(args), | |
| 864 | + | autoSubagentsResumeToolAllowed(args), | |
| 865 | + | )) | |
| 862 | 866 | } | |
| 863 | 867 | if selectedProfile != nil && selectedProfile.SystemPromptMode != "replace" && selectedProfile.SystemPrompt != "" { | |
| 864 | 868 | append_ = append(append_, selectedProfile.SystemPrompt) | |
| @@ -1317,8 +1321,19 @@ func autoSubagentsStatusToolAllowed(args Args) bool { | |||
| 1317 | 1321 | return autoSubagentsToolAllowedFor(args, "subagent_status") | |
| 1318 | 1322 | } | |
| 1319 | 1323 | ||
| 1324 | + | func autoSubagentsStopToolAllowed(args Args) bool { | |
| 1325 | + | return autoSubagentsToolAllowedFor(args, "subagent_stop") | |
| 1326 | + | } | |
| 1327 | + | ||
| 1328 | + | func autoSubagentsResumeToolAllowed(args Args) bool { | |
| 1329 | + | return autoSubagentsToolAllowedFor(args, "subagent_resume") | |
| 1330 | + | } | |
| 1331 | + | ||
| 1320 | 1332 | func autoSubagentsAnyToolAllowed(args Args) bool { | |
| 1321 | - | return autoSubagentsToolAllowed(args) || autoSubagentsStatusToolAllowed(args) | |
| 1333 | + | return autoSubagentsToolAllowed(args) || | |
| 1334 | + | autoSubagentsStatusToolAllowed(args) || | |
| 1335 | + | autoSubagentsStopToolAllowed(args) || | |
| 1336 | + | autoSubagentsResumeToolAllowed(args) | |
| 1322 | 1337 | } | |
| 1323 | 1338 | ||
| 1324 | 1339 | func autoSubagentsToolAllowedFor(args Args, toolName string) bool { | |
| @@ -858,7 +858,11 @@ func Resolve(args Args, requireCred bool) (Resolved, error) { | |||||
| 858 | append_ = append(append_, subagentsAddendum) | 858 | append_ = append(append_, subagentsAddendum) | ||
| 859 | } | 859 | } | ||
| 860 | } | 860 | } | ||
| 861 | - | append_ = append(append_, AutoSubagentsSystemAddendumFor(autoSubagentsToolAllowed(args))) | 861 | + | append_ = append(append_, AutoSubagentsSystemAddendumFor( |
| 862 | + | autoSubagentsToolAllowed(args), | |||
| 863 | + | autoSubagentsStopToolAllowed(args), | |||
| 864 | + | autoSubagentsResumeToolAllowed(args), | |||
| 865 | + | )) | |||
| 862 | } | 866 | } | ||
| 863 | if selectedProfile != nil && selectedProfile.SystemPromptMode != "replace" && selectedProfile.SystemPrompt != "" { | 867 | if selectedProfile != nil && selectedProfile.SystemPromptMode != "replace" && selectedProfile.SystemPrompt != "" { | ||
| 864 | append_ = append(append_, selectedProfile.SystemPrompt) | 868 | append_ = append(append_, selectedProfile.SystemPrompt) | ||
| @@ -1317,8 +1321,19 @@ func autoSubagentsStatusToolAllowed(args Args) bool { | |||||
| 1317 | return autoSubagentsToolAllowedFor(args, "subagent_status") | 1321 | return autoSubagentsToolAllowedFor(args, "subagent_status") | ||
| 1318 | } | 1322 | } | ||
| 1319 | 1323 | ||||
| 1324 | + | func autoSubagentsStopToolAllowed(args Args) bool { | |||
| 1325 | + | return autoSubagentsToolAllowedFor(args, "subagent_stop") | |||
| 1326 | + | } | |||
| 1327 | + | ||||
| 1328 | + | func autoSubagentsResumeToolAllowed(args Args) bool { | |||
| 1329 | + | return autoSubagentsToolAllowedFor(args, "subagent_resume") | |||
| 1330 | + | } | |||
| 1331 | + | ||||
| 1320 | func autoSubagentsAnyToolAllowed(args Args) bool { | 1332 | func autoSubagentsAnyToolAllowed(args Args) bool { | ||
| 1321 | - | return autoSubagentsToolAllowed(args) || autoSubagentsStatusToolAllowed(args) | 1333 | + | return autoSubagentsToolAllowed(args) || |
| 1334 | + | autoSubagentsStatusToolAllowed(args) || | |||
| 1335 | + | autoSubagentsStopToolAllowed(args) || | |||
| 1336 | + | autoSubagentsResumeToolAllowed(args) | |||
| 1322 | } | 1337 | } | ||
| 1323 | 1338 | ||||
| 1324 | func autoSubagentsToolAllowedFor(args Args, toolName string) bool { | 1339 | func autoSubagentsToolAllowedFor(args Args, toolName string) bool { | ||
M packages/agent/build_test.go +11 -1
| @@ -235,11 +235,15 @@ func TestAutoSubagentsToolPoliciesTrackEachTool(t *testing.T) { | |||
| 235 | 235 | tools []string | |
| 236 | 236 | wantSpawn bool | |
| 237 | 237 | wantStatus bool | |
| 238 | + | wantStop bool | |
| 239 | + | wantResume bool | |
| 238 | 240 | wantAny bool | |
| 239 | 241 | }{ | |
| 240 | - | {name: "default", wantSpawn: true, wantStatus: true, wantAny: true}, | |
| 242 | + | {name: "default", wantSpawn: true, wantStatus: true, wantStop: true, wantResume: true, wantAny: true}, | |
| 241 | 243 | {name: "spawn", tools: []string{"subagent_spawn"}, wantSpawn: true, wantAny: true}, | |
| 242 | 244 | {name: "status", tools: []string{"subagent_status"}, wantStatus: true, wantAny: true}, | |
| 245 | + | {name: "stop", tools: []string{"subagent_stop"}, wantStop: true, wantAny: true}, | |
| 246 | + | {name: "resume", tools: []string{"subagent_resume"}, wantResume: true, wantAny: true}, | |
| 243 | 247 | {name: "other", tools: []string{"read"}}, | |
| 244 | 248 | {name: "no tools", wantAny: false, tools: nil}, | |
| 245 | 249 | } { | |
| @@ -254,6 +258,12 @@ func TestAutoSubagentsToolPoliciesTrackEachTool(t *testing.T) { | |||
| 254 | 258 | if got := autoSubagentsStatusToolAllowed(args); got != tc.wantStatus { | |
| 255 | 259 | t.Fatalf("status allowed = %v, want %v", got, tc.wantStatus) | |
| 256 | 260 | } | |
| 261 | + | if got := autoSubagentsStopToolAllowed(args); got != tc.wantStop { | |
| 262 | + | t.Fatalf("stop allowed = %v, want %v", got, tc.wantStop) | |
| 263 | + | } | |
| 264 | + | if got := autoSubagentsResumeToolAllowed(args); got != tc.wantResume { | |
| 265 | + | t.Fatalf("resume allowed = %v, want %v", got, tc.wantResume) | |
| 266 | + | } | |
| 257 | 267 | if got := autoSubagentsAnyToolAllowed(args); got != tc.wantAny { | |
| 258 | 268 | t.Fatalf("any allowed = %v, want %v", got, tc.wantAny) | |
| 259 | 269 | } | |
| @@ -235,11 +235,15 @@ func TestAutoSubagentsToolPoliciesTrackEachTool(t *testing.T) { | |||||
| 235 | tools []string | 235 | tools []string | ||
| 236 | wantSpawn bool | 236 | wantSpawn bool | ||
| 237 | wantStatus bool | 237 | wantStatus bool | ||
| 238 | + | wantStop bool | |||
| 239 | + | wantResume bool | |||
| 238 | wantAny bool | 240 | wantAny bool | ||
| 239 | }{ | 241 | }{ | ||
| 240 | - | {name: "default", wantSpawn: true, wantStatus: true, wantAny: true}, | 242 | + | {name: "default", wantSpawn: true, wantStatus: true, wantStop: true, wantResume: true, wantAny: true}, |
| 241 | {name: "spawn", tools: []string{"subagent_spawn"}, wantSpawn: true, wantAny: true}, | 243 | {name: "spawn", tools: []string{"subagent_spawn"}, wantSpawn: true, wantAny: true}, | ||
| 242 | {name: "status", tools: []string{"subagent_status"}, wantStatus: true, wantAny: true}, | 244 | {name: "status", tools: []string{"subagent_status"}, wantStatus: true, wantAny: true}, | ||
| 245 | + | {name: "stop", tools: []string{"subagent_stop"}, wantStop: true, wantAny: true}, | |||
| 246 | + | {name: "resume", tools: []string{"subagent_resume"}, wantResume: true, wantAny: true}, | |||
| 243 | {name: "other", tools: []string{"read"}}, | 247 | {name: "other", tools: []string{"read"}}, | ||
| 244 | {name: "no tools", wantAny: false, tools: nil}, | 248 | {name: "no tools", wantAny: false, tools: nil}, | ||
| 245 | } { | 249 | } { | ||
| @@ -254,6 +258,12 @@ func TestAutoSubagentsToolPoliciesTrackEachTool(t *testing.T) { | |||||
| 254 | if got := autoSubagentsStatusToolAllowed(args); got != tc.wantStatus { | 258 | if got := autoSubagentsStatusToolAllowed(args); got != tc.wantStatus { | ||
| 255 | t.Fatalf("status allowed = %v, want %v", got, tc.wantStatus) | 259 | t.Fatalf("status allowed = %v, want %v", got, tc.wantStatus) | ||
| 256 | } | 260 | } | ||
| 261 | + | if got := autoSubagentsStopToolAllowed(args); got != tc.wantStop { | |||
| 262 | + | t.Fatalf("stop allowed = %v, want %v", got, tc.wantStop) | |||
| 263 | + | } | |||
| 264 | + | if got := autoSubagentsResumeToolAllowed(args); got != tc.wantResume { | |||
| 265 | + | t.Fatalf("resume allowed = %v, want %v", got, tc.wantResume) | |||
| 266 | + | } | |||
| 257 | if got := autoSubagentsAnyToolAllowed(args); got != tc.wantAny { | 267 | if got := autoSubagentsAnyToolAllowed(args); got != tc.wantAny { | ||
| 258 | t.Fatalf("any allowed = %v, want %v", got, tc.wantAny) | 268 | t.Fatalf("any allowed = %v, want %v", got, tc.wantAny) | ||
| 259 | } | 269 | } | ||
M packages/agent/cli.go +59 -25
| @@ -1454,6 +1454,16 @@ func runInteractive(ctx context.Context, args Args, version string) error { | |||
| 1454 | 1454 | iv.TrackSubagentWorker(a, task) | |
| 1455 | 1455 | } | |
| 1456 | 1456 | } | |
| 1457 | + | onResumedSupervisor := func(a *subagents.Agent, prompt string) { | |
| 1458 | + | if iv != nil { | |
| 1459 | + | iv.TrackResumedSubagentWorker(a, prompt) | |
| 1460 | + | } | |
| 1461 | + | } | |
| 1462 | + | onStopRequestedSupervisor := func(a *subagents.Agent) { | |
| 1463 | + | if iv != nil { | |
| 1464 | + | iv.TrackStoppedSubagentWorker(a) | |
| 1465 | + | } | |
| 1466 | + | } | |
| 1457 | 1467 | resolveSubagent := func(name string) (*subagents.Profile, error) { | |
| 1458 | 1468 | return findSubagentProfile(r.CWD, name) | |
| 1459 | 1469 | } | |
| @@ -1498,6 +1508,22 @@ func runInteractive(ctx context.Context, args Args, version string) error { | |||
| 1498 | 1508 | } | |
| 1499 | 1509 | reg[statusTool.Name()] = statusTool | |
| 1500 | 1510 | } | |
| 1511 | + | if autoSubagentsStopToolAllowed(args) { | |
| 1512 | + | stopTool := &tools.SubagentStopTool{ | |
| 1513 | + | Supervisor: subagentsMgr, | |
| 1514 | + | Enabled: AutoSubagentsEnabled, | |
| 1515 | + | OnStopRequested: onStopRequestedSupervisor, | |
| 1516 | + | } | |
| 1517 | + | reg[stopTool.Name()] = stopTool | |
| 1518 | + | } | |
| 1519 | + | if autoSubagentsResumeToolAllowed(args) { | |
| 1520 | + | resumeTool := &tools.SubagentResumeTool{ | |
| 1521 | + | Supervisor: subagentsMgr, | |
| 1522 | + | Enabled: AutoSubagentsEnabled, | |
| 1523 | + | OnResumed: onResumedSupervisor, | |
| 1524 | + | } | |
| 1525 | + | reg[resumeTool.Name()] = resumeTool | |
| 1526 | + | } | |
| 1501 | 1527 | return reg | |
| 1502 | 1528 | } | |
| 1503 | 1529 | prepareInteractiveRegistry := func(reg core.Registry) core.Registry { | |
| @@ -2216,6 +2242,8 @@ func runInteractive(ctx context.Context, args Args, version string) error { | |||
| 2216 | 2242 | subagentsAddendum := subagents.SystemPromptAddendum(discoveredSubagents) | |
| 2217 | 2243 | autoSubagentsToolAllowedForSession := autoSubagentsToolAllowed(args) | |
| 2218 | 2244 | autoSubagentsStatusToolAllowedForSession := autoSubagentsStatusToolAllowed(args) | |
| 2245 | + | autoSubagentsStopToolAllowedForSession := autoSubagentsStopToolAllowed(args) | |
| 2246 | + | autoSubagentsResumeToolAllowedForSession := autoSubagentsResumeToolAllowed(args) | |
| 2219 | 2247 | webSearchToolAllowedForInvocation := webSearchToolAllowedForSession(args) | |
| 2220 | 2248 | webSearchInvocationOverride := webSearchExplicitlyEnabledForSession(args) | |
| 2221 | 2249 | setWebSearchAvailable := func(available bool) { | |
| @@ -2243,6 +2271,8 @@ func runInteractive(ctx context.Context, args Args, version string) error { | |||
| 2243 | 2271 | WebSearchInvocationOverride: webSearchInvocationOverride, | |
| 2244 | 2272 | AutoSubagentsToolAllowed: &autoSubagentsToolAllowedForSession, | |
| 2245 | 2273 | AutoSubagentsStatusToolAllowed: &autoSubagentsStatusToolAllowedForSession, | |
| 2274 | + | AutoSubagentsStopToolAllowed: &autoSubagentsStopToolAllowedForSession, | |
| 2275 | + | AutoSubagentsResumeToolAllowed: &autoSubagentsResumeToolAllowedForSession, | |
| 2246 | 2276 | FastMode: &fastMode, | |
| 2247 | 2277 | LSPEnabled: initialCfg.LSPEnabled, | |
| 2248 | 2278 | SubagentLSPEnabled: initialCfg.SubagentLSPEnabled, | |
| @@ -2260,31 +2290,35 @@ func runInteractive(ctx context.Context, args Args, version string) error { | |||
| 2260 | 2290 | FlatTools: initialCfg.FlatToolRender(), | |
| 2261 | 2291 | CompactUser: initialCfg.CompactUserInput(), | |
| 2262 | 2292 | ExtensionThemes: extMgr.ThemeOptions, | |
| 2263 | - | AutoSubagentsSystemAddendum: AutoSubagentsSystemAddendumFor(autoSubagentsToolAllowedForSession), | |
| 2264 | - | SubagentsSystemAddendum: subagentsAddendum, | |
| 2265 | - | SettingsStore: configSettingsStore{}, | |
| 2266 | - | Model: r.Model, | |
| 2267 | - | Provider: r.Provider, | |
| 2268 | - | AuthMethod: r.AuthMethod, | |
| 2269 | - | BaseURL: r.BaseURL, | |
| 2270 | - | Reasoning: r.Reasoning, | |
| 2271 | - | SystemPrompt: r.SystemPrompt, | |
| 2272 | - | Tools: r.ToolRegistry, | |
| 2273 | - | MaxSteps: r.MaxSteps, | |
| 2274 | - | CWD: r.CWD, | |
| 2275 | - | StartupAgentName: args.AgentName, | |
| 2276 | - | StartupContextPaths: instructionContextPaths(r.ContextFiles), | |
| 2277 | - | StartupExtensionNames: startupExtensionNames(extMgr.All()), | |
| 2278 | - | StartupExtensionErrors: startupExtensionErrors, | |
| 2279 | - | StartupSkillNames: startupSkillNames(startupSkills), | |
| 2280 | - | ShowInstructionsAtStartup: initialCfg.ShowInstructionsAtStartup, | |
| 2281 | - | ZutHome: ZutHome(), | |
| 2282 | - | SessionsRoot: agentSessionsRoot(ZutHome(), args), | |
| 2283 | - | Version: version, | |
| 2284 | - | UpdateInfoChan: updateCh, | |
| 2285 | - | Sandbox: sharedSandbox, | |
| 2286 | - | Agent: ag, | |
| 2287 | - | InitialCompactHandoff: currentCompactHandoff(), | |
| 2293 | + | AutoSubagentsSystemAddendum: AutoSubagentsSystemAddendumFor( | |
| 2294 | + | autoSubagentsToolAllowedForSession, | |
| 2295 | + | autoSubagentsStopToolAllowedForSession, | |
| 2296 | + | autoSubagentsResumeToolAllowedForSession, | |
| 2297 | + | ), | |
| 2298 | + | SubagentsSystemAddendum: subagentsAddendum, | |
| 2299 | + | SettingsStore: configSettingsStore{}, | |
| 2300 | + | Model: r.Model, | |
| 2301 | + | Provider: r.Provider, | |
| 2302 | + | AuthMethod: r.AuthMethod, | |
| 2303 | + | BaseURL: r.BaseURL, | |
| 2304 | + | Reasoning: r.Reasoning, | |
| 2305 | + | SystemPrompt: r.SystemPrompt, | |
| 2306 | + | Tools: r.ToolRegistry, | |
| 2307 | + | MaxSteps: r.MaxSteps, | |
| 2308 | + | CWD: r.CWD, | |
| 2309 | + | StartupAgentName: args.AgentName, | |
| 2310 | + | StartupContextPaths: instructionContextPaths(r.ContextFiles), | |
| 2311 | + | StartupExtensionNames: startupExtensionNames(extMgr.All()), | |
| 2312 | + | StartupExtensionErrors: startupExtensionErrors, | |
| 2313 | + | StartupSkillNames: startupSkillNames(startupSkills), | |
| 2314 | + | ShowInstructionsAtStartup: initialCfg.ShowInstructionsAtStartup, | |
| 2315 | + | ZutHome: ZutHome(), | |
| 2316 | + | SessionsRoot: agentSessionsRoot(ZutHome(), args), | |
| 2317 | + | Version: version, | |
| 2318 | + | UpdateInfoChan: updateCh, | |
| 2319 | + | Sandbox: sharedSandbox, | |
| 2320 | + | Agent: ag, | |
| 2321 | + | InitialCompactHandoff: currentCompactHandoff(), | |
| 2288 | 2322 | InitialSessionTitle: func() string { | |
| 2289 | 2323 | if sess == nil || sessionTitlePending { | |
| 2290 | 2324 | return "" | |
| @@ -1454,6 +1454,16 @@ func runInteractive(ctx context.Context, args Args, version string) error { | |||||
| 1454 | iv.TrackSubagentWorker(a, task) | 1454 | iv.TrackSubagentWorker(a, task) | ||
| 1455 | } | 1455 | } | ||
| 1456 | } | 1456 | } | ||
| 1457 | + | onResumedSupervisor := func(a *subagents.Agent, prompt string) { | |||
| 1458 | + | if iv != nil { | |||
| 1459 | + | iv.TrackResumedSubagentWorker(a, prompt) | |||
| 1460 | + | } | |||
| 1461 | + | } | |||
| 1462 | + | onStopRequestedSupervisor := func(a *subagents.Agent) { | |||
| 1463 | + | if iv != nil { | |||
| 1464 | + | iv.TrackStoppedSubagentWorker(a) | |||
| 1465 | + | } | |||
| 1466 | + | } | |||
| 1457 | resolveSubagent := func(name string) (*subagents.Profile, error) { | 1467 | resolveSubagent := func(name string) (*subagents.Profile, error) { | ||
| 1458 | return findSubagentProfile(r.CWD, name) | 1468 | return findSubagentProfile(r.CWD, name) | ||
| 1459 | } | 1469 | } | ||
| @@ -1498,6 +1508,22 @@ func runInteractive(ctx context.Context, args Args, version string) error { | |||||
| 1498 | } | 1508 | } | ||
| 1499 | reg[statusTool.Name()] = statusTool | 1509 | reg[statusTool.Name()] = statusTool | ||
| 1500 | } | 1510 | } | ||
| 1511 | + | if autoSubagentsStopToolAllowed(args) { | |||
| 1512 | + | stopTool := &tools.SubagentStopTool{ | |||
| 1513 | + | Supervisor: subagentsMgr, | |||
| 1514 | + | Enabled: AutoSubagentsEnabled, | |||
| 1515 | + | OnStopRequested: onStopRequestedSupervisor, | |||
| 1516 | + | } | |||
| 1517 | + | reg[stopTool.Name()] = stopTool | |||
| 1518 | + | } | |||
| 1519 | + | if autoSubagentsResumeToolAllowed(args) { | |||
| 1520 | + | resumeTool := &tools.SubagentResumeTool{ | |||
| 1521 | + | Supervisor: subagentsMgr, | |||
| 1522 | + | Enabled: AutoSubagentsEnabled, | |||
| 1523 | + | OnResumed: onResumedSupervisor, | |||
| 1524 | + | } | |||
| 1525 | + | reg[resumeTool.Name()] = resumeTool | |||
| 1526 | + | } | |||
| 1501 | return reg | 1527 | return reg | ||
| 1502 | } | 1528 | } | ||
| 1503 | prepareInteractiveRegistry := func(reg core.Registry) core.Registry { | 1529 | prepareInteractiveRegistry := func(reg core.Registry) core.Registry { | ||
| @@ -2216,6 +2242,8 @@ func runInteractive(ctx context.Context, args Args, version string) error { | |||||
| 2216 | subagentsAddendum := subagents.SystemPromptAddendum(discoveredSubagents) | 2242 | subagentsAddendum := subagents.SystemPromptAddendum(discoveredSubagents) | ||
| 2217 | autoSubagentsToolAllowedForSession := autoSubagentsToolAllowed(args) | 2243 | autoSubagentsToolAllowedForSession := autoSubagentsToolAllowed(args) | ||
| 2218 | autoSubagentsStatusToolAllowedForSession := autoSubagentsStatusToolAllowed(args) | 2244 | autoSubagentsStatusToolAllowedForSession := autoSubagentsStatusToolAllowed(args) | ||
| 2245 | + | autoSubagentsStopToolAllowedForSession := autoSubagentsStopToolAllowed(args) | |||
| 2246 | + | autoSubagentsResumeToolAllowedForSession := autoSubagentsResumeToolAllowed(args) | |||
| 2219 | webSearchToolAllowedForInvocation := webSearchToolAllowedForSession(args) | 2247 | webSearchToolAllowedForInvocation := webSearchToolAllowedForSession(args) | ||
| 2220 | webSearchInvocationOverride := webSearchExplicitlyEnabledForSession(args) | 2248 | webSearchInvocationOverride := webSearchExplicitlyEnabledForSession(args) | ||
| 2221 | setWebSearchAvailable := func(available bool) { | 2249 | setWebSearchAvailable := func(available bool) { | ||
| @@ -2243,6 +2271,8 @@ func runInteractive(ctx context.Context, args Args, version string) error { | |||||
| 2243 | WebSearchInvocationOverride: webSearchInvocationOverride, | 2271 | WebSearchInvocationOverride: webSearchInvocationOverride, | ||
| 2244 | AutoSubagentsToolAllowed: &autoSubagentsToolAllowedForSession, | 2272 | AutoSubagentsToolAllowed: &autoSubagentsToolAllowedForSession, | ||
| 2245 | AutoSubagentsStatusToolAllowed: &autoSubagentsStatusToolAllowedForSession, | 2273 | AutoSubagentsStatusToolAllowed: &autoSubagentsStatusToolAllowedForSession, | ||
| 2274 | + | AutoSubagentsStopToolAllowed: &autoSubagentsStopToolAllowedForSession, | |||
| 2275 | + | AutoSubagentsResumeToolAllowed: &autoSubagentsResumeToolAllowedForSession, | |||
| 2246 | FastMode: &fastMode, | 2276 | FastMode: &fastMode, | ||
| 2247 | LSPEnabled: initialCfg.LSPEnabled, | 2277 | LSPEnabled: initialCfg.LSPEnabled, | ||
| 2248 | SubagentLSPEnabled: initialCfg.SubagentLSPEnabled, | 2278 | SubagentLSPEnabled: initialCfg.SubagentLSPEnabled, | ||
| @@ -2260,31 +2290,35 @@ func runInteractive(ctx context.Context, args Args, version string) error { | |||||
| 2260 | FlatTools: initialCfg.FlatToolRender(), | 2290 | FlatTools: initialCfg.FlatToolRender(), | ||
| 2261 | CompactUser: initialCfg.CompactUserInput(), | 2291 | CompactUser: initialCfg.CompactUserInput(), | ||
| 2262 | ExtensionThemes: extMgr.ThemeOptions, | 2292 | ExtensionThemes: extMgr.ThemeOptions, | ||
| 2263 | - | AutoSubagentsSystemAddendum: AutoSubagentsSystemAddendumFor(autoSubagentsToolAllowedForSession), | 2293 | + | AutoSubagentsSystemAddendum: AutoSubagentsSystemAddendumFor( |
| 2264 | - | SubagentsSystemAddendum: subagentsAddendum, | 2294 | + | autoSubagentsToolAllowedForSession, |
| 2265 | - | SettingsStore: configSettingsStore{}, | 2295 | + | autoSubagentsStopToolAllowedForSession, |
| 2266 | - | Model: r.Model, | 2296 | + | autoSubagentsResumeToolAllowedForSession, |
| 2267 | - | Provider: r.Provider, | 2297 | + | ), |
| 2268 | - | AuthMethod: r.AuthMethod, | 2298 | + | SubagentsSystemAddendum: subagentsAddendum, |
| 2269 | - | BaseURL: r.BaseURL, | 2299 | + | SettingsStore: configSettingsStore{}, |
| 2270 | - | Reasoning: r.Reasoning, | 2300 | + | Model: r.Model, |
| 2271 | - | SystemPrompt: r.SystemPrompt, | 2301 | + | Provider: r.Provider, |
| 2272 | - | Tools: r.ToolRegistry, | 2302 | + | AuthMethod: r.AuthMethod, |
| 2273 | - | MaxSteps: r.MaxSteps, | 2303 | + | BaseURL: r.BaseURL, |
| 2274 | - | CWD: r.CWD, | 2304 | + | Reasoning: r.Reasoning, |
| 2275 | - | StartupAgentName: args.AgentName, | 2305 | + | SystemPrompt: r.SystemPrompt, |
| 2276 | - | StartupContextPaths: instructionContextPaths(r.ContextFiles), | 2306 | + | Tools: r.ToolRegistry, |
| 2277 | - | StartupExtensionNames: startupExtensionNames(extMgr.All()), | 2307 | + | MaxSteps: r.MaxSteps, |
| 2278 | - | StartupExtensionErrors: startupExtensionErrors, | 2308 | + | CWD: r.CWD, |
| 2279 | - | StartupSkillNames: startupSkillNames(startupSkills), | 2309 | + | StartupAgentName: args.AgentName, |
| 2280 | - | ShowInstructionsAtStartup: initialCfg.ShowInstructionsAtStartup, | 2310 | + | StartupContextPaths: instructionContextPaths(r.ContextFiles), |
| 2281 | - | ZutHome: ZutHome(), | 2311 | + | StartupExtensionNames: startupExtensionNames(extMgr.All()), |
| 2282 | - | SessionsRoot: agentSessionsRoot(ZutHome(), args), | 2312 | + | StartupExtensionErrors: startupExtensionErrors, |
| 2283 | - | Version: version, | 2313 | + | StartupSkillNames: startupSkillNames(startupSkills), |
| 2284 | - | UpdateInfoChan: updateCh, | 2314 | + | ShowInstructionsAtStartup: initialCfg.ShowInstructionsAtStartup, |
| 2285 | - | Sandbox: sharedSandbox, | 2315 | + | ZutHome: ZutHome(), |
| 2286 | - | Agent: ag, | 2316 | + | SessionsRoot: agentSessionsRoot(ZutHome(), args), |
| 2287 | - | InitialCompactHandoff: currentCompactHandoff(), | 2317 | + | Version: version, |
| 2318 | + | UpdateInfoChan: updateCh, | |||
| 2319 | + | Sandbox: sharedSandbox, | |||
| 2320 | + | Agent: ag, | |||
| 2321 | + | InitialCompactHandoff: currentCompactHandoff(), | |||
| 2288 | InitialSessionTitle: func() string { | 2322 | InitialSessionTitle: func() string { | ||
| 2289 | if sess == nil || sessionTitlePending { | 2323 | if sess == nil || sessionTitlePending { | ||
| 2290 | return "" | 2324 | return "" | ||
M packages/agent/modes/auto_subagents_policy_test.go +22 -4
| @@ -102,7 +102,7 @@ func TestAutoSubagentsSettingsDisableUnavailablePolicy(t *testing.T) { | |||
| 102 | 102 | name: "launch policy excludes tool", | |
| 103 | 103 | supervisor: subagents.New(subagents.Config{Root: t.TempDir(), RepoRoot: t.TempDir()}), | |
| 104 | 104 | allowed: &falseValue, | |
| 105 | - | wantHint: "launch-time tool policy excludes subagent_spawn", | |
| 105 | + | wantHint: "launch-time tool policy excludes subagent manager tools", | |
| 106 | 106 | }, | |
| 107 | 107 | } | |
| 108 | 108 | for _, tt := range tests { | |
| @@ -162,6 +162,12 @@ func TestAutoSubagentsToolRegistrationHonorsLaunchPolicy(t *testing.T) { | |||
| 162 | 162 | if _, ok := iv.agent.Tools["subagent_status"]; ok { | |
| 163 | 163 | t.Fatal("subagent_status registered despite launch-time policy") | |
| 164 | 164 | } | |
| 165 | + | if _, ok := iv.agent.Tools["subagent_stop"]; ok { | |
| 166 | + | t.Fatal("subagent_stop registered despite launch-time policy") | |
| 167 | + | } | |
| 168 | + | if _, ok := iv.agent.Tools["subagent_resume"]; ok { | |
| 169 | + | t.Fatal("subagent_resume registered despite launch-time policy") | |
| 170 | + | } | |
| 165 | 171 | iv.applySettingToggle("auto_subagents_enabled", true) | |
| 166 | 172 | iv.mu.Lock() | |
| 167 | 173 | statusErr := iv.statusErr | |
| @@ -180,17 +186,25 @@ func TestAutoSubagentsToolRegistrationHonorsSeparateLaunchPolicies(t *testing.T) | |||
| 180 | 186 | name string | |
| 181 | 187 | spawnAllowed bool | |
| 182 | 188 | statusAllowed bool | |
| 189 | + | stopAllowed bool | |
| 190 | + | resumeAllowed bool | |
| 183 | 191 | wantSpawn bool | |
| 184 | 192 | wantStatus bool | |
| 193 | + | wantStop bool | |
| 194 | + | wantResume bool | |
| 185 | 195 | }{ | |
| 186 | 196 | {name: "status only", statusAllowed: true, wantStatus: true}, | |
| 187 | 197 | {name: "spawn only", spawnAllowed: true, wantSpawn: true}, | |
| 188 | - | {name: "both", spawnAllowed: true, statusAllowed: true, wantSpawn: true, wantStatus: true}, | |
| 198 | + | {name: "stop only", stopAllowed: true, wantStop: true}, | |
| 199 | + | {name: "resume only", resumeAllowed: true, wantResume: true}, | |
| 200 | + | {name: "all", spawnAllowed: true, statusAllowed: true, stopAllowed: true, resumeAllowed: true, wantSpawn: true, wantStatus: true, wantStop: true, wantResume: true}, | |
| 189 | 201 | } { | |
| 190 | 202 | t.Run(tc.name, func(t *testing.T) { | |
| 191 | 203 | enabled := true | |
| 192 | 204 | spawnAllowed := tc.spawnAllowed | |
| 193 | 205 | statusAllowed := tc.statusAllowed | |
| 206 | + | stopAllowed := tc.stopAllowed | |
| 207 | + | resumeAllowed := tc.resumeAllowed | |
| 194 | 208 | supervisor := subagents.New(subagents.Config{Root: t.TempDir(), RepoRoot: t.TempDir()}) | |
| 195 | 209 | t.Cleanup(supervisor.StopAll) | |
| 196 | 210 | iv := &Interactive{ | |
| @@ -199,6 +213,8 @@ func TestAutoSubagentsToolRegistrationHonorsSeparateLaunchPolicies(t *testing.T) | |||
| 199 | 213 | AutoSubagentsEnabled: &enabled, | |
| 200 | 214 | AutoSubagentsToolAllowed: &spawnAllowed, | |
| 201 | 215 | AutoSubagentsStatusToolAllowed: &statusAllowed, | |
| 216 | + | AutoSubagentsStopToolAllowed: &stopAllowed, | |
| 217 | + | AutoSubagentsResumeToolAllowed: &resumeAllowed, | |
| 202 | 218 | Supervisor: supervisor, | |
| 203 | 219 | }, | |
| 204 | 220 | dirty: make(chan struct{}, 1), | |
| @@ -207,8 +223,10 @@ func TestAutoSubagentsToolRegistrationHonorsSeparateLaunchPolicies(t *testing.T) | |||
| 207 | 223 | iv.applyAutoSubagentsTool(true) | |
| 208 | 224 | _, gotSpawn := iv.agent.Tools["subagent_spawn"] | |
| 209 | 225 | _, gotStatus := iv.agent.Tools["subagent_status"] | |
| 210 | - | if gotSpawn != tc.wantSpawn || gotStatus != tc.wantStatus { | |
| 211 | - | t.Fatalf("registered tools spawn=%v status=%v, want spawn=%v status=%v", gotSpawn, gotStatus, tc.wantSpawn, tc.wantStatus) | |
| 226 | + | _, gotStop := iv.agent.Tools["subagent_stop"] | |
| 227 | + | _, gotResume := iv.agent.Tools["subagent_resume"] | |
| 228 | + | if gotSpawn != tc.wantSpawn || gotStatus != tc.wantStatus || gotStop != tc.wantStop || gotResume != tc.wantResume { | |
| 229 | + | t.Fatalf("registered tools spawn=%v status=%v stop=%v resume=%v, want spawn=%v status=%v stop=%v resume=%v", gotSpawn, gotStatus, gotStop, gotResume, tc.wantSpawn, tc.wantStatus, tc.wantStop, tc.wantResume) | |
| 212 | 230 | } | |
| 213 | 231 | }) | |
| 214 | 232 | } | |
| @@ -102,7 +102,7 @@ func TestAutoSubagentsSettingsDisableUnavailablePolicy(t *testing.T) { | |||||
| 102 | name: "launch policy excludes tool", | 102 | name: "launch policy excludes tool", | ||
| 103 | supervisor: subagents.New(subagents.Config{Root: t.TempDir(), RepoRoot: t.TempDir()}), | 103 | supervisor: subagents.New(subagents.Config{Root: t.TempDir(), RepoRoot: t.TempDir()}), | ||
| 104 | allowed: &falseValue, | 104 | allowed: &falseValue, | ||
| 105 | - | wantHint: "launch-time tool policy excludes subagent_spawn", | 105 | + | wantHint: "launch-time tool policy excludes subagent manager tools", |
| 106 | }, | 106 | }, | ||
| 107 | } | 107 | } | ||
| 108 | for _, tt := range tests { | 108 | for _, tt := range tests { | ||
| @@ -162,6 +162,12 @@ func TestAutoSubagentsToolRegistrationHonorsLaunchPolicy(t *testing.T) { | |||||
| 162 | if _, ok := iv.agent.Tools["subagent_status"]; ok { | 162 | if _, ok := iv.agent.Tools["subagent_status"]; ok { | ||
| 163 | t.Fatal("subagent_status registered despite launch-time policy") | 163 | t.Fatal("subagent_status registered despite launch-time policy") | ||
| 164 | } | 164 | } | ||
| 165 | + | if _, ok := iv.agent.Tools["subagent_stop"]; ok { | |||
| 166 | + | t.Fatal("subagent_stop registered despite launch-time policy") | |||
| 167 | + | } | |||
| 168 | + | if _, ok := iv.agent.Tools["subagent_resume"]; ok { | |||
| 169 | + | t.Fatal("subagent_resume registered despite launch-time policy") | |||
| 170 | + | } | |||
| 165 | iv.applySettingToggle("auto_subagents_enabled", true) | 171 | iv.applySettingToggle("auto_subagents_enabled", true) | ||
| 166 | iv.mu.Lock() | 172 | iv.mu.Lock() | ||
| 167 | statusErr := iv.statusErr | 173 | statusErr := iv.statusErr | ||
| @@ -180,17 +186,25 @@ func TestAutoSubagentsToolRegistrationHonorsSeparateLaunchPolicies(t *testing.T) | |||||
| 180 | name string | 186 | name string | ||
| 181 | spawnAllowed bool | 187 | spawnAllowed bool | ||
| 182 | statusAllowed bool | 188 | statusAllowed bool | ||
| 189 | + | stopAllowed bool | |||
| 190 | + | resumeAllowed bool | |||
| 183 | wantSpawn bool | 191 | wantSpawn bool | ||
| 184 | wantStatus bool | 192 | wantStatus bool | ||
| 193 | + | wantStop bool | |||
| 194 | + | wantResume bool | |||
| 185 | }{ | 195 | }{ | ||
| 186 | {name: "status only", statusAllowed: true, wantStatus: true}, | 196 | {name: "status only", statusAllowed: true, wantStatus: true}, | ||
| 187 | {name: "spawn only", spawnAllowed: true, wantSpawn: true}, | 197 | {name: "spawn only", spawnAllowed: true, wantSpawn: true}, | ||
| 188 | - | {name: "both", spawnAllowed: true, statusAllowed: true, wantSpawn: true, wantStatus: true}, | 198 | + | {name: "stop only", stopAllowed: true, wantStop: true}, |
| 199 | + | {name: "resume only", resumeAllowed: true, wantResume: true}, | |||
| 200 | + | {name: "all", spawnAllowed: true, statusAllowed: true, stopAllowed: true, resumeAllowed: true, wantSpawn: true, wantStatus: true, wantStop: true, wantResume: true}, | |||
| 189 | } { | 201 | } { | ||
| 190 | t.Run(tc.name, func(t *testing.T) { | 202 | t.Run(tc.name, func(t *testing.T) { | ||
| 191 | enabled := true | 203 | enabled := true | ||
| 192 | spawnAllowed := tc.spawnAllowed | 204 | spawnAllowed := tc.spawnAllowed | ||
| 193 | statusAllowed := tc.statusAllowed | 205 | statusAllowed := tc.statusAllowed | ||
| 206 | + | stopAllowed := tc.stopAllowed | |||
| 207 | + | resumeAllowed := tc.resumeAllowed | |||
| 194 | supervisor := subagents.New(subagents.Config{Root: t.TempDir(), RepoRoot: t.TempDir()}) | 208 | supervisor := subagents.New(subagents.Config{Root: t.TempDir(), RepoRoot: t.TempDir()}) | ||
| 195 | t.Cleanup(supervisor.StopAll) | 209 | t.Cleanup(supervisor.StopAll) | ||
| 196 | iv := &Interactive{ | 210 | iv := &Interactive{ | ||
| @@ -199,6 +213,8 @@ func TestAutoSubagentsToolRegistrationHonorsSeparateLaunchPolicies(t *testing.T) | |||||
| 199 | AutoSubagentsEnabled: &enabled, | 213 | AutoSubagentsEnabled: &enabled, | ||
| 200 | AutoSubagentsToolAllowed: &spawnAllowed, | 214 | AutoSubagentsToolAllowed: &spawnAllowed, | ||
| 201 | AutoSubagentsStatusToolAllowed: &statusAllowed, | 215 | AutoSubagentsStatusToolAllowed: &statusAllowed, | ||
| 216 | + | AutoSubagentsStopToolAllowed: &stopAllowed, | |||
| 217 | + | AutoSubagentsResumeToolAllowed: &resumeAllowed, | |||
| 202 | Supervisor: supervisor, | 218 | Supervisor: supervisor, | ||
| 203 | }, | 219 | }, | ||
| 204 | dirty: make(chan struct{}, 1), | 220 | dirty: make(chan struct{}, 1), | ||
| @@ -207,8 +223,10 @@ func TestAutoSubagentsToolRegistrationHonorsSeparateLaunchPolicies(t *testing.T) | |||||
| 207 | iv.applyAutoSubagentsTool(true) | 223 | iv.applyAutoSubagentsTool(true) | ||
| 208 | _, gotSpawn := iv.agent.Tools["subagent_spawn"] | 224 | _, gotSpawn := iv.agent.Tools["subagent_spawn"] | ||
| 209 | _, gotStatus := iv.agent.Tools["subagent_status"] | 225 | _, gotStatus := iv.agent.Tools["subagent_status"] | ||
| 210 | - | if gotSpawn != tc.wantSpawn || gotStatus != tc.wantStatus { | 226 | + | _, gotStop := iv.agent.Tools["subagent_stop"] |
| 211 | - | t.Fatalf("registered tools spawn=%v status=%v, want spawn=%v status=%v", gotSpawn, gotStatus, tc.wantSpawn, tc.wantStatus) | 227 | + | _, gotResume := iv.agent.Tools["subagent_resume"] |
| 228 | + | if gotSpawn != tc.wantSpawn || gotStatus != tc.wantStatus || gotStop != tc.wantStop || gotResume != tc.wantResume { | |||
| 229 | + | t.Fatalf("registered tools spawn=%v status=%v stop=%v resume=%v, want spawn=%v status=%v stop=%v resume=%v", gotSpawn, gotStatus, gotStop, gotResume, tc.wantSpawn, tc.wantStatus, tc.wantStop, tc.wantResume) | |||
| 212 | } | 230 | } | ||
| 213 | }) | 231 | }) | ||
| 214 | } | 232 | } | ||
M packages/agent/modes/auto_subagents_test.go +78 -0
| @@ -114,6 +114,84 @@ func TestTrackSubagentWorkerReportsStartupFailure(t *testing.T) { | |||
| 114 | 114 | } | |
| 115 | 115 | } | |
| 116 | 116 | ||
| 117 | + | func TestTrackStoppedSubagentWorkerDeliversTerminalUpdate(t *testing.T) { | |
| 118 | + | started := make(chan struct{}) | |
| 119 | + | mgr := subagents.New(subagents.Config{ | |
| 120 | + | Root: t.TempDir(), | |
| 121 | + | RepoRoot: t.TempDir(), | |
| 122 | + | NewRunner: func(*subagents.Agent) subagents.Runner { | |
| 123 | + | return subagents.RunnerFunc(func(ctx context.Context, _ subagents.Sink) error { | |
| 124 | + | close(started) | |
| 125 | + | <-ctx.Done() | |
| 126 | + | return ctx.Err() | |
| 127 | + | }) | |
| 128 | + | }, | |
| 129 | + | }) | |
| 130 | + | a, err := mgr.Spawn(context.Background(), "investigate the stuck worker") | |
| 131 | + | if err != nil { | |
| 132 | + | t.Fatal(err) | |
| 133 | + | } | |
| 134 | + | <-started | |
| 135 | + | t.Cleanup(func() { | |
| 136 | + | _ = mgr.Stop(a.ID) | |
| 137 | + | a.Wait() | |
| 138 | + | }) | |
| 139 | + | ||
| 140 | + | iv := newQueuedAutoSubagentsInteractive() | |
| 141 | + | iv.TrackStoppedSubagentWorker(a) | |
| 142 | + | if err := mgr.Stop(a.ID); err != nil { | |
| 143 | + | t.Fatal(err) | |
| 144 | + | } | |
| 145 | + | ||
| 146 | + | update := waitForQueuedPrompt(t, iv) | |
| 147 | + | if !strings.Contains(update, "task: investigate the stuck worker") { | |
| 148 | + | t.Fatalf("stop update missing task: %q", update) | |
| 149 | + | } | |
| 150 | + | if !strings.Contains(update, "status: killed") { | |
| 151 | + | t.Fatalf("stop update missing killed status: %q", update) | |
| 152 | + | } | |
| 153 | + | } | |
| 154 | + | ||
| 155 | + | func TestTrackResumedSubagentWorkerDeliversFollowUp(t *testing.T) { | |
| 156 | + | started := make(chan struct{}) | |
| 157 | + | mgr := subagents.New(subagents.Config{ | |
| 158 | + | Root: t.TempDir(), | |
| 159 | + | RepoRoot: t.TempDir(), | |
| 160 | + | NewRunner: func(*subagents.Agent) subagents.Runner { | |
| 161 | + | return subagents.RunnerFunc(func(ctx context.Context, _ subagents.Sink) error { | |
| 162 | + | close(started) | |
| 163 | + | <-ctx.Done() | |
| 164 | + | return ctx.Err() | |
| 165 | + | }) | |
| 166 | + | }, | |
| 167 | + | }) | |
| 168 | + | a, err := mgr.Spawn(context.Background(), "review the implementation") | |
| 169 | + | if err != nil { | |
| 170 | + | t.Fatal(err) | |
| 171 | + | } | |
| 172 | + | <-started | |
| 173 | + | t.Cleanup(func() { | |
| 174 | + | _ = mgr.Stop(a.ID) | |
| 175 | + | a.Wait() | |
| 176 | + | }) | |
| 177 | + | ||
| 178 | + | iv := newQueuedAutoSubagentsInteractive() | |
| 179 | + | const followUp = "I applied your review. What do you think now?" | |
| 180 | + | iv.TrackResumedSubagentWorker(a, followUp) | |
| 181 | + | if a.OnTurnEnd == nil { | |
| 182 | + | t.Fatal("resumed worker has no completion watcher") | |
| 183 | + | } | |
| 184 | + | a.OnTurnEnd(2, "") | |
| 185 | + | ||
| 186 | + | update := waitForQueuedPrompt(t, iv) | |
| 187 | + | if !strings.Contains(update, "task: "+followUp) { | |
| 188 | + | t.Fatalf("follow-up update uses the original task instead of the new prompt: %q", update) | |
| 189 | + | } | |
| 190 | + | if !strings.Contains(update, "status: completed") { | |
| 191 | + | t.Fatalf("follow-up update missing completed status: %q", update) | |
| 192 | + | } | |
| 193 | + | } | |
| 194 | + | ||
| 117 | 195 | func TestCompleteSupervisorWatchReportsTurnOutcomeOnce(t *testing.T) { | |
| 118 | 196 | started := make(chan struct{}) | |
| 119 | 197 | mgr := subagents.New(subagents.Config{ | |
| @@ -114,6 +114,84 @@ func TestTrackSubagentWorkerReportsStartupFailure(t *testing.T) { | |||||
| 114 | } | 114 | } | ||
| 115 | } | 115 | } | ||
| 116 | 116 | ||||
| 117 | + | func TestTrackStoppedSubagentWorkerDeliversTerminalUpdate(t *testing.T) { | |||
| 118 | + | started := make(chan struct{}) | |||
| 119 | + | mgr := subagents.New(subagents.Config{ | |||
| 120 | + | Root: t.TempDir(), | |||
| 121 | + | RepoRoot: t.TempDir(), | |||
| 122 | + | NewRunner: func(*subagents.Agent) subagents.Runner { | |||
| 123 | + | return subagents.RunnerFunc(func(ctx context.Context, _ subagents.Sink) error { | |||
| 124 | + | close(started) | |||
| 125 | + | <-ctx.Done() | |||
| 126 | + | return ctx.Err() | |||
| 127 | + | }) | |||
| 128 | + | }, | |||
| 129 | + | }) | |||
| 130 | + | a, err := mgr.Spawn(context.Background(), "investigate the stuck worker") | |||
| 131 | + | if err != nil { | |||
| 132 | + | t.Fatal(err) | |||
| 133 | + | } | |||
| 134 | + | <-started | |||
| 135 | + | t.Cleanup(func() { | |||
| 136 | + | _ = mgr.Stop(a.ID) | |||
| 137 | + | a.Wait() | |||
| 138 | + | }) | |||
| 139 | + | ||||
| 140 | + | iv := newQueuedAutoSubagentsInteractive() | |||
| 141 | + | iv.TrackStoppedSubagentWorker(a) | |||
| 142 | + | if err := mgr.Stop(a.ID); err != nil { | |||
| 143 | + | t.Fatal(err) | |||
| 144 | + | } | |||
| 145 | + | ||||
| 146 | + | update := waitForQueuedPrompt(t, iv) | |||
| 147 | + | if !strings.Contains(update, "task: investigate the stuck worker") { | |||
| 148 | + | t.Fatalf("stop update missing task: %q", update) | |||
| 149 | + | } | |||
| 150 | + | if !strings.Contains(update, "status: killed") { | |||
| 151 | + | t.Fatalf("stop update missing killed status: %q", update) | |||
| 152 | + | } | |||
| 153 | + | } | |||
| 154 | + | ||||
| 155 | + | func TestTrackResumedSubagentWorkerDeliversFollowUp(t *testing.T) { | |||
| 156 | + | started := make(chan struct{}) | |||
| 157 | + | mgr := subagents.New(subagents.Config{ | |||
| 158 | + | Root: t.TempDir(), | |||
| 159 | + | RepoRoot: t.TempDir(), | |||
| 160 | + | NewRunner: func(*subagents.Agent) subagents.Runner { | |||
| 161 | + | return subagents.RunnerFunc(func(ctx context.Context, _ subagents.Sink) error { | |||
| 162 | + | close(started) | |||
| 163 | + | <-ctx.Done() | |||
| 164 | + | return ctx.Err() | |||
| 165 | + | }) | |||
| 166 | + | }, | |||
| 167 | + | }) | |||
| 168 | + | a, err := mgr.Spawn(context.Background(), "review the implementation") | |||
| 169 | + | if err != nil { | |||
| 170 | + | t.Fatal(err) | |||
| 171 | + | } | |||
| 172 | + | <-started | |||
| 173 | + | t.Cleanup(func() { | |||
| 174 | + | _ = mgr.Stop(a.ID) | |||
| 175 | + | a.Wait() | |||
| 176 | + | }) | |||
| 177 | + | ||||
| 178 | + | iv := newQueuedAutoSubagentsInteractive() | |||
| 179 | + | const followUp = "I applied your review. What do you think now?" | |||
| 180 | + | iv.TrackResumedSubagentWorker(a, followUp) | |||
| 181 | + | if a.OnTurnEnd == nil { | |||
| 182 | + | t.Fatal("resumed worker has no completion watcher") | |||
| 183 | + | } | |||
| 184 | + | a.OnTurnEnd(2, "") | |||
| 185 | + | ||||
| 186 | + | update := waitForQueuedPrompt(t, iv) | |||
| 187 | + | if !strings.Contains(update, "task: "+followUp) { | |||
| 188 | + | t.Fatalf("follow-up update uses the original task instead of the new prompt: %q", update) | |||
| 189 | + | } | |||
| 190 | + | if !strings.Contains(update, "status: completed") { | |||
| 191 | + | t.Fatalf("follow-up update missing completed status: %q", update) | |||
| 192 | + | } | |||
| 193 | + | } | |||
| 194 | + | ||||
| 117 | func TestCompleteSupervisorWatchReportsTurnOutcomeOnce(t *testing.T) { | 195 | func TestCompleteSupervisorWatchReportsTurnOutcomeOnce(t *testing.T) { | ||
| 118 | started := make(chan struct{}) | 196 | started := make(chan struct{}) | ||
| 119 | mgr := subagents.New(subagents.Config{ | 197 | mgr := subagents.New(subagents.Config{ | ||
M packages/agent/modes/interactive.go +109 -24
| @@ -101,6 +101,12 @@ type InteractiveConfig struct { | |||
| 101 | 101 | // the launch-time --no-tools/--tools policy excludes status queries. | |
| 102 | 102 | AutoSubagentsStatusToolAllowed *bool | |
| 103 | 103 | ||
| 104 | + | // AutoSubagentsStopToolAllowed and AutoSubagentsResumeToolAllowed are nil | |
| 105 | + | // for normal sessions and false when the launch-time tool policy excludes | |
| 106 | + | // manager lifecycle actions. | |
| 107 | + | AutoSubagentsStopToolAllowed *bool | |
| 108 | + | AutoSubagentsResumeToolAllowed *bool | |
| 109 | + | ||
| 104 | 110 | // FastMode mirrors the persisted OpenAI fast-mode flag at startup. | |
| 105 | 111 | // nil/missing means disabled. Unsupported providers reject attempts | |
| 106 | 112 | // to enable it and reject requests when it remains enabled. | |
| @@ -8553,32 +8559,59 @@ func (a telegramSenderAdapter) Active() bool { | |||
| 8553 | 8559 | // subagentWatchEntry is one tracked auto-subagents sub-agent. It records the | |
| 8554 | 8560 | // delegated turn's outcome separately from the long-lived daemon status. | |
| 8555 | 8561 | type subagentWatchEntry struct { | |
| 8556 | - | agent *subagents.Agent | |
| 8557 | - | task string | |
| 8558 | - | done bool | |
| 8559 | - | outcome string | |
| 8560 | - | err string | |
| 8562 | + | agent *subagents.Agent | |
| 8563 | + | task string | |
| 8564 | + | followUp bool | |
| 8565 | + | done bool | |
| 8566 | + | outcome string | |
| 8567 | + | err string | |
| 8561 | 8568 | } | |
| 8562 | 8569 | ||
| 8563 | 8570 | // TrackSubagentWorker is the exported entry point used by the cli to | |
| 8564 | 8571 | // hand a freshly-spawned auto-subagents agent off to the watcher. | |
| 8565 | 8572 | func (i *Interactive) TrackSubagentWorker(a *subagents.Agent, task string) { | |
| 8566 | - | i.trackSubagentWorker(a, task) | |
| 8573 | + | i.trackSubagentWorker(a, task, false) | |
| 8567 | 8574 | } | |
| 8568 | 8575 | ||
| 8569 | - | // trackSubagentWorker records a freshly-spawned auto-subagents agent and | |
| 8570 | - | // subscribes to both ways its delegated task can finish. Successful | |
| 8571 | - | // long-lived agents report a prompt-level turn_end and keep listening; | |
| 8572 | - | // startup failures and unexpected daemon exits only unblock Agent.Wait. | |
| 8573 | - | // Whichever signal arrives first completes the entry exactly once. | |
| 8574 | - | // | |
| 8575 | - | // Wired in from cli.go via SubagentSpawnTool.OnSpawned only when auto- | |
| 8576 | - | // subagent is enabled, so this is a no-op when the feature is off. | |
| 8577 | - | func (i *Interactive) trackSubagentWorker(a *subagents.Agent, task string) { | |
| 8576 | + | // TrackResumedSubagentWorker watches a resumed follow-up independently of the | |
| 8577 | + | // worker's original task. A long-lived daemon can complete several manager | |
| 8578 | + | // turns, each of which must produce its own automatic delivery. | |
| 8579 | + | func (i *Interactive) TrackResumedSubagentWorker(a *subagents.Agent, prompt string) { | |
| 8580 | + | i.trackSubagentWorker(a, prompt, true) | |
| 8581 | + | } | |
| 8582 | + | ||
| 8583 | + | // TrackStoppedSubagentWorker watches a requested worker shutdown. If an active | |
| 8584 | + | // task watcher already owns the worker, that watcher reports the terminal | |
| 8585 | + | // outcome; otherwise this creates a single exit-only watcher, including for a | |
| 8586 | + | // worker restored by Reload. | |
| 8587 | + | func (i *Interactive) TrackStoppedSubagentWorker(a *subagents.Agent) { | |
| 8588 | + | if i == nil || a == nil { | |
| 8589 | + | return | |
| 8590 | + | } | |
| 8591 | + | i.subagentWatchMu.Lock() | |
| 8592 | + | for _, existing := range i.subagentWatch { | |
| 8593 | + | if existing.agent.ID == a.ID && !existing.done { | |
| 8594 | + | i.subagentWatchMu.Unlock() | |
| 8595 | + | return | |
| 8596 | + | } | |
| 8597 | + | } | |
| 8598 | + | entry := &subagentWatchEntry{agent: a, task: a.Task} | |
| 8599 | + | i.subagentWatch = append(i.subagentWatch, entry) | |
| 8600 | + | i.subagentWatchMu.Unlock() | |
| 8601 | + | i.invalidate() | |
| 8602 | + | i.watchSubagentExit(entry) | |
| 8603 | + | } | |
| 8604 | + | ||
| 8605 | + | // trackSubagentWorker records an auto-subagents turn and subscribes to both | |
| 8606 | + | // ways it can finish. Successful long-lived agents report a prompt-level | |
| 8607 | + | // turn_end and keep listening; startup failures and unexpected daemon exits | |
| 8608 | + | // only unblock Agent.Wait. Whichever signal arrives first completes the entry | |
| 8609 | + | // exactly once. | |
| 8610 | + | func (i *Interactive) trackSubagentWorker(a *subagents.Agent, task string, followUp bool) { | |
| 8578 | 8611 | if i == nil || a == nil { | |
| 8579 | 8612 | return | |
| 8580 | 8613 | } | |
| 8581 | - | entry := &subagentWatchEntry{agent: a, task: task} | |
| 8614 | + | entry := &subagentWatchEntry{agent: a, task: task, followUp: followUp} | |
| 8582 | 8615 | i.subagentWatchMu.Lock() | |
| 8583 | 8616 | i.subagentWatch = append(i.subagentWatch, entry) | |
| 8584 | 8617 | i.subagentWatchMu.Unlock() | |
| @@ -8591,9 +8624,13 @@ func (i *Interactive) trackSubagentWorker(a *subagents.Agent, task string) { | |||
| 8591 | 8624 | } | |
| 8592 | 8625 | i.completeSupervisorWatchEntry(entry, outcome, errMsg) | |
| 8593 | 8626 | }) | |
| 8627 | + | i.watchSubagentExit(entry) | |
| 8628 | + | } | |
| 8629 | + | ||
| 8630 | + | func (i *Interactive) watchSubagentExit(entry *subagentWatchEntry) { | |
| 8594 | 8631 | go func() { | |
| 8595 | - | a.Wait() | |
| 8596 | - | snap := a.Snapshot() | |
| 8632 | + | entry.agent.Wait() | |
| 8633 | + | snap := entry.agent.Snapshot() | |
| 8597 | 8634 | outcome := string(snap.Status) | |
| 8598 | 8635 | if snap.Status == subagents.StatusDone { | |
| 8599 | 8636 | outcome = "completed" | |
| @@ -8646,7 +8683,10 @@ func (i *Interactive) flushSupervisorSummary(batch []*subagentWatchEntry) { | |||
| 8646 | 8683 | if status == "" { | |
| 8647 | 8684 | status = string(snap.Status) | |
| 8648 | 8685 | } | |
| 8649 | - | task := snap.Task | |
| 8686 | + | task := e.task | |
| 8687 | + | if task == "" || !e.followUp { | |
| 8688 | + | task = snap.Task | |
| 8689 | + | } | |
| 8650 | 8690 | if task == "" { | |
| 8651 | 8691 | task = e.task | |
| 8652 | 8692 | } | |
| @@ -8762,7 +8802,7 @@ func (i *Interactive) applyAutoSubagentsSystemPrompt(active bool) { | |||
| 8762 | 8802 | // snapshot+mutate pattern so extension tools and /reload-ext additions | |
| 8763 | 8803 | // survive a toggle. | |
| 8764 | 8804 | func (i *Interactive) autoSubagentsAvailable() bool { | |
| 8765 | - | return i.cfg.Supervisor != nil && (i.autoSubagentsToolAllowed() || i.autoSubagentsStatusToolAllowed()) | |
| 8805 | + | return i.cfg.Supervisor != nil && autoSubagentsAnyToolAllowedConfig(i.cfg) | |
| 8766 | 8806 | } | |
| 8767 | 8807 | ||
| 8768 | 8808 | func (i *Interactive) autoSubagentsUnavailableHint() string { | |
| @@ -8770,8 +8810,8 @@ func (i *Interactive) autoSubagentsUnavailableHint() string { | |||
| 8770 | 8810 | if i.cfg.Supervisor == nil { | |
| 8771 | 8811 | hints = append(hints, "subagent supervisor not available in this mode") | |
| 8772 | 8812 | } | |
| 8773 | - | if !i.autoSubagentsToolAllowed() && !i.autoSubagentsStatusToolAllowed() { | |
| 8774 | - | hints = append(hints, "launch-time tool policy excludes subagent_spawn and subagent_status") | |
| 8813 | + | if !autoSubagentsAnyToolAllowedConfig(i.cfg) { | |
| 8814 | + | hints = append(hints, "launch-time tool policy excludes subagent manager tools") | |
| 8775 | 8815 | } | |
| 8776 | 8816 | return strings.Join(hints, "; ") | |
| 8777 | 8817 | } | |
| @@ -8789,6 +8829,27 @@ func autoSubagentsStatusToolAllowedConfig(cfg InteractiveConfig) bool { | |||
| 8789 | 8829 | return autoSubagentsToolAllowedConfig(cfg) | |
| 8790 | 8830 | } | |
| 8791 | 8831 | ||
| 8832 | + | func autoSubagentsStopToolAllowedConfig(cfg InteractiveConfig) bool { | |
| 8833 | + | if cfg.AutoSubagentsStopToolAllowed != nil { | |
| 8834 | + | return *cfg.AutoSubagentsStopToolAllowed | |
| 8835 | + | } | |
| 8836 | + | return autoSubagentsToolAllowedConfig(cfg) | |
| 8837 | + | } | |
| 8838 | + | ||
| 8839 | + | func autoSubagentsResumeToolAllowedConfig(cfg InteractiveConfig) bool { | |
| 8840 | + | if cfg.AutoSubagentsResumeToolAllowed != nil { | |
| 8841 | + | return *cfg.AutoSubagentsResumeToolAllowed | |
| 8842 | + | } | |
| 8843 | + | return autoSubagentsToolAllowedConfig(cfg) | |
| 8844 | + | } | |
| 8845 | + | ||
| 8846 | + | func autoSubagentsAnyToolAllowedConfig(cfg InteractiveConfig) bool { | |
| 8847 | + | return autoSubagentsToolAllowedConfig(cfg) || | |
| 8848 | + | autoSubagentsStatusToolAllowedConfig(cfg) || | |
| 8849 | + | autoSubagentsStopToolAllowedConfig(cfg) || | |
| 8850 | + | autoSubagentsResumeToolAllowedConfig(cfg) | |
| 8851 | + | } | |
| 8852 | + | ||
| 8792 | 8853 | func (i *Interactive) autoSubagentsToolAllowed() bool { | |
| 8793 | 8854 | return autoSubagentsToolAllowedConfig(i.cfg) | |
| 8794 | 8855 | } | |
| @@ -8797,6 +8858,14 @@ func (i *Interactive) autoSubagentsStatusToolAllowed() bool { | |||
| 8797 | 8858 | return autoSubagentsStatusToolAllowedConfig(i.cfg) | |
| 8798 | 8859 | } | |
| 8799 | 8860 | ||
| 8861 | + | func (i *Interactive) autoSubagentsStopToolAllowed() bool { | |
| 8862 | + | return autoSubagentsStopToolAllowedConfig(i.cfg) | |
| 8863 | + | } | |
| 8864 | + | ||
| 8865 | + | func (i *Interactive) autoSubagentsResumeToolAllowed() bool { | |
| 8866 | + | return autoSubagentsResumeToolAllowedConfig(i.cfg) | |
| 8867 | + | } | |
| 8868 | + | ||
| 8800 | 8869 | func (i *Interactive) autoSubagentsEnabled() bool { | |
| 8801 | 8870 | i.mu.Lock() | |
| 8802 | 8871 | defer i.mu.Unlock() | |
| @@ -8818,7 +8887,7 @@ func (i *Interactive) applyAutoSubagentsTool(active bool) { | |||
| 8818 | 8887 | current := i.agent.ToolsSnapshot() | |
| 8819 | 8888 | next := core.Registry{} | |
| 8820 | 8889 | for name, t := range current { | |
| 8821 | - | if name == "subagent_spawn" || name == "subagent_status" { | |
| 8890 | + | if name == "subagent_spawn" || name == "subagent_status" || name == "subagent_stop" || name == "subagent_resume" { | |
| 8822 | 8891 | continue | |
| 8823 | 8892 | } | |
| 8824 | 8893 | next[name] = t | |
| @@ -8832,7 +8901,7 @@ func (i *Interactive) applyAutoSubagentsTool(active bool) { | |||
| 8832 | 8901 | DefaultProvider: func() string { return i.cfg.Provider }, | |
| 8833 | 8902 | DefaultReasoning: func() string { return i.cfg.Reasoning }, | |
| 8834 | 8903 | ResolveSubagent: i.cfg.ResolveSubagent, | |
| 8835 | - | OnSpawned: i.trackSubagentWorker, | |
| 8904 | + | OnSpawned: i.TrackSubagentWorker, | |
| 8836 | 8905 | } | |
| 8837 | 8906 | next[canonical.Name()] = canonical | |
| 8838 | 8907 | } | |
| @@ -8843,6 +8912,22 @@ func (i *Interactive) applyAutoSubagentsTool(active bool) { | |||
| 8843 | 8912 | } | |
| 8844 | 8913 | next[statusTool.Name()] = statusTool | |
| 8845 | 8914 | } | |
| 8915 | + | if i.autoSubagentsStopToolAllowed() { | |
| 8916 | + | stopTool := &tools.SubagentStopTool{ | |
| 8917 | + | Supervisor: i.cfg.Supervisor, | |
| 8918 | + | Enabled: func() bool { return i.autoSubagentsEnabled() }, | |
| 8919 | + | OnStopRequested: i.TrackStoppedSubagentWorker, | |
| 8920 | + | } | |
| 8921 | + | next[stopTool.Name()] = stopTool | |
| 8922 | + | } | |
| 8923 | + | if i.autoSubagentsResumeToolAllowed() { | |
| 8924 | + | resumeTool := &tools.SubagentResumeTool{ | |
| 8925 | + | Supervisor: i.cfg.Supervisor, | |
| 8926 | + | Enabled: func() bool { return i.autoSubagentsEnabled() }, | |
| 8927 | + | OnResumed: i.TrackResumedSubagentWorker, | |
| 8928 | + | } | |
| 8929 | + | next[resumeTool.Name()] = resumeTool | |
| 8930 | + | } | |
| 8846 | 8931 | } | |
| 8847 | 8932 | i.agent.SetTools(next) | |
| 8848 | 8933 | } | |
| @@ -101,6 +101,12 @@ type InteractiveConfig struct { | |||||
| 101 | // the launch-time --no-tools/--tools policy excludes status queries. | 101 | // the launch-time --no-tools/--tools policy excludes status queries. | ||
| 102 | AutoSubagentsStatusToolAllowed *bool | 102 | AutoSubagentsStatusToolAllowed *bool | ||
| 103 | 103 | ||||
| 104 | + | // AutoSubagentsStopToolAllowed and AutoSubagentsResumeToolAllowed are nil | |||
| 105 | + | // for normal sessions and false when the launch-time tool policy excludes | |||
| 106 | + | // manager lifecycle actions. | |||
| 107 | + | AutoSubagentsStopToolAllowed *bool | |||
| 108 | + | AutoSubagentsResumeToolAllowed *bool | |||
| 109 | + | ||||
| 104 | // FastMode mirrors the persisted OpenAI fast-mode flag at startup. | 110 | // FastMode mirrors the persisted OpenAI fast-mode flag at startup. | ||
| 105 | // nil/missing means disabled. Unsupported providers reject attempts | 111 | // nil/missing means disabled. Unsupported providers reject attempts | ||
| 106 | // to enable it and reject requests when it remains enabled. | 112 | // to enable it and reject requests when it remains enabled. | ||
| @@ -8553,32 +8559,59 @@ func (a telegramSenderAdapter) Active() bool { | |||||
| 8553 | // subagentWatchEntry is one tracked auto-subagents sub-agent. It records the | 8559 | // subagentWatchEntry is one tracked auto-subagents sub-agent. It records the | ||
| 8554 | // delegated turn's outcome separately from the long-lived daemon status. | 8560 | // delegated turn's outcome separately from the long-lived daemon status. | ||
| 8555 | type subagentWatchEntry struct { | 8561 | type subagentWatchEntry struct { | ||
| 8556 | - | agent *subagents.Agent | 8562 | + | agent *subagents.Agent |
| 8557 | - | task string | 8563 | + | task string |
| 8558 | - | done bool | 8564 | + | followUp bool |
| 8559 | - | outcome string | 8565 | + | done bool |
| 8560 | - | err string | 8566 | + | outcome string |
| 8567 | + | err string | |||
| 8561 | } | 8568 | } | ||
| 8562 | 8569 | ||||
| 8563 | // TrackSubagentWorker is the exported entry point used by the cli to | 8570 | // TrackSubagentWorker is the exported entry point used by the cli to | ||
| 8564 | // hand a freshly-spawned auto-subagents agent off to the watcher. | 8571 | // hand a freshly-spawned auto-subagents agent off to the watcher. | ||
| 8565 | func (i *Interactive) TrackSubagentWorker(a *subagents.Agent, task string) { | 8572 | func (i *Interactive) TrackSubagentWorker(a *subagents.Agent, task string) { | ||
| 8566 | - | i.trackSubagentWorker(a, task) | 8573 | + | i.trackSubagentWorker(a, task, false) |
| 8567 | } | 8574 | } | ||
| 8568 | 8575 | ||||
| 8569 | - | // trackSubagentWorker records a freshly-spawned auto-subagents agent and | 8576 | + | // TrackResumedSubagentWorker watches a resumed follow-up independently of the |
| 8570 | - | // subscribes to both ways its delegated task can finish. Successful | 8577 | + | // worker's original task. A long-lived daemon can complete several manager |
| 8571 | - | // long-lived agents report a prompt-level turn_end and keep listening; | 8578 | + | // turns, each of which must produce its own automatic delivery. |
| 8572 | - | // startup failures and unexpected daemon exits only unblock Agent.Wait. | 8579 | + | func (i *Interactive) TrackResumedSubagentWorker(a *subagents.Agent, prompt string) { |
| 8573 | - | // Whichever signal arrives first completes the entry exactly once. | 8580 | + | i.trackSubagentWorker(a, prompt, true) |
| 8574 | - | // | 8581 | + | } |
| 8575 | - | // Wired in from cli.go via SubagentSpawnTool.OnSpawned only when auto- | 8582 | + | |
| 8576 | - | // subagent is enabled, so this is a no-op when the feature is off. | 8583 | + | // TrackStoppedSubagentWorker watches a requested worker shutdown. If an active |
| 8577 | - | func (i *Interactive) trackSubagentWorker(a *subagents.Agent, task string) { | 8584 | + | // task watcher already owns the worker, that watcher reports the terminal |
| 8585 | + | // outcome; otherwise this creates a single exit-only watcher, including for a | |||
| 8586 | + | // worker restored by Reload. | |||
| 8587 | + | func (i *Interactive) TrackStoppedSubagentWorker(a *subagents.Agent) { | |||
| 8588 | + | if i == nil || a == nil { | |||
| 8589 | + | return | |||
| 8590 | + | } | |||
| 8591 | + | i.subagentWatchMu.Lock() | |||
| 8592 | + | for _, existing := range i.subagentWatch { | |||
| 8593 | + | if existing.agent.ID == a.ID && !existing.done { | |||
| 8594 | + | i.subagentWatchMu.Unlock() | |||
| 8595 | + | return | |||
| 8596 | + | } | |||
| 8597 | + | } | |||
| 8598 | + | entry := &subagentWatchEntry{agent: a, task: a.Task} | |||
| 8599 | + | i.subagentWatch = append(i.subagentWatch, entry) | |||
| 8600 | + | i.subagentWatchMu.Unlock() | |||
| 8601 | + | i.invalidate() | |||
| 8602 | + | i.watchSubagentExit(entry) | |||
| 8603 | + | } | |||
| 8604 | + | ||||
| 8605 | + | // trackSubagentWorker records an auto-subagents turn and subscribes to both | |||
| 8606 | + | // ways it can finish. Successful long-lived agents report a prompt-level | |||
| 8607 | + | // turn_end and keep listening; startup failures and unexpected daemon exits | |||
| 8608 | + | // only unblock Agent.Wait. Whichever signal arrives first completes the entry | |||
| 8609 | + | // exactly once. | |||
| 8610 | + | func (i *Interactive) trackSubagentWorker(a *subagents.Agent, task string, followUp bool) { | |||
| 8578 | if i == nil || a == nil { | 8611 | if i == nil || a == nil { | ||
| 8579 | return | 8612 | return | ||
| 8580 | } | 8613 | } | ||
| 8581 | - | entry := &subagentWatchEntry{agent: a, task: task} | 8614 | + | entry := &subagentWatchEntry{agent: a, task: task, followUp: followUp} |
| 8582 | i.subagentWatchMu.Lock() | 8615 | i.subagentWatchMu.Lock() | ||
| 8583 | i.subagentWatch = append(i.subagentWatch, entry) | 8616 | i.subagentWatch = append(i.subagentWatch, entry) | ||
| 8584 | i.subagentWatchMu.Unlock() | 8617 | i.subagentWatchMu.Unlock() | ||
| @@ -8591,9 +8624,13 @@ func (i *Interactive) trackSubagentWorker(a *subagents.Agent, task string) { | |||||
| 8591 | } | 8624 | } | ||
| 8592 | i.completeSupervisorWatchEntry(entry, outcome, errMsg) | 8625 | i.completeSupervisorWatchEntry(entry, outcome, errMsg) | ||
| 8593 | }) | 8626 | }) | ||
| 8627 | + | i.watchSubagentExit(entry) | |||
| 8628 | + | } | |||
| 8629 | + | ||||
| 8630 | + | func (i *Interactive) watchSubagentExit(entry *subagentWatchEntry) { | |||
| 8594 | go func() { | 8631 | go func() { | ||
| 8595 | - | a.Wait() | 8632 | + | entry.agent.Wait() |
| 8596 | - | snap := a.Snapshot() | 8633 | + | snap := entry.agent.Snapshot() |
| 8597 | outcome := string(snap.Status) | 8634 | outcome := string(snap.Status) | ||
| 8598 | if snap.Status == subagents.StatusDone { | 8635 | if snap.Status == subagents.StatusDone { | ||
| 8599 | outcome = "completed" | 8636 | outcome = "completed" | ||
| @@ -8646,7 +8683,10 @@ func (i *Interactive) flushSupervisorSummary(batch []*subagentWatchEntry) { | |||||
| 8646 | if status == "" { | 8683 | if status == "" { | ||
| 8647 | status = string(snap.Status) | 8684 | status = string(snap.Status) | ||
| 8648 | } | 8685 | } | ||
| 8649 | - | task := snap.Task | 8686 | + | task := e.task |
| 8687 | + | if task == "" || !e.followUp { | |||
| 8688 | + | task = snap.Task | |||
| 8689 | + | } | |||
| 8650 | if task == "" { | 8690 | if task == "" { | ||
| 8651 | task = e.task | 8691 | task = e.task | ||
| 8652 | } | 8692 | } | ||
| @@ -8762,7 +8802,7 @@ func (i *Interactive) applyAutoSubagentsSystemPrompt(active bool) { | |||||
| 8762 | // snapshot+mutate pattern so extension tools and /reload-ext additions | 8802 | // snapshot+mutate pattern so extension tools and /reload-ext additions | ||
| 8763 | // survive a toggle. | 8803 | // survive a toggle. | ||
| 8764 | func (i *Interactive) autoSubagentsAvailable() bool { | 8804 | func (i *Interactive) autoSubagentsAvailable() bool { | ||
| 8765 | - | return i.cfg.Supervisor != nil && (i.autoSubagentsToolAllowed() || i.autoSubagentsStatusToolAllowed()) | 8805 | + | return i.cfg.Supervisor != nil && autoSubagentsAnyToolAllowedConfig(i.cfg) |
| 8766 | } | 8806 | } | ||
| 8767 | 8807 | ||||
| 8768 | func (i *Interactive) autoSubagentsUnavailableHint() string { | 8808 | func (i *Interactive) autoSubagentsUnavailableHint() string { | ||
| @@ -8770,8 +8810,8 @@ func (i *Interactive) autoSubagentsUnavailableHint() string { | |||||
| 8770 | if i.cfg.Supervisor == nil { | 8810 | if i.cfg.Supervisor == nil { | ||
| 8771 | hints = append(hints, "subagent supervisor not available in this mode") | 8811 | hints = append(hints, "subagent supervisor not available in this mode") | ||
| 8772 | } | 8812 | } | ||
| 8773 | - | if !i.autoSubagentsToolAllowed() && !i.autoSubagentsStatusToolAllowed() { | 8813 | + | if !autoSubagentsAnyToolAllowedConfig(i.cfg) { |
| 8774 | - | hints = append(hints, "launch-time tool policy excludes subagent_spawn and subagent_status") | 8814 | + | hints = append(hints, "launch-time tool policy excludes subagent manager tools") |
| 8775 | } | 8815 | } | ||
| 8776 | return strings.Join(hints, "; ") | 8816 | return strings.Join(hints, "; ") | ||
| 8777 | } | 8817 | } | ||
| @@ -8789,6 +8829,27 @@ func autoSubagentsStatusToolAllowedConfig(cfg InteractiveConfig) bool { | |||||
| 8789 | return autoSubagentsToolAllowedConfig(cfg) | 8829 | return autoSubagentsToolAllowedConfig(cfg) | ||
| 8790 | } | 8830 | } | ||
| 8791 | 8831 | ||||
| 8832 | + | func autoSubagentsStopToolAllowedConfig(cfg InteractiveConfig) bool { | |||
| 8833 | + | if cfg.AutoSubagentsStopToolAllowed != nil { | |||
| 8834 | + | return *cfg.AutoSubagentsStopToolAllowed | |||
| 8835 | + | } | |||
| 8836 | + | return autoSubagentsToolAllowedConfig(cfg) | |||
| 8837 | + | } | |||
| 8838 | + | ||||
| 8839 | + | func autoSubagentsResumeToolAllowedConfig(cfg InteractiveConfig) bool { | |||
| 8840 | + | if cfg.AutoSubagentsResumeToolAllowed != nil { | |||
| 8841 | + | return *cfg.AutoSubagentsResumeToolAllowed | |||
| 8842 | + | } | |||
| 8843 | + | return autoSubagentsToolAllowedConfig(cfg) | |||
| 8844 | + | } | |||
| 8845 | + | ||||
| 8846 | + | func autoSubagentsAnyToolAllowedConfig(cfg InteractiveConfig) bool { | |||
| 8847 | + | return autoSubagentsToolAllowedConfig(cfg) || | |||
| 8848 | + | autoSubagentsStatusToolAllowedConfig(cfg) || | |||
| 8849 | + | autoSubagentsStopToolAllowedConfig(cfg) || | |||
| 8850 | + | autoSubagentsResumeToolAllowedConfig(cfg) | |||
| 8851 | + | } | |||
| 8852 | + | ||||
| 8792 | func (i *Interactive) autoSubagentsToolAllowed() bool { | 8853 | func (i *Interactive) autoSubagentsToolAllowed() bool { | ||
| 8793 | return autoSubagentsToolAllowedConfig(i.cfg) | 8854 | return autoSubagentsToolAllowedConfig(i.cfg) | ||
| 8794 | } | 8855 | } | ||
| @@ -8797,6 +8858,14 @@ func (i *Interactive) autoSubagentsStatusToolAllowed() bool { | |||||
| 8797 | return autoSubagentsStatusToolAllowedConfig(i.cfg) | 8858 | return autoSubagentsStatusToolAllowedConfig(i.cfg) | ||
| 8798 | } | 8859 | } | ||
| 8799 | 8860 | ||||
| 8861 | + | func (i *Interactive) autoSubagentsStopToolAllowed() bool { | |||
| 8862 | + | return autoSubagentsStopToolAllowedConfig(i.cfg) | |||
| 8863 | + | } | |||
| 8864 | + | ||||
| 8865 | + | func (i *Interactive) autoSubagentsResumeToolAllowed() bool { | |||
| 8866 | + | return autoSubagentsResumeToolAllowedConfig(i.cfg) | |||
| 8867 | + | } | |||
| 8868 | + | ||||
| 8800 | func (i *Interactive) autoSubagentsEnabled() bool { | 8869 | func (i *Interactive) autoSubagentsEnabled() bool { | ||
| 8801 | i.mu.Lock() | 8870 | i.mu.Lock() | ||
| 8802 | defer i.mu.Unlock() | 8871 | defer i.mu.Unlock() | ||
| @@ -8818,7 +8887,7 @@ func (i *Interactive) applyAutoSubagentsTool(active bool) { | |||||
| 8818 | current := i.agent.ToolsSnapshot() | 8887 | current := i.agent.ToolsSnapshot() | ||
| 8819 | next := core.Registry{} | 8888 | next := core.Registry{} | ||
| 8820 | for name, t := range current { | 8889 | for name, t := range current { | ||
| 8821 | - | if name == "subagent_spawn" || name == "subagent_status" { | 8890 | + | if name == "subagent_spawn" || name == "subagent_status" || name == "subagent_stop" || name == "subagent_resume" { |
| 8822 | continue | 8891 | continue | ||
| 8823 | } | 8892 | } | ||
| 8824 | next[name] = t | 8893 | next[name] = t | ||
| @@ -8832,7 +8901,7 @@ func (i *Interactive) applyAutoSubagentsTool(active bool) { | |||||
| 8832 | DefaultProvider: func() string { return i.cfg.Provider }, | 8901 | DefaultProvider: func() string { return i.cfg.Provider }, | ||
| 8833 | DefaultReasoning: func() string { return i.cfg.Reasoning }, | 8902 | DefaultReasoning: func() string { return i.cfg.Reasoning }, | ||
| 8834 | ResolveSubagent: i.cfg.ResolveSubagent, | 8903 | ResolveSubagent: i.cfg.ResolveSubagent, | ||
| 8835 | - | OnSpawned: i.trackSubagentWorker, | 8904 | + | OnSpawned: i.TrackSubagentWorker, |
| 8836 | } | 8905 | } | ||
| 8837 | next[canonical.Name()] = canonical | 8906 | next[canonical.Name()] = canonical | ||
| 8838 | } | 8907 | } | ||
| @@ -8843,6 +8912,22 @@ func (i *Interactive) applyAutoSubagentsTool(active bool) { | |||||
| 8843 | } | 8912 | } | ||
| 8844 | next[statusTool.Name()] = statusTool | 8913 | next[statusTool.Name()] = statusTool | ||
| 8845 | } | 8914 | } | ||
| 8915 | + | if i.autoSubagentsStopToolAllowed() { | |||
| 8916 | + | stopTool := &tools.SubagentStopTool{ | |||
| 8917 | + | Supervisor: i.cfg.Supervisor, | |||
| 8918 | + | Enabled: func() bool { return i.autoSubagentsEnabled() }, | |||
| 8919 | + | OnStopRequested: i.TrackStoppedSubagentWorker, | |||
| 8920 | + | } | |||
| 8921 | + | next[stopTool.Name()] = stopTool | |||
| 8922 | + | } | |||
| 8923 | + | if i.autoSubagentsResumeToolAllowed() { | |||
| 8924 | + | resumeTool := &tools.SubagentResumeTool{ | |||
| 8925 | + | Supervisor: i.cfg.Supervisor, | |||
| 8926 | + | Enabled: func() bool { return i.autoSubagentsEnabled() }, | |||
| 8927 | + | OnResumed: i.TrackResumedSubagentWorker, | |||
| 8928 | + | } | |||
| 8929 | + | next[resumeTool.Name()] = resumeTool | |||
| 8930 | + | } | |||
| 8846 | } | 8931 | } | ||
| 8847 | i.agent.SetTools(next) | 8932 | i.agent.SetTools(next) | ||
| 8848 | } | 8933 | } | ||
M packages/agent/settings_store.go +20 -6
| @@ -282,11 +282,25 @@ When workers finish, use the host update's agent ID, status, task, optional erro | |||
| 282 | 282 | // spawn tool. | |
| 283 | 283 | const AutoSubagentsDelegationUnavailableAddendum = `Delegation is unavailable in this session because the launch-time tool policy does not expose subagent_spawn. Remain the primary-agent orchestrator: report this limitation to the user rather than implementing, debugging/testing, or reviewing directly, and end or yield your turn until delegation is available. Do not treat subagent_status or other tools as a substitute for subagent_spawn.` | |
| 284 | 284 | ||
| 285 | - | // AutoSubagentsSystemAddendumFor returns the strict contract with explicit | |
| 286 | - | // limitation guidance when the spawn tool is not available. | |
| 287 | - | func AutoSubagentsSystemAddendumFor(spawnToolAllowed bool) string { | |
| 288 | - | if spawnToolAllowed { | |
| 289 | - | return AutoSubagentsSystemAddendum | |
| 285 | + | // AutoSubagentsSystemAddendumFor returns the strict contract with lifecycle | |
| 286 | + | // guidance only for manager actions exposed by the launch-time tool policy. | |
| 287 | + | // It adds explicit limitation guidance when the spawn tool is not available. | |
| 288 | + | func AutoSubagentsSystemAddendumFor(spawnToolAllowed, stopToolAllowed, resumeToolAllowed bool) string { | |
| 289 | + | addendum := AutoSubagentsSystemAddendum | |
| 290 | + | switch { | |
| 291 | + | case stopToolAllowed && resumeToolAllowed: | |
| 292 | + | addendum += "\n\nManager lifecycle actions are available: use subagent_stop to request termination of a stuck worker, and subagent_resume with an agent id and follow-up prompt to continue an idle worker or restart a stopped worker with its existing session context." | |
| 293 | + | case stopToolAllowed: | |
| 294 | + | addendum += "\n\nA manager lifecycle action is available: use subagent_stop to request termination of a stuck worker." | |
| 295 | + | case resumeToolAllowed: | |
| 296 | + | addendum += "\n\nA manager lifecycle action is available: use subagent_resume with an agent id and follow-up prompt to continue an idle worker or restart a stopped worker with its existing session context." | |
| 297 | + | } | |
| 298 | + | if !spawnToolAllowed { | |
| 299 | + | if stopToolAllowed || resumeToolAllowed { | |
| 300 | + | addendum += "\n\nSpawning new workers is unavailable in this session. Use only the enabled manager lifecycle actions for existing workers. Do not implement, debug, test, or review directly." | |
| 301 | + | } else { | |
| 302 | + | addendum += "\n\n" + AutoSubagentsDelegationUnavailableAddendum | |
| 303 | + | } | |
| 290 | 304 | } | |
| 291 | - | return AutoSubagentsSystemAddendum + "\n\n" + AutoSubagentsDelegationUnavailableAddendum | |
| 305 | + | return addendum | |
| 292 | 306 | } | |
| @@ -282,11 +282,25 @@ When workers finish, use the host update's agent ID, status, task, optional erro | |||||
| 282 | // spawn tool. | 282 | // spawn tool. | ||
| 283 | const AutoSubagentsDelegationUnavailableAddendum = `Delegation is unavailable in this session because the launch-time tool policy does not expose subagent_spawn. Remain the primary-agent orchestrator: report this limitation to the user rather than implementing, debugging/testing, or reviewing directly, and end or yield your turn until delegation is available. Do not treat subagent_status or other tools as a substitute for subagent_spawn.` | 283 | const AutoSubagentsDelegationUnavailableAddendum = `Delegation is unavailable in this session because the launch-time tool policy does not expose subagent_spawn. Remain the primary-agent orchestrator: report this limitation to the user rather than implementing, debugging/testing, or reviewing directly, and end or yield your turn until delegation is available. Do not treat subagent_status or other tools as a substitute for subagent_spawn.` | ||
| 284 | 284 | ||||
| 285 | - | // AutoSubagentsSystemAddendumFor returns the strict contract with explicit | 285 | + | // AutoSubagentsSystemAddendumFor returns the strict contract with lifecycle |
| 286 | - | // limitation guidance when the spawn tool is not available. | 286 | + | // guidance only for manager actions exposed by the launch-time tool policy. |
| 287 | - | func AutoSubagentsSystemAddendumFor(spawnToolAllowed bool) string { | 287 | + | // It adds explicit limitation guidance when the spawn tool is not available. |
| 288 | - | if spawnToolAllowed { | 288 | + | func AutoSubagentsSystemAddendumFor(spawnToolAllowed, stopToolAllowed, resumeToolAllowed bool) string { |
| 289 | - | return AutoSubagentsSystemAddendum | 289 | + | addendum := AutoSubagentsSystemAddendum |
| 290 | + | switch { | |||
| 291 | + | case stopToolAllowed && resumeToolAllowed: | |||
| 292 | + | addendum += "\n\nManager lifecycle actions are available: use subagent_stop to request termination of a stuck worker, and subagent_resume with an agent id and follow-up prompt to continue an idle worker or restart a stopped worker with its existing session context." | |||
| 293 | + | case stopToolAllowed: | |||
| 294 | + | addendum += "\n\nA manager lifecycle action is available: use subagent_stop to request termination of a stuck worker." | |||
| 295 | + | case resumeToolAllowed: | |||
| 296 | + | addendum += "\n\nA manager lifecycle action is available: use subagent_resume with an agent id and follow-up prompt to continue an idle worker or restart a stopped worker with its existing session context." | |||
| 297 | + | } | |||
| 298 | + | if !spawnToolAllowed { | |||
| 299 | + | if stopToolAllowed || resumeToolAllowed { | |||
| 300 | + | addendum += "\n\nSpawning new workers is unavailable in this session. Use only the enabled manager lifecycle actions for existing workers. Do not implement, debug, test, or review directly." | |||
| 301 | + | } else { | |||
| 302 | + | addendum += "\n\n" + AutoSubagentsDelegationUnavailableAddendum | |||
| 303 | + | } | |||
| 290 | } | 304 | } | ||
| 291 | - | return AutoSubagentsSystemAddendum + "\n\n" + AutoSubagentsDelegationUnavailableAddendum | 305 | + | return addendum |
| 292 | } | 306 | } | ||
M packages/agent/settings_store_test.go +41 -1
| @@ -1,6 +1,46 @@ | |||
| 1 | 1 | package agent | |
| 2 | 2 | ||
| 3 | - | import "testing" | |
| 3 | + | import ( | |
| 4 | + | "strings" | |
| 5 | + | "testing" | |
| 6 | + | ) | |
| 7 | + | ||
| 8 | + | func TestAutoSubagentsSystemAddendumForListsOnlyEnabledLifecycleTools(t *testing.T) { | |
| 9 | + | for _, tc := range []struct { | |
| 10 | + | name string | |
| 11 | + | spawn bool | |
| 12 | + | stop bool | |
| 13 | + | resume bool | |
| 14 | + | want []string | |
| 15 | + | unwanted []string | |
| 16 | + | wantUnavailable bool | |
| 17 | + | }{ | |
| 18 | + | {name: "none", spawn: true, unwanted: []string{"subagent_stop", "subagent_resume"}}, | |
| 19 | + | {name: "fully unavailable", unwanted: []string{"subagent_stop", "subagent_resume"}, wantUnavailable: true}, | |
| 20 | + | {name: "stop", spawn: true, stop: true, want: []string{"subagent_stop"}, unwanted: []string{"subagent_resume"}}, | |
| 21 | + | {name: "resume", spawn: true, resume: true, want: []string{"subagent_resume"}, unwanted: []string{"subagent_stop"}}, | |
| 22 | + | {name: "both", spawn: true, stop: true, resume: true, want: []string{"subagent_stop", "subagent_resume"}}, | |
| 23 | + | {name: "stop without spawn", stop: true, want: []string{"subagent_stop", "Spawning new workers is unavailable"}, unwanted: []string{"subagent_resume"}}, | |
| 24 | + | {name: "resume without spawn", resume: true, want: []string{"subagent_resume", "Spawning new workers is unavailable"}, unwanted: []string{"subagent_stop"}}, | |
| 25 | + | } { | |
| 26 | + | t.Run(tc.name, func(t *testing.T) { | |
| 27 | + | got := AutoSubagentsSystemAddendumFor(tc.spawn, tc.stop, tc.resume) | |
| 28 | + | for _, want := range tc.want { | |
| 29 | + | if !strings.Contains(got, want) { | |
| 30 | + | t.Fatalf("addendum missing enabled lifecycle tool %q:\n%s", want, got) | |
| 31 | + | } | |
| 32 | + | } | |
| 33 | + | for _, unwanted := range tc.unwanted { | |
| 34 | + | if strings.Contains(got, unwanted) { | |
| 35 | + | t.Fatalf("addendum mentions unavailable lifecycle tool %q:\n%s", unwanted, got) | |
| 36 | + | } | |
| 37 | + | } | |
| 38 | + | if gotUnavailable := strings.Contains(got, AutoSubagentsDelegationUnavailableAddendum); gotUnavailable != tc.wantUnavailable { | |
| 39 | + | t.Fatalf("delegation-unavailable guidance present = %t, want %t:\n%s", gotUnavailable, tc.wantUnavailable, got) | |
| 40 | + | } | |
| 41 | + | }) | |
| 42 | + | } | |
| 43 | + | } | |
| 4 | 44 | ||
| 5 | 45 | func TestConfigSettingsStorePersistsShowInstructionsAtStartup(t *testing.T) { | |
| 6 | 46 | t.Setenv("ZUT_HOME", t.TempDir()) | |
| @@ -1,6 +1,46 @@ | |||||
| 1 | package agent | 1 | package agent | ||
| 2 | 2 | ||||
| 3 | - | import "testing" | 3 | + | import ( |
| 4 | + | "strings" | |||
| 5 | + | "testing" | |||
| 6 | + | ) | |||
| 7 | + | ||||
| 8 | + | func TestAutoSubagentsSystemAddendumForListsOnlyEnabledLifecycleTools(t *testing.T) { | |||
| 9 | + | for _, tc := range []struct { | |||
| 10 | + | name string | |||
| 11 | + | spawn bool | |||
| 12 | + | stop bool | |||
| 13 | + | resume bool | |||
| 14 | + | want []string | |||
| 15 | + | unwanted []string | |||
| 16 | + | wantUnavailable bool | |||
| 17 | + | }{ | |||
| 18 | + | {name: "none", spawn: true, unwanted: []string{"subagent_stop", "subagent_resume"}}, | |||
| 19 | + | {name: "fully unavailable", unwanted: []string{"subagent_stop", "subagent_resume"}, wantUnavailable: true}, | |||
| 20 | + | {name: "stop", spawn: true, stop: true, want: []string{"subagent_stop"}, unwanted: []string{"subagent_resume"}}, | |||
| 21 | + | {name: "resume", spawn: true, resume: true, want: []string{"subagent_resume"}, unwanted: []string{"subagent_stop"}}, | |||
| 22 | + | {name: "both", spawn: true, stop: true, resume: true, want: []string{"subagent_stop", "subagent_resume"}}, | |||
| 23 | + | {name: "stop without spawn", stop: true, want: []string{"subagent_stop", "Spawning new workers is unavailable"}, unwanted: []string{"subagent_resume"}}, | |||
| 24 | + | {name: "resume without spawn", resume: true, want: []string{"subagent_resume", "Spawning new workers is unavailable"}, unwanted: []string{"subagent_stop"}}, | |||
| 25 | + | } { | |||
| 26 | + | t.Run(tc.name, func(t *testing.T) { | |||
| 27 | + | got := AutoSubagentsSystemAddendumFor(tc.spawn, tc.stop, tc.resume) | |||
| 28 | + | for _, want := range tc.want { | |||
| 29 | + | if !strings.Contains(got, want) { | |||
| 30 | + | t.Fatalf("addendum missing enabled lifecycle tool %q:\n%s", want, got) | |||
| 31 | + | } | |||
| 32 | + | } | |||
| 33 | + | for _, unwanted := range tc.unwanted { | |||
| 34 | + | if strings.Contains(got, unwanted) { | |||
| 35 | + | t.Fatalf("addendum mentions unavailable lifecycle tool %q:\n%s", unwanted, got) | |||
| 36 | + | } | |||
| 37 | + | } | |||
| 38 | + | if gotUnavailable := strings.Contains(got, AutoSubagentsDelegationUnavailableAddendum); gotUnavailable != tc.wantUnavailable { | |||
| 39 | + | t.Fatalf("delegation-unavailable guidance present = %t, want %t:\n%s", gotUnavailable, tc.wantUnavailable, got) | |||
| 40 | + | } | |||
| 41 | + | }) | |||
| 42 | + | } | |||
| 43 | + | } | |||
| 4 | 44 | ||||
| 5 | func TestConfigSettingsStorePersistsShowInstructionsAtStartup(t *testing.T) { | 45 | func TestConfigSettingsStorePersistsShowInstructionsAtStartup(t *testing.T) { | ||
| 6 | t.Setenv("ZUT_HOME", t.TempDir()) | 46 | t.Setenv("ZUT_HOME", t.TempDir()) | ||
M packages/agent/subagent_worker.go +4 -1
| @@ -236,13 +236,16 @@ func runSubagentWorkerMode(ctx context.Context, args Args, version string) error | |||
| 236 | 236 | "turn_id": turnID, | |
| 237 | 237 | "error": turnErrorMessage(err), | |
| 238 | 238 | }) | |
| 239 | - | em.emit("agent.idle", map[string]any{"turn_id": turnID}) | |
| 240 | 239 | ||
| 241 | 240 | cancel() | |
| 242 | 241 | mu.Lock() | |
| 243 | 242 | busyTurn = false | |
| 244 | 243 | cancelFn = nil | |
| 245 | 244 | mu.Unlock() | |
| 245 | + | // The supervisor treats agent.idle as permission to start a follow-up. | |
| 246 | + | // Publish it only after the busy gate has opened, otherwise a manager | |
| 247 | + | // can send a turn that the inbox loop drops as still busy. | |
| 248 | + | em.emit("agent.idle", map[string]any{"turn_id": turnID}) | |
| 246 | 249 | } | |
| 247 | 250 | ||
| 248 | 251 | launchTurn := func(prompt string) { | |
| @@ -236,13 +236,16 @@ func runSubagentWorkerMode(ctx context.Context, args Args, version string) error | |||||
| 236 | "turn_id": turnID, | 236 | "turn_id": turnID, | ||
| 237 | "error": turnErrorMessage(err), | 237 | "error": turnErrorMessage(err), | ||
| 238 | }) | 238 | }) | ||
| 239 | - | em.emit("agent.idle", map[string]any{"turn_id": turnID}) | |||
| 240 | 239 | ||||
| 241 | cancel() | 240 | cancel() | ||
| 242 | mu.Lock() | 241 | mu.Lock() | ||
| 243 | busyTurn = false | 242 | busyTurn = false | ||
| 244 | cancelFn = nil | 243 | cancelFn = nil | ||
| 245 | mu.Unlock() | 244 | mu.Unlock() | ||
| 245 | + | // The supervisor treats agent.idle as permission to start a follow-up. | |||
| 246 | + | // Publish it only after the busy gate has opened, otherwise a manager | |||
| 247 | + | // can send a turn that the inbox loop drops as still busy. | |||
| 248 | + | em.emit("agent.idle", map[string]any{"turn_id": turnID}) | |||
| 246 | } | 249 | } | ||
| 247 | 250 | ||||
| 248 | launchTurn := func(prompt string) { | 251 | launchTurn := func(prompt string) { | ||
M packages/agent/subagents/agent.go +39 -4
| @@ -76,12 +76,16 @@ type Agent struct { | |||
| 76 | 76 | // rather than Spawn. The runner consults it to decide whether | |
| 77 | 77 | // to pass the original Task as a positional argv to the child: | |
| 78 | 78 | // on first Spawn we want the child to run the task immediately; | |
| 79 | - | // on Resume the task was already run last time and replaying it | |
| 80 | - | // would produce a second turn that collides ("agent busy; send | |
| 81 | - | // 'cancel' first") with whatever the user types next. Not | |
| 82 | - | // persisted — every Resume sets it explicitly. | |
| 79 | + | // on Resume the task was already run last time and must not replay. | |
| 80 | + | // Not persisted — every Resume sets it explicitly. | |
| 83 | 81 | Resuming bool | |
| 84 | 82 | ||
| 83 | + | // ResumePrompt is an optional manager follow-up passed to a resumed child as | |
| 84 | + | // its initial prompt. It remains in durable metadata until the child reports | |
| 85 | + | // turn.started, so a host exit while the worker is queued cannot lose it. | |
| 86 | + | ResumePrompt string | |
| 87 | + | ResumePromptAt time.Time | |
| 88 | + | ||
| 85 | 89 | // InboxPath is the unix socket the child agent listens on for | |
| 86 | 90 | // follow-up prompts and control messages. The supervisor opens | |
| 87 | 91 | // an Inbox at this path; the child opens a Listener. | |
| @@ -382,6 +386,37 @@ func (a *Agent) stateDirectory(defaultRoot string) string { | |||
| 382 | 386 | // from the runner when the child daemon emits a prompt-level | |
| 383 | 387 | // turn_end event with a step field. Safe to call from any goroutine: | |
| 384 | 388 | // the runner reads the callback under the same mutex. | |
| 389 | + | func (a *Agent) resumePrompt() string { | |
| 390 | + | prompt, _ := a.resumePromptInfo() | |
| 391 | + | return prompt | |
| 392 | + | } | |
| 393 | + | ||
| 394 | + | func (a *Agent) resumePromptInfo() (string, time.Time) { | |
| 395 | + | a.mu.Lock() | |
| 396 | + | defer a.mu.Unlock() | |
| 397 | + | return a.ResumePrompt, a.ResumePromptAt | |
| 398 | + | } | |
| 399 | + | ||
| 400 | + | func (a *Agent) setResumePrompt(prompt string, acceptedAt time.Time) (string, time.Time) { | |
| 401 | + | a.mu.Lock() | |
| 402 | + | defer a.mu.Unlock() | |
| 403 | + | previousPrompt, previousAcceptedAt := a.ResumePrompt, a.ResumePromptAt | |
| 404 | + | a.ResumePrompt = prompt | |
| 405 | + | a.ResumePromptAt = acceptedAt | |
| 406 | + | return previousPrompt, previousAcceptedAt | |
| 407 | + | } | |
| 408 | + | ||
| 409 | + | func (a *Agent) clearResumePrompt() bool { | |
| 410 | + | a.mu.Lock() | |
| 411 | + | defer a.mu.Unlock() | |
| 412 | + | if a.ResumePrompt == "" { | |
| 413 | + | return false | |
| 414 | + | } | |
| 415 | + | a.ResumePrompt = "" | |
| 416 | + | a.ResumePromptAt = time.Time{} | |
| 417 | + | return true | |
| 418 | + | } | |
| 419 | + | ||
| 385 | 420 | func (a *Agent) SetOnTurnEnd(fn func(step int, errMsg string)) { | |
| 386 | 421 | a.mu.Lock() | |
| 387 | 422 | a.OnTurnEnd = fn | |
| @@ -76,12 +76,16 @@ type Agent struct { | |||||
| 76 | // rather than Spawn. The runner consults it to decide whether | 76 | // rather than Spawn. The runner consults it to decide whether | ||
| 77 | // to pass the original Task as a positional argv to the child: | 77 | // to pass the original Task as a positional argv to the child: | ||
| 78 | // on first Spawn we want the child to run the task immediately; | 78 | // on first Spawn we want the child to run the task immediately; | ||
| 79 | - | // on Resume the task was already run last time and replaying it | 79 | + | // on Resume the task was already run last time and must not replay. |
| 80 | - | // would produce a second turn that collides ("agent busy; send | 80 | + | // Not persisted — every Resume sets it explicitly. |
| 81 | - | // 'cancel' first") with whatever the user types next. Not | |||
| 82 | - | // persisted — every Resume sets it explicitly. | |||
| 83 | Resuming bool | 81 | Resuming bool | ||
| 84 | 82 | ||||
| 83 | + | // ResumePrompt is an optional manager follow-up passed to a resumed child as | |||
| 84 | + | // its initial prompt. It remains in durable metadata until the child reports | |||
| 85 | + | // turn.started, so a host exit while the worker is queued cannot lose it. | |||
| 86 | + | ResumePrompt string | |||
| 87 | + | ResumePromptAt time.Time | |||
| 88 | + | ||||
| 85 | // InboxPath is the unix socket the child agent listens on for | 89 | // InboxPath is the unix socket the child agent listens on for | ||
| 86 | // follow-up prompts and control messages. The supervisor opens | 90 | // follow-up prompts and control messages. The supervisor opens | ||
| 87 | // an Inbox at this path; the child opens a Listener. | 91 | // an Inbox at this path; the child opens a Listener. | ||
| @@ -382,6 +386,37 @@ func (a *Agent) stateDirectory(defaultRoot string) string { | |||||
| 382 | // from the runner when the child daemon emits a prompt-level | 386 | // from the runner when the child daemon emits a prompt-level | ||
| 383 | // turn_end event with a step field. Safe to call from any goroutine: | 387 | // turn_end event with a step field. Safe to call from any goroutine: | ||
| 384 | // the runner reads the callback under the same mutex. | 388 | // the runner reads the callback under the same mutex. | ||
| 389 | + | func (a *Agent) resumePrompt() string { | |||
| 390 | + | prompt, _ := a.resumePromptInfo() | |||
| 391 | + | return prompt | |||
| 392 | + | } | |||
| 393 | + | ||||
| 394 | + | func (a *Agent) resumePromptInfo() (string, time.Time) { | |||
| 395 | + | a.mu.Lock() | |||
| 396 | + | defer a.mu.Unlock() | |||
| 397 | + | return a.ResumePrompt, a.ResumePromptAt | |||
| 398 | + | } | |||
| 399 | + | ||||
| 400 | + | func (a *Agent) setResumePrompt(prompt string, acceptedAt time.Time) (string, time.Time) { | |||
| 401 | + | a.mu.Lock() | |||
| 402 | + | defer a.mu.Unlock() | |||
| 403 | + | previousPrompt, previousAcceptedAt := a.ResumePrompt, a.ResumePromptAt | |||
| 404 | + | a.ResumePrompt = prompt | |||
| 405 | + | a.ResumePromptAt = acceptedAt | |||
| 406 | + | return previousPrompt, previousAcceptedAt | |||
| 407 | + | } | |||
| 408 | + | ||||
| 409 | + | func (a *Agent) clearResumePrompt() bool { | |||
| 410 | + | a.mu.Lock() | |||
| 411 | + | defer a.mu.Unlock() | |||
| 412 | + | if a.ResumePrompt == "" { | |||
| 413 | + | return false | |||
| 414 | + | } | |||
| 415 | + | a.ResumePrompt = "" | |||
| 416 | + | a.ResumePromptAt = time.Time{} | |||
| 417 | + | return true | |||
| 418 | + | } | |||
| 419 | + | ||||
| 385 | func (a *Agent) SetOnTurnEnd(fn func(step int, errMsg string)) { | 420 | func (a *Agent) SetOnTurnEnd(fn func(step int, errMsg string)) { | ||
| 386 | a.mu.Lock() | 421 | a.mu.Lock() | ||
| 387 | a.OnTurnEnd = fn | 422 | a.OnTurnEnd = fn | ||
M packages/agent/subagents/inbox.go +123 -23
| @@ -2,6 +2,7 @@ package subagents | |||
| 2 | 2 | ||
| 3 | 3 | import ( | |
| 4 | 4 | "bufio" | |
| 5 | + | "context" | |
| 5 | 6 | "errors" | |
| 6 | 7 | "fmt" | |
| 7 | 8 | "net" | |
| @@ -32,6 +33,10 @@ type Inbox struct { | |||
| 32 | 33 | ||
| 33 | 34 | mu sync.Mutex | |
| 34 | 35 | conn net.Conn // lazily dialed; one persistent connection per agent | |
| 36 | + | ||
| 37 | + | // send is a one-slot semaphore that serializes JSONL writes without making | |
| 38 | + | // a caller wait behind another blocked write after its context is canceled. | |
| 39 | + | send chan struct{} | |
| 35 | 40 | } | |
| 36 | 41 | ||
| 37 | 42 | // NewInbox returns a handle that will dial the socket at path on | |
| @@ -40,7 +45,11 @@ type Inbox struct { | |||
| 40 | 45 | // parent build the Inbox before the child has booted; sends that | |
| 41 | 46 | // happen before the child is ready get a clear "not yet" error | |
| 42 | 47 | // the caller can retry or surface. | |
| 43 | - | func NewInbox(path string) *Inbox { return &Inbox{path: path} } | |
| 48 | + | func NewInbox(path string) *Inbox { | |
| 49 | + | inbox := &Inbox{path: path, send: make(chan struct{}, 1)} | |
| 50 | + | inbox.send <- struct{}{} | |
| 51 | + | return inbox | |
| 52 | + | } | |
| 44 | 53 | ||
| 45 | 54 | // Path returns the absolute socket path. Used by the runner to | |
| 46 | 55 | // pass the path to the child as a flag. | |
| @@ -48,34 +57,109 @@ func (b *Inbox) Path() string { return b.path } | |||
| 48 | 57 | ||
| 49 | 58 | // SendCommand sends a versioned JSONL command. | |
| 50 | 59 | func (b *Inbox) SendCommand(command Envelope) error { | |
| 60 | + | return b.SendCommandContext(context.Background(), command) | |
| 61 | + | } | |
| 62 | + | ||
| 63 | + | // SendCommandContext sends a command while honoring cancellation during both | |
| 64 | + | // connection setup and a blocked write. Cancellation closes the persistent | |
| 65 | + | // connection so a later command redials instead of reusing a half-written | |
| 66 | + | // stream. | |
| 67 | + | func (b *Inbox) SendCommandContext(ctx context.Context, command Envelope) error { | |
| 51 | 68 | line, err := MarshalJSONL(command) | |
| 52 | 69 | if err != nil { | |
| 53 | 70 | return err | |
| 54 | 71 | } | |
| 55 | - | return b.sendBytes(line) | |
| 72 | + | return b.sendBytesContext(ctx, line) | |
| 56 | 73 | } | |
| 57 | 74 | ||
| 58 | 75 | func (b *Inbox) sendBytes(data []byte) error { | |
| 76 | + | return b.sendBytesContext(context.Background(), data) | |
| 77 | + | } | |
| 78 | + | ||
| 79 | + | func (b *Inbox) sendBytesContext(ctx context.Context, data []byte) error { | |
| 80 | + | if ctx == nil { | |
| 81 | + | ctx = context.Background() | |
| 82 | + | } | |
| 83 | + | if err := ctx.Err(); err != nil { | |
| 84 | + | return err | |
| 85 | + | } | |
| 86 | + | send, err := b.acquireSend(ctx) | |
| 87 | + | if err != nil { | |
| 88 | + | return err | |
| 89 | + | } | |
| 90 | + | defer releaseSend(send) | |
| 91 | + | if err := ctx.Err(); err != nil { | |
| 92 | + | return err | |
| 93 | + | } | |
| 94 | + | ||
| 59 | 95 | b.mu.Lock() | |
| 60 | - | defer b.mu.Unlock() | |
| 61 | 96 | if b.conn == nil { | |
| 62 | - | c, err := dialUnix(b.path, 200*time.Millisecond) | |
| 97 | + | c, err := dialUnixContext(ctx, b.path, 200*time.Millisecond) | |
| 63 | 98 | if err != nil { | |
| 99 | + | b.mu.Unlock() | |
| 64 | 100 | return err | |
| 65 | 101 | } | |
| 66 | 102 | b.conn = c | |
| 67 | 103 | } | |
| 68 | - | if _, err := b.conn.Write(data); err != nil { | |
| 69 | - | // Drop the connection so the next call redials. The | |
| 70 | - | // previous error is more informative than the redial's | |
| 71 | - | // would be, so surface this one. | |
| 72 | - | _ = b.conn.Close() | |
| 73 | - | b.conn = nil | |
| 104 | + | conn := b.conn | |
| 105 | + | b.mu.Unlock() | |
| 106 | + | ||
| 107 | + | stopCancel := context.AfterFunc(ctx, func() { _ = conn.Close() }) | |
| 108 | + | if deadline, ok := ctx.Deadline(); ok { | |
| 109 | + | if err := conn.SetWriteDeadline(deadline); err != nil { | |
| 110 | + | stopCancel() | |
| 111 | + | return err | |
| 112 | + | } | |
| 113 | + | defer conn.SetWriteDeadline(time.Time{}) | |
| 114 | + | } | |
| 115 | + | if _, err := conn.Write(data); err != nil { | |
| 116 | + | // Drop the connection so the next call redials. The previous error is | |
| 117 | + | // more informative than the redial's would be, so surface this one. | |
| 118 | + | stopCancel() | |
| 119 | + | b.mu.Lock() | |
| 120 | + | if b.conn == conn { | |
| 121 | + | _ = b.conn.Close() | |
| 122 | + | b.conn = nil | |
| 123 | + | } | |
| 124 | + | b.mu.Unlock() | |
| 125 | + | if ctxErr := ctx.Err(); ctxErr != nil { | |
| 126 | + | return ctxErr | |
| 127 | + | } | |
| 74 | 128 | return err | |
| 75 | 129 | } | |
| 130 | + | if !stopCancel() { | |
| 131 | + | // Cancellation closed this connection after the write completed. Drop it | |
| 132 | + | // so the next command redials instead of reusing a closed stream. | |
| 133 | + | b.mu.Lock() | |
| 134 | + | if b.conn == conn { | |
| 135 | + | b.conn = nil | |
| 136 | + | } | |
| 137 | + | b.mu.Unlock() | |
| 138 | + | return ctx.Err() | |
| 139 | + | } | |
| 76 | 140 | return nil | |
| 77 | 141 | } | |
| 78 | 142 | ||
| 143 | + | func (b *Inbox) acquireSend(ctx context.Context) (chan struct{}, error) { | |
| 144 | + | b.mu.Lock() | |
| 145 | + | if b.send == nil { | |
| 146 | + | b.send = make(chan struct{}, 1) | |
| 147 | + | b.send <- struct{}{} | |
| 148 | + | } | |
| 149 | + | send := b.send | |
| 150 | + | b.mu.Unlock() | |
| 151 | + | select { | |
| 152 | + | case <-ctx.Done(): | |
| 153 | + | return nil, ctx.Err() | |
| 154 | + | case <-send: | |
| 155 | + | return send, nil | |
| 156 | + | } | |
| 157 | + | } | |
| 158 | + | ||
| 159 | + | func releaseSend(send chan struct{}) { | |
| 160 | + | send <- struct{}{} | |
| 161 | + | } | |
| 162 | + | ||
| 79 | 163 | // Close drops any persistent connection. Safe to call repeatedly. | |
| 80 | 164 | // Does not unlink the socket file — that's the child's job | |
| 81 | 165 | // (Listener.Close). | |
| @@ -95,29 +179,45 @@ func (b *Inbox) Close() error { | |||
| 95 | 179 | // the TUI surfaces it as "agent <id> not listening yet". | |
| 96 | 180 | var ErrNotReady = errors.New("subagent: agent inbox not ready") | |
| 97 | 181 | ||
| 98 | - | // dialUnix wraps net.DialTimeout with a small retry window so the | |
| 99 | - | // happy path "child just started, supervisor sends right away" | |
| 100 | - | // works without forcing the caller to poll. | |
| 101 | - | // | |
| 102 | - | // Errors collapse onto ErrNotReady whenever the failure means "no | |
| 103 | - | // one is listening": either the socket file doesn't exist (child | |
| 104 | - | // hasn't booted) or it does exist but the dial was refused (the | |
| 105 | - | // previous child exited and left a stale inode). Both are | |
| 106 | - | // recoverable by Supervisor.Resume, and both should surface as the same | |
| 107 | - | // user-facing hint rather than as a path-leaking error string. | |
| 108 | - | func dialUnix(path string, timeout time.Duration) (net.Conn, error) { | |
| 182 | + | // dialUnixContext retries a Unix-socket connection briefly so the happy path | |
| 183 | + | // "child just started, supervisor sends right away" works without forcing the | |
| 184 | + | // caller to poll. Errors collapse onto ErrNotReady whenever the failure means | |
| 185 | + | // no worker is listening, without exposing the socket path to the caller. | |
| 186 | + | func dialUnixContext(ctx context.Context, path string, timeout time.Duration) (net.Conn, error) { | |
| 187 | + | if ctx == nil { | |
| 188 | + | ctx = context.Background() | |
| 189 | + | } | |
| 109 | 190 | deadline := time.Now().Add(timeout) | |
| 110 | 191 | var lastErr error | |
| 111 | 192 | for { | |
| 112 | - | c, err := net.DialTimeout("unix", path, 50*time.Millisecond) | |
| 193 | + | if err := ctx.Err(); err != nil { | |
| 194 | + | return nil, err | |
| 195 | + | } | |
| 196 | + | attemptTimeout := 50 * time.Millisecond | |
| 197 | + | if remaining := time.Until(deadline); remaining < attemptTimeout { | |
| 198 | + | attemptTimeout = remaining | |
| 199 | + | } | |
| 200 | + | if attemptTimeout <= 0 { | |
| 201 | + | break | |
| 202 | + | } | |
| 203 | + | c, err := (&net.Dialer{Timeout: attemptTimeout}).DialContext(ctx, "unix", path) | |
| 113 | 204 | if err == nil { | |
| 114 | 205 | return c, nil | |
| 115 | 206 | } | |
| 207 | + | if ctxErr := ctx.Err(); ctxErr != nil { | |
| 208 | + | return nil, ctxErr | |
| 209 | + | } | |
| 116 | 210 | lastErr = err | |
| 117 | 211 | if time.Now().After(deadline) { | |
| 118 | 212 | break | |
| 119 | 213 | } | |
| 120 | - | time.Sleep(20 * time.Millisecond) | |
| 214 | + | wait := time.NewTimer(20 * time.Millisecond) | |
| 215 | + | select { | |
| 216 | + | case <-ctx.Done(): | |
| 217 | + | wait.Stop() | |
| 218 | + | return nil, ctx.Err() | |
| 219 | + | case <-wait.C: | |
| 220 | + | } | |
| 121 | 221 | } | |
| 122 | 222 | if _, statErr := os.Stat(path); os.IsNotExist(statErr) { | |
| 123 | 223 | return nil, ErrNotReady | |
| @@ -2,6 +2,7 @@ package subagents | |||||
| 2 | 2 | ||||
| 3 | import ( | 3 | import ( | ||
| 4 | "bufio" | 4 | "bufio" | ||
| 5 | + | "context" | |||
| 5 | "errors" | 6 | "errors" | ||
| 6 | "fmt" | 7 | "fmt" | ||
| 7 | "net" | 8 | "net" | ||
| @@ -32,6 +33,10 @@ type Inbox struct { | |||||
| 32 | 33 | ||||
| 33 | mu sync.Mutex | 34 | mu sync.Mutex | ||
| 34 | conn net.Conn // lazily dialed; one persistent connection per agent | 35 | conn net.Conn // lazily dialed; one persistent connection per agent | ||
| 36 | + | ||||
| 37 | + | // send is a one-slot semaphore that serializes JSONL writes without making | |||
| 38 | + | // a caller wait behind another blocked write after its context is canceled. | |||
| 39 | + | send chan struct{} | |||
| 35 | } | 40 | } | ||
| 36 | 41 | ||||
| 37 | // NewInbox returns a handle that will dial the socket at path on | 42 | // NewInbox returns a handle that will dial the socket at path on | ||
| @@ -40,7 +45,11 @@ type Inbox struct { | |||||
| 40 | // parent build the Inbox before the child has booted; sends that | 45 | // parent build the Inbox before the child has booted; sends that | ||
| 41 | // happen before the child is ready get a clear "not yet" error | 46 | // happen before the child is ready get a clear "not yet" error | ||
| 42 | // the caller can retry or surface. | 47 | // the caller can retry or surface. | ||
| 43 | - | func NewInbox(path string) *Inbox { return &Inbox{path: path} } | 48 | + | func NewInbox(path string) *Inbox { |
| 49 | + | inbox := &Inbox{path: path, send: make(chan struct{}, 1)} | |||
| 50 | + | inbox.send <- struct{}{} | |||
| 51 | + | return inbox | |||
| 52 | + | } | |||
| 44 | 53 | ||||
| 45 | // Path returns the absolute socket path. Used by the runner to | 54 | // Path returns the absolute socket path. Used by the runner to | ||
| 46 | // pass the path to the child as a flag. | 55 | // pass the path to the child as a flag. | ||
| @@ -48,34 +57,109 @@ func (b *Inbox) Path() string { return b.path } | |||||
| 48 | 57 | ||||
| 49 | // SendCommand sends a versioned JSONL command. | 58 | // SendCommand sends a versioned JSONL command. | ||
| 50 | func (b *Inbox) SendCommand(command Envelope) error { | 59 | func (b *Inbox) SendCommand(command Envelope) error { | ||
| 60 | + | return b.SendCommandContext(context.Background(), command) | |||
| 61 | + | } | |||
| 62 | + | ||||
| 63 | + | // SendCommandContext sends a command while honoring cancellation during both | |||
| 64 | + | // connection setup and a blocked write. Cancellation closes the persistent | |||
| 65 | + | // connection so a later command redials instead of reusing a half-written | |||
| 66 | + | // stream. | |||
| 67 | + | func (b *Inbox) SendCommandContext(ctx context.Context, command Envelope) error { | |||
| 51 | line, err := MarshalJSONL(command) | 68 | line, err := MarshalJSONL(command) | ||
| 52 | if err != nil { | 69 | if err != nil { | ||
| 53 | return err | 70 | return err | ||
| 54 | } | 71 | } | ||
| 55 | - | return b.sendBytes(line) | 72 | + | return b.sendBytesContext(ctx, line) |
| 56 | } | 73 | } | ||
| 57 | 74 | ||||
| 58 | func (b *Inbox) sendBytes(data []byte) error { | 75 | func (b *Inbox) sendBytes(data []byte) error { | ||
| 76 | + | return b.sendBytesContext(context.Background(), data) | |||
| 77 | + | } | |||
| 78 | + | ||||
| 79 | + | func (b *Inbox) sendBytesContext(ctx context.Context, data []byte) error { | |||
| 80 | + | if ctx == nil { | |||
| 81 | + | ctx = context.Background() | |||
| 82 | + | } | |||
| 83 | + | if err := ctx.Err(); err != nil { | |||
| 84 | + | return err | |||
| 85 | + | } | |||
| 86 | + | send, err := b.acquireSend(ctx) | |||
| 87 | + | if err != nil { | |||
| 88 | + | return err | |||
| 89 | + | } | |||
| 90 | + | defer releaseSend(send) | |||
| 91 | + | if err := ctx.Err(); err != nil { | |||
| 92 | + | return err | |||
| 93 | + | } | |||
| 94 | + | ||||
| 59 | b.mu.Lock() | 95 | b.mu.Lock() | ||
| 60 | - | defer b.mu.Unlock() | |||
| 61 | if b.conn == nil { | 96 | if b.conn == nil { | ||
| 62 | - | c, err := dialUnix(b.path, 200*time.Millisecond) | 97 | + | c, err := dialUnixContext(ctx, b.path, 200*time.Millisecond) |
| 63 | if err != nil { | 98 | if err != nil { | ||
| 99 | + | b.mu.Unlock() | |||
| 64 | return err | 100 | return err | ||
| 65 | } | 101 | } | ||
| 66 | b.conn = c | 102 | b.conn = c | ||
| 67 | } | 103 | } | ||
| 68 | - | if _, err := b.conn.Write(data); err != nil { | 104 | + | conn := b.conn |
| 69 | - | // Drop the connection so the next call redials. The | 105 | + | b.mu.Unlock() |
| 70 | - | // previous error is more informative than the redial's | 106 | + | |
| 71 | - | // would be, so surface this one. | 107 | + | stopCancel := context.AfterFunc(ctx, func() { _ = conn.Close() }) |
| 72 | - | _ = b.conn.Close() | 108 | + | if deadline, ok := ctx.Deadline(); ok { |
| 73 | - | b.conn = nil | 109 | + | if err := conn.SetWriteDeadline(deadline); err != nil { |
| 110 | + | stopCancel() | |||
| 111 | + | return err | |||
| 112 | + | } | |||
| 113 | + | defer conn.SetWriteDeadline(time.Time{}) | |||
| 114 | + | } | |||
| 115 | + | if _, err := conn.Write(data); err != nil { | |||
| 116 | + | // Drop the connection so the next call redials. The previous error is | |||
| 117 | + | // more informative than the redial's would be, so surface this one. | |||
| 118 | + | stopCancel() | |||
| 119 | + | b.mu.Lock() | |||
| 120 | + | if b.conn == conn { | |||
| 121 | + | _ = b.conn.Close() | |||
| 122 | + | b.conn = nil | |||
| 123 | + | } | |||
| 124 | + | b.mu.Unlock() | |||
| 125 | + | if ctxErr := ctx.Err(); ctxErr != nil { | |||
| 126 | + | return ctxErr | |||
| 127 | + | } | |||
| 74 | return err | 128 | return err | ||
| 75 | } | 129 | } | ||
| 130 | + | if !stopCancel() { | |||
| 131 | + | // Cancellation closed this connection after the write completed. Drop it | |||
| 132 | + | // so the next command redials instead of reusing a closed stream. | |||
| 133 | + | b.mu.Lock() | |||
| 134 | + | if b.conn == conn { | |||
| 135 | + | b.conn = nil | |||
| 136 | + | } | |||
| 137 | + | b.mu.Unlock() | |||
| 138 | + | return ctx.Err() | |||
| 139 | + | } | |||
| 76 | return nil | 140 | return nil | ||
| 77 | } | 141 | } | ||
| 78 | 142 | ||||
| 143 | + | func (b *Inbox) acquireSend(ctx context.Context) (chan struct{}, error) { | |||
| 144 | + | b.mu.Lock() | |||
| 145 | + | if b.send == nil { | |||
| 146 | + | b.send = make(chan struct{}, 1) | |||
| 147 | + | b.send <- struct{}{} | |||
| 148 | + | } | |||
| 149 | + | send := b.send | |||
| 150 | + | b.mu.Unlock() | |||
| 151 | + | select { | |||
| 152 | + | case <-ctx.Done(): | |||
| 153 | + | return nil, ctx.Err() | |||
| 154 | + | case <-send: | |||
| 155 | + | return send, nil | |||
| 156 | + | } | |||
| 157 | + | } | |||
| 158 | + | ||||
| 159 | + | func releaseSend(send chan struct{}) { | |||
| 160 | + | send <- struct{}{} | |||
| 161 | + | } | |||
| 162 | + | ||||
| 79 | // Close drops any persistent connection. Safe to call repeatedly. | 163 | // Close drops any persistent connection. Safe to call repeatedly. | ||
| 80 | // Does not unlink the socket file — that's the child's job | 164 | // Does not unlink the socket file — that's the child's job | ||
| 81 | // (Listener.Close). | 165 | // (Listener.Close). | ||
| @@ -95,29 +179,45 @@ func (b *Inbox) Close() error { | |||||
| 95 | // the TUI surfaces it as "agent <id> not listening yet". | 179 | // the TUI surfaces it as "agent <id> not listening yet". | ||
| 96 | var ErrNotReady = errors.New("subagent: agent inbox not ready") | 180 | var ErrNotReady = errors.New("subagent: agent inbox not ready") | ||
| 97 | 181 | ||||
| 98 | - | // dialUnix wraps net.DialTimeout with a small retry window so the | 182 | + | // dialUnixContext retries a Unix-socket connection briefly so the happy path |
| 99 | - | // happy path "child just started, supervisor sends right away" | 183 | + | // "child just started, supervisor sends right away" works without forcing the |
| 100 | - | // works without forcing the caller to poll. | 184 | + | // caller to poll. Errors collapse onto ErrNotReady whenever the failure means |
| 101 | - | // | 185 | + | // no worker is listening, without exposing the socket path to the caller. |
| 102 | - | // Errors collapse onto ErrNotReady whenever the failure means "no | 186 | + | func dialUnixContext(ctx context.Context, path string, timeout time.Duration) (net.Conn, error) { |
| 103 | - | // one is listening": either the socket file doesn't exist (child | 187 | + | if ctx == nil { |
| 104 | - | // hasn't booted) or it does exist but the dial was refused (the | 188 | + | ctx = context.Background() |
| 105 | - | // previous child exited and left a stale inode). Both are | 189 | + | } |
| 106 | - | // recoverable by Supervisor.Resume, and both should surface as the same | |||
| 107 | - | // user-facing hint rather than as a path-leaking error string. | |||
| 108 | - | func dialUnix(path string, timeout time.Duration) (net.Conn, error) { | |||
| 109 | deadline := time.Now().Add(timeout) | 190 | deadline := time.Now().Add(timeout) | ||
| 110 | var lastErr error | 191 | var lastErr error | ||
| 111 | for { | 192 | for { | ||
| 112 | - | c, err := net.DialTimeout("unix", path, 50*time.Millisecond) | 193 | + | if err := ctx.Err(); err != nil { |
| 194 | + | return nil, err | |||
| 195 | + | } | |||
| 196 | + | attemptTimeout := 50 * time.Millisecond | |||
| 197 | + | if remaining := time.Until(deadline); remaining < attemptTimeout { | |||
| 198 | + | attemptTimeout = remaining | |||
| 199 | + | } | |||
| 200 | + | if attemptTimeout <= 0 { | |||
| 201 | + | break | |||
| 202 | + | } | |||
| 203 | + | c, err := (&net.Dialer{Timeout: attemptTimeout}).DialContext(ctx, "unix", path) | |||
| 113 | if err == nil { | 204 | if err == nil { | ||
| 114 | return c, nil | 205 | return c, nil | ||
| 115 | } | 206 | } | ||
| 207 | + | if ctxErr := ctx.Err(); ctxErr != nil { | |||
| 208 | + | return nil, ctxErr | |||
| 209 | + | } | |||
| 116 | lastErr = err | 210 | lastErr = err | ||
| 117 | if time.Now().After(deadline) { | 211 | if time.Now().After(deadline) { | ||
| 118 | break | 212 | break | ||
| 119 | } | 213 | } | ||
| 120 | - | time.Sleep(20 * time.Millisecond) | 214 | + | wait := time.NewTimer(20 * time.Millisecond) |
| 215 | + | select { | |||
| 216 | + | case <-ctx.Done(): | |||
| 217 | + | wait.Stop() | |||
| 218 | + | return nil, ctx.Err() | |||
| 219 | + | case <-wait.C: | |||
| 220 | + | } | |||
| 121 | } | 221 | } | ||
| 122 | if _, statErr := os.Stat(path); os.IsNotExist(statErr) { | 222 | if _, statErr := os.Stat(path); os.IsNotExist(statErr) { | ||
| 123 | return nil, ErrNotReady | 223 | return nil, ErrNotReady | ||
M packages/agent/subagents/inbox_test.go +110 -0
| @@ -1,8 +1,10 @@ | |||
| 1 | 1 | package subagents | |
| 2 | 2 | ||
| 3 | 3 | import ( | |
| 4 | + | "context" | |
| 4 | 5 | "errors" | |
| 5 | 6 | "fmt" | |
| 7 | + | "net" | |
| 6 | 8 | "os" | |
| 7 | 9 | "path/filepath" | |
| 8 | 10 | "runtime" | |
| @@ -93,6 +95,114 @@ func TestInboxNotReady(t *testing.T) { | |||
| 93 | 95 | } | |
| 94 | 96 | } | |
| 95 | 97 | ||
| 98 | + | type blockingWriteConn struct { | |
| 99 | + | net.Conn | |
| 100 | + | started chan struct{} | |
| 101 | + | release chan struct{} | |
| 102 | + | startOnce sync.Once | |
| 103 | + | closeOnce sync.Once | |
| 104 | + | } | |
| 105 | + | ||
| 106 | + | func (c *blockingWriteConn) Write(data []byte) (int, error) { | |
| 107 | + | c.startOnce.Do(func() { close(c.started) }) | |
| 108 | + | <-c.release | |
| 109 | + | return c.Conn.Write(data) | |
| 110 | + | } | |
| 111 | + | ||
| 112 | + | func (c *blockingWriteConn) Close() error { | |
| 113 | + | c.closeOnce.Do(func() { close(c.release) }) | |
| 114 | + | return c.Conn.Close() | |
| 115 | + | } | |
| 116 | + | ||
| 117 | + | type notifyingContext struct { | |
| 118 | + | context.Context | |
| 119 | + | doneCalled chan struct{} | |
| 120 | + | once sync.Once | |
| 121 | + | } | |
| 122 | + | ||
| 123 | + | func (c *notifyingContext) Done() <-chan struct{} { | |
| 124 | + | c.once.Do(func() { close(c.doneCalled) }) | |
| 125 | + | return c.Context.Done() | |
| 126 | + | } | |
| 127 | + | ||
| 128 | + | func TestInboxSendContextCancelsBlockedWrite(t *testing.T) { | |
| 129 | + | local, remote := net.Pipe() | |
| 130 | + | defer remote.Close() | |
| 131 | + | conn := &blockingWriteConn{ | |
| 132 | + | Conn: local, | |
| 133 | + | started: make(chan struct{}), | |
| 134 | + | release: make(chan struct{}), | |
| 135 | + | } | |
| 136 | + | inbox := NewInbox("unused") | |
| 137 | + | inbox.conn = conn | |
| 138 | + | defer inbox.Close() | |
| 139 | + | ||
| 140 | + | ctx, cancel := context.WithCancel(context.Background()) | |
| 141 | + | sent := make(chan error, 1) | |
| 142 | + | go func() { sent <- inbox.sendBytesContext(ctx, []byte("follow-up\n")) }() | |
| 143 | + | select { | |
| 144 | + | case <-conn.started: | |
| 145 | + | case <-time.After(time.Second): | |
| 146 | + | t.Fatal("write did not start") | |
| 147 | + | } | |
| 148 | + | cancel() | |
| 149 | + | select { | |
| 150 | + | case err := <-sent: | |
| 151 | + | if !errors.Is(err, context.Canceled) { | |
| 152 | + | t.Fatalf("blocked write error = %v, want context canceled", err) | |
| 153 | + | } | |
| 154 | + | case <-time.After(time.Second): | |
| 155 | + | t.Fatal("blocked write did not honor context cancellation") | |
| 156 | + | } | |
| 157 | + | } | |
| 158 | + | ||
| 159 | + | func TestInboxSendContextCancelsWhileAnotherWriteIsBlocked(t *testing.T) { | |
| 160 | + | local, remote := net.Pipe() | |
| 161 | + | defer remote.Close() | |
| 162 | + | conn := &blockingWriteConn{ | |
| 163 | + | Conn: local, | |
| 164 | + | started: make(chan struct{}), | |
| 165 | + | release: make(chan struct{}), | |
| 166 | + | } | |
| 167 | + | inbox := NewInbox("unused") | |
| 168 | + | inbox.conn = conn | |
| 169 | + | defer inbox.Close() | |
| 170 | + | ||
| 171 | + | first := make(chan error, 1) | |
| 172 | + | go func() { first <- inbox.sendBytesContext(context.Background(), []byte("first\n")) }() | |
| 173 | + | select { | |
| 174 | + | case <-conn.started: | |
| 175 | + | case <-time.After(time.Second): | |
| 176 | + | t.Fatal("first write did not start") | |
| 177 | + | } | |
| 178 | + | ||
| 179 | + | base, cancel := context.WithCancel(context.Background()) | |
| 180 | + | secondCtx := ¬ifyingContext{Context: base, doneCalled: make(chan struct{})} | |
| 181 | + | second := make(chan error, 1) | |
| 182 | + | go func() { second <- inbox.sendBytesContext(secondCtx, []byte("second\n")) }() | |
| 183 | + | select { | |
| 184 | + | case <-secondCtx.doneCalled: | |
| 185 | + | case <-time.After(time.Second): | |
| 186 | + | t.Fatal("second write did not wait for the send semaphore") | |
| 187 | + | } | |
| 188 | + | cancel() | |
| 189 | + | select { | |
| 190 | + | case err := <-second: | |
| 191 | + | if !errors.Is(err, context.Canceled) { | |
| 192 | + | t.Fatalf("waiting write error = %v, want context canceled", err) | |
| 193 | + | } | |
| 194 | + | case <-time.After(time.Second): | |
| 195 | + | t.Fatal("waiting write did not honor context cancellation") | |
| 196 | + | } | |
| 197 | + | ||
| 198 | + | _ = conn.Close() | |
| 199 | + | select { | |
| 200 | + | case <-first: | |
| 201 | + | case <-time.After(time.Second): | |
| 202 | + | t.Fatal("first write did not unblock") | |
| 203 | + | } | |
| 204 | + | } | |
| 205 | + | ||
| 96 | 206 | // TestIsNoListenerErrMatchesECONNREFUSED documents the contract | |
| 97 | 207 | // that the user's screenshot regression depends on: any dial error | |
| 98 | 208 | // whose root cause is "the kernel refused the connect because nobody | |
| @@ -1,8 +1,10 @@ | |||||
| 1 | package subagents | 1 | package subagents | ||
| 2 | 2 | ||||
| 3 | import ( | 3 | import ( | ||
| 4 | + | "context" | |||
| 4 | "errors" | 5 | "errors" | ||
| 5 | "fmt" | 6 | "fmt" | ||
| 7 | + | "net" | |||
| 6 | "os" | 8 | "os" | ||
| 7 | "path/filepath" | 9 | "path/filepath" | ||
| 8 | "runtime" | 10 | "runtime" | ||
| @@ -93,6 +95,114 @@ func TestInboxNotReady(t *testing.T) { | |||||
| 93 | } | 95 | } | ||
| 94 | } | 96 | } | ||
| 95 | 97 | ||||
| 98 | + | type blockingWriteConn struct { | |||
| 99 | + | net.Conn | |||
| 100 | + | started chan struct{} | |||
| 101 | + | release chan struct{} | |||
| 102 | + | startOnce sync.Once | |||
| 103 | + | closeOnce sync.Once | |||
| 104 | + | } | |||
| 105 | + | ||||
| 106 | + | func (c *blockingWriteConn) Write(data []byte) (int, error) { | |||
| 107 | + | c.startOnce.Do(func() { close(c.started) }) | |||
| 108 | + | <-c.release | |||
| 109 | + | return c.Conn.Write(data) | |||
| 110 | + | } | |||
| 111 | + | ||||
| 112 | + | func (c *blockingWriteConn) Close() error { | |||
| 113 | + | c.closeOnce.Do(func() { close(c.release) }) | |||
| 114 | + | return c.Conn.Close() | |||
| 115 | + | } | |||
| 116 | + | ||||
| 117 | + | type notifyingContext struct { | |||
| 118 | + | context.Context | |||
| 119 | + | doneCalled chan struct{} | |||
| 120 | + | once sync.Once | |||
| 121 | + | } | |||
| 122 | + | ||||
| 123 | + | func (c *notifyingContext) Done() <-chan struct{} { | |||
| 124 | + | c.once.Do(func() { close(c.doneCalled) }) | |||
| 125 | + | return c.Context.Done() | |||
| 126 | + | } | |||
| 127 | + | ||||
| 128 | + | func TestInboxSendContextCancelsBlockedWrite(t *testing.T) { | |||
| 129 | + | local, remote := net.Pipe() | |||
| 130 | + | defer remote.Close() | |||
| 131 | + | conn := &blockingWriteConn{ | |||
| 132 | + | Conn: local, | |||
| 133 | + | started: make(chan struct{}), | |||
| 134 | + | release: make(chan struct{}), | |||
| 135 | + | } | |||
| 136 | + | inbox := NewInbox("unused") | |||
| 137 | + | inbox.conn = conn | |||
| 138 | + | defer inbox.Close() | |||
| 139 | + | ||||
| 140 | + | ctx, cancel := context.WithCancel(context.Background()) | |||
| 141 | + | sent := make(chan error, 1) | |||
| 142 | + | go func() { sent <- inbox.sendBytesContext(ctx, []byte("follow-up\n")) }() | |||
| 143 | + | select { | |||
| 144 | + | case <-conn.started: | |||
| 145 | + | case <-time.After(time.Second): | |||
| 146 | + | t.Fatal("write did not start") | |||
| 147 | + | } | |||
| 148 | + | cancel() | |||
| 149 | + | select { | |||
| 150 | + | case err := <-sent: | |||
| 151 | + | if !errors.Is(err, context.Canceled) { | |||
| 152 | + | t.Fatalf("blocked write error = %v, want context canceled", err) | |||
| 153 | + | } | |||
| 154 | + | case <-time.After(time.Second): | |||
| 155 | + | t.Fatal("blocked write did not honor context cancellation") | |||
| 156 | + | } | |||
| 157 | + | } | |||
| 158 | + | ||||
| 159 | + | func TestInboxSendContextCancelsWhileAnotherWriteIsBlocked(t *testing.T) { | |||
| 160 | + | local, remote := net.Pipe() | |||
| 161 | + | defer remote.Close() | |||
| 162 | + | conn := &blockingWriteConn{ | |||
| 163 | + | Conn: local, | |||
| 164 | + | started: make(chan struct{}), | |||
| 165 | + | release: make(chan struct{}), | |||
| 166 | + | } | |||
| 167 | + | inbox := NewInbox("unused") | |||
| 168 | + | inbox.conn = conn | |||
| 169 | + | defer inbox.Close() | |||
| 170 | + | ||||
| 171 | + | first := make(chan error, 1) | |||
| 172 | + | go func() { first <- inbox.sendBytesContext(context.Background(), []byte("first\n")) }() | |||
| 173 | + | select { | |||
| 174 | + | case <-conn.started: | |||
| 175 | + | case <-time.After(time.Second): | |||
| 176 | + | t.Fatal("first write did not start") | |||
| 177 | + | } | |||
| 178 | + | ||||
| 179 | + | base, cancel := context.WithCancel(context.Background()) | |||
| 180 | + | secondCtx := ¬ifyingContext{Context: base, doneCalled: make(chan struct{})} | |||
| 181 | + | second := make(chan error, 1) | |||
| 182 | + | go func() { second <- inbox.sendBytesContext(secondCtx, []byte("second\n")) }() | |||
| 183 | + | select { | |||
| 184 | + | case <-secondCtx.doneCalled: | |||
| 185 | + | case <-time.After(time.Second): | |||
| 186 | + | t.Fatal("second write did not wait for the send semaphore") | |||
| 187 | + | } | |||
| 188 | + | cancel() | |||
| 189 | + | select { | |||
| 190 | + | case err := <-second: | |||
| 191 | + | if !errors.Is(err, context.Canceled) { | |||
| 192 | + | t.Fatalf("waiting write error = %v, want context canceled", err) | |||
| 193 | + | } | |||
| 194 | + | case <-time.After(time.Second): | |||
| 195 | + | t.Fatal("waiting write did not honor context cancellation") | |||
| 196 | + | } | |||
| 197 | + | ||||
| 198 | + | _ = conn.Close() | |||
| 199 | + | select { | |||
| 200 | + | case <-first: | |||
| 201 | + | case <-time.After(time.Second): | |||
| 202 | + | t.Fatal("first write did not unblock") | |||
| 203 | + | } | |||
| 204 | + | } | |||
| 205 | + | ||||
| 96 | // TestIsNoListenerErrMatchesECONNREFUSED documents the contract | 206 | // TestIsNoListenerErrMatchesECONNREFUSED documents the contract | ||
| 97 | // that the user's screenshot regression depends on: any dial error | 207 | // that the user's screenshot regression depends on: any dial error | ||
| 98 | // whose root cause is "the kernel refused the connect because nobody | 208 | // whose root cause is "the kernel refused the connect because nobody | ||
M packages/agent/subagents/persist.go +108 -6
| @@ -74,6 +74,11 @@ type agentMeta struct { | |||
| 74 | 74 | InboxPath string `json:"inbox_path"` | |
| 75 | 75 | EventLogPath string `json:"event_log_path"` | |
| 76 | 76 | SessionPath string `json:"session_path"` | |
| 77 | + | // ResumePrompt is a follow-up accepted by subagent_resume but not yet | |
| 78 | + | // acknowledged by the child with a turn.started event. It is durable so a | |
| 79 | + | // host exit while the worker is queued does not lose the manager signal. | |
| 80 | + | ResumePrompt string `json:"resume_prompt,omitempty"` | |
| 81 | + | ResumePromptAt time.Time `json:"resume_prompt_at,omitempty"` | |
| 77 | 82 | ||
| 78 | 83 | // SessionID, when non-empty, scopes the agent to a particular | |
| 79 | 84 | // host zut session: the dashboard only shows agents whose | |
| @@ -97,6 +102,8 @@ func writeAgentMeta(stateDir string, a *Agent) error { | |||
| 97 | 102 | status := a.status | |
| 98 | 103 | activity := a.activity | |
| 99 | 104 | finished := a.finished | |
| 105 | + | resumePrompt := a.ResumePrompt | |
| 106 | + | resumePromptAt := a.ResumePromptAt | |
| 100 | 107 | errText := "" | |
| 101 | 108 | if a.lastErr != nil { | |
| 102 | 109 | errText = a.lastErr.Error() | |
| @@ -156,6 +163,8 @@ func writeAgentMeta(stateDir string, a *Agent) error { | |||
| 156 | 163 | InboxPath: a.InboxPath, | |
| 157 | 164 | EventLogPath: a.EventLogPath, | |
| 158 | 165 | SessionPath: a.SessionPath, | |
| 166 | + | ResumePrompt: resumePrompt, | |
| 167 | + | ResumePromptAt: resumePromptAt, | |
| 159 | 168 | SessionID: a.SessionID, | |
| 160 | 169 | } | |
| 161 | 170 | b, err := json.MarshalIndent(m, "", " ") | |
| @@ -440,6 +449,8 @@ func (f *Supervisor) buildDetachedAgent(m agentMeta) *Agent { | |||
| 440 | 449 | InboxPath: m.InboxPath, | |
| 441 | 450 | EventLogPath: m.EventLogPath, | |
| 442 | 451 | SessionPath: m.SessionPath, | |
| 452 | + | ResumePrompt: m.ResumePrompt, | |
| 453 | + | ResumePromptAt: m.ResumePromptAt, | |
| 443 | 454 | SessionID: m.SessionID, | |
| 444 | 455 | inbox: NewInbox(m.InboxPath), | |
| 445 | 456 | status: status, | |
| @@ -531,6 +542,9 @@ func (a *Agent) loadTranscript() error { | |||
| 531 | 542 | if err != nil { | |
| 532 | 543 | return fmt.Errorf("read subagent transcript: %w", err) | |
| 533 | 544 | } | |
| 545 | + | if prompt, acceptedAt := a.resumePromptInfo(); prompt != "" && resumePromptAcknowledged(events, acceptedAt) { | |
| 546 | + | a.clearResumePrompt() | |
| 547 | + | } | |
| 534 | 548 | a.mu.Lock() | |
| 535 | 549 | if a.transcriptLoaded { | |
| 536 | 550 | a.mu.Unlock() | |
| @@ -569,6 +583,18 @@ func (a *Agent) loadTranscript() error { | |||
| 569 | 583 | // Status precedence: explicit lifecycle events (agent_stopped) win | |
| 570 | 584 | // over inferred ones (assistant_message → idle). If the log contains | |
| 571 | 585 | // no terminator we keep status=StatusDetached so the user can resume. | |
| 586 | + | func resumePromptAcknowledged(events []Event, acceptedAt time.Time) bool { | |
| 587 | + | if acceptedAt.IsZero() { | |
| 588 | + | return false | |
| 589 | + | } | |
| 590 | + | for _, ev := range events { | |
| 591 | + | if (ev.Type == EventTurnStarted || ev.Type == "turn_start") && !ev.Time.Before(acceptedAt) { | |
| 592 | + | return true | |
| 593 | + | } | |
| 594 | + | } | |
| 595 | + | return false | |
| 596 | + | } | |
| 597 | + | ||
| 572 | 598 | func replayEventsIntoAgent(a *Agent, evs []Event) { | |
| 573 | 599 | terminal := false | |
| 574 | 600 | for _, ev := range evs { | |
| @@ -698,22 +724,76 @@ func replayEventTranscript(a *Agent, ev Event) { | |||
| 698 | 724 | // Killed). Resuming a still-running agent returns an error so two | |
| 699 | 725 | // runners don't race for the same session. | |
| 700 | 726 | func (f *Supervisor) Resume(ctx context.Context, id string) (*Agent, error) { | |
| 701 | - | return f.resume(ctx, id, true) | |
| 727 | + | return f.resume(ctx, id, true, "") | |
| 702 | 728 | } | |
| 703 | 729 | ||
| 704 | 730 | // ResumeSession continues the existing child session without replaying the | |
| 705 | 731 | // original task. It is the explicit canonical spelling for Resume. | |
| 706 | 732 | func (f *Supervisor) ResumeSession(ctx context.Context, id string) (*Agent, error) { | |
| 707 | - | return f.resume(ctx, id, true) | |
| 733 | + | return f.resume(ctx, id, true, "") | |
| 734 | + | } | |
| 735 | + | ||
| 736 | + | // ResumeWithPrompt continues an agent with a manager follow-up while retaining | |
| 737 | + | // its existing session. An idle live worker receives the turn through its | |
| 738 | + | // inbox; a terminal worker is restarted with the follow-up as its initial | |
| 739 | + | // prompt, avoiding a race with inbox listener startup. | |
| 740 | + | func (f *Supervisor) ResumeWithPrompt(ctx context.Context, id, prompt string) (*Agent, error) { | |
| 741 | + | if strings.TrimSpace(prompt) == "" { | |
| 742 | + | return nil, fmt.Errorf("subagent: follow-up prompt is required") | |
| 743 | + | } | |
| 744 | + | ||
| 745 | + | f.operationMu.Lock() | |
| 746 | + | existing := f.Get(id) | |
| 747 | + | if existing == nil { | |
| 748 | + | f.operationMu.Unlock() | |
| 749 | + | return nil, fmt.Errorf("subagent: no such agent %q", id) | |
| 750 | + | } | |
| 751 | + | status := existing.Status() | |
| 752 | + | if status == StatusRunning { | |
| 753 | + | if existing.ProcessState() != ProcessAlive || existing.TurnState() != TurnIdle { | |
| 754 | + | f.operationMu.Unlock() | |
| 755 | + | return nil, fmt.Errorf("subagent: agent %s is still working; wait for it to become idle before sending a follow-up", existing.ID) | |
| 756 | + | } | |
| 757 | + | if existing.inbox == nil { | |
| 758 | + | f.operationMu.Unlock() | |
| 759 | + | return nil, fmt.Errorf("subagent: agent %s has no inbox", existing.ID) | |
| 760 | + | } | |
| 761 | + | turnID := existing.CurrentTurnID() | |
| 762 | + | // Reserve the worker before writing to its inbox. A second manager call | |
| 763 | + | // must not enqueue another turn in the small interval before the worker | |
| 764 | + | // reports its turn.start event. Persist the prompt first so a host exit | |
| 765 | + | // before that event can resume and redeliver it. | |
| 766 | + | previousPrompt, previousPromptAt := existing.setResumePrompt(prompt, f.cfg.Now()) | |
| 767 | + | existing.setTurnState(TurnQueued, turnID) | |
| 768 | + | inbox := existing.inbox | |
| 769 | + | command := NewCommand(CommandTurnStart, existing.ID, turnID, TurnStartPayload{Prompt: prompt}) | |
| 770 | + | f.persistAgent(existing) | |
| 771 | + | f.operationMu.Unlock() | |
| 772 | + | ||
| 773 | + | if err := inbox.SendCommandContext(ctx, command); err != nil { | |
| 774 | + | f.operationMu.Lock() | |
| 775 | + | if existing.Status() == StatusRunning && existing.TurnState() == TurnQueued && existing.CurrentTurnID() == turnID { | |
| 776 | + | existing.setTurnState(TurnIdle, turnID) | |
| 777 | + | existing.setResumePrompt(previousPrompt, previousPromptAt) | |
| 778 | + | f.persistAgent(existing) | |
| 779 | + | } | |
| 780 | + | f.operationMu.Unlock() | |
| 781 | + | return nil, fmt.Errorf("subagent: send follow-up to %s: %w", existing.ID, err) | |
| 782 | + | } | |
| 783 | + | return existing, nil | |
| 784 | + | } | |
| 785 | + | f.operationMu.Unlock() | |
| 786 | + | ||
| 787 | + | return f.resume(ctx, id, true, prompt) | |
| 708 | 788 | } | |
| 709 | 789 | ||
| 710 | 790 | // RestartTask intentionally runs the stored original task again in a fresh | |
| 711 | 791 | // worker attempt. Callers should use ResumeSession for normal recovery. | |
| 712 | 792 | func (f *Supervisor) RestartTask(ctx context.Context, id string) (*Agent, error) { | |
| 713 | - | return f.resume(ctx, id, false) | |
| 793 | + | return f.resume(ctx, id, false, "") | |
| 714 | 794 | } | |
| 715 | 795 | ||
| 716 | - | func (f *Supervisor) resume(ctx context.Context, id string, resuming bool) (*Agent, error) { | |
| 796 | + | func (f *Supervisor) resume(ctx context.Context, id string, resuming bool, resumePrompt string) (*Agent, error) { | |
| 717 | 797 | f.operationMu.Lock() | |
| 718 | 798 | defer f.operationMu.Unlock() | |
| 719 | 799 | if ctx == nil { | |
| @@ -741,6 +821,14 @@ func (f *Supervisor) resume(ctx context.Context, id string, resuming bool) (*Age | |||
| 741 | 821 | if inboxLive(existing.InboxPath) { | |
| 742 | 822 | return nil, fmt.Errorf("subagent: agent %s still has a live worker; send shutdown or stop it first", existing.ID) | |
| 743 | 823 | } | |
| 824 | + | // A fresh Reload does not hydrate its event log. Before replaying a pending | |
| 825 | + | // follow-up, hydrate it to see whether the child already acknowledged the | |
| 826 | + | // turn before the previous host exited. | |
| 827 | + | if resuming && existing.resumePrompt() != "" { | |
| 828 | + | if err := existing.loadTranscript(); err != nil { | |
| 829 | + | return nil, fmt.Errorf("subagent: replay pending follow-up: %w", err) | |
| 830 | + | } | |
| 831 | + | } | |
| 744 | 832 | stateDir := existing.stateDirectory(f.cfg.Root) | |
| 745 | 833 | lease, err := acquireAgentLease(stateDir) | |
| 746 | 834 | if err != nil { | |
| @@ -762,7 +850,18 @@ func (f *Supervisor) resume(ctx context.Context, id string, resuming bool) (*Age | |||
| 762 | 850 | if err != nil { | |
| 763 | 851 | return nil, fmt.Errorf("subagent inbox path: %w", err) | |
| 764 | 852 | } | |
| 853 | + | now := f.cfg.Now() | |
| 765 | 854 | fastMode := existing.FastMode | |
| 855 | + | pendingResumePrompt := "" | |
| 856 | + | pendingResumePromptAt := time.Time{} | |
| 857 | + | if resuming { | |
| 858 | + | pendingResumePrompt = firstNonEmpty(resumePrompt, existing.resumePrompt()) | |
| 859 | + | if resumePrompt != "" { | |
| 860 | + | pendingResumePromptAt = now | |
| 861 | + | } else if pendingResumePrompt != "" { | |
| 862 | + | _, pendingResumePromptAt = existing.resumePromptInfo() | |
| 863 | + | } | |
| 864 | + | } | |
| 766 | 865 | m := agentMeta{ | |
| 767 | 866 | ID: existing.ID, Task: existing.Task, | |
| 768 | 867 | OriginalTask: existing.OriginalTask, | |
| @@ -779,7 +878,9 @@ func (f *Supervisor) resume(ctx context.Context, id string, resuming bool) (*Age | |||
| 779 | 878 | CurrentTurnID: existingSnapshot.CurrentTurnID, Attempt: existingSnapshot.Attempt, | |
| 780 | 879 | SessionID: existing.SessionID, | |
| 781 | 880 | InboxPath: inboxPath, EventLogPath: existing.EventLogPath, | |
| 782 | - | SessionPath: existing.SessionPath, | |
| 881 | + | SessionPath: existing.SessionPath, | |
| 882 | + | ResumePrompt: pendingResumePrompt, | |
| 883 | + | ResumePromptAt: pendingResumePromptAt, | |
| 783 | 884 | } | |
| 784 | 885 | ||
| 785 | 886 | if len(m.Tools) == 0 && len(f.cfg.Policy.AllowedTools) > 0 { | |
| @@ -805,7 +906,6 @@ func (f *Supervisor) resume(ctx context.Context, id string, resuming bool) (*Age | |||
| 805 | 906 | if err := validateWorkspaceRoot(repositoryRoot, workspaceMode, allowedRoots); err != nil { | |
| 806 | 907 | return nil, err | |
| 807 | 908 | } | |
| 808 | - | now := f.cfg.Now() | |
| 809 | 909 | maxTurns := m.MaxTurns | |
| 810 | 910 | if maxTurns <= 0 { | |
| 811 | 911 | maxTurns = f.cfg.Policy.MaxTurns | |
| @@ -868,6 +968,8 @@ func (f *Supervisor) resume(ctx context.Context, id string, resuming bool) (*Age | |||
| 868 | 968 | EventLogPath: m.EventLogPath, | |
| 869 | 969 | SessionPath: m.SessionPath, | |
| 870 | 970 | Resuming: resuming, | |
| 971 | + | ResumePrompt: pendingResumePrompt, | |
| 972 | + | ResumePromptAt: pendingResumePromptAt, | |
| 871 | 973 | inbox: NewInbox(m.InboxPath), | |
| 872 | 974 | status: StatusPending, | |
| 873 | 975 | activity: "resuming", | |
| @@ -74,6 +74,11 @@ type agentMeta struct { | |||||
| 74 | InboxPath string `json:"inbox_path"` | 74 | InboxPath string `json:"inbox_path"` | ||
| 75 | EventLogPath string `json:"event_log_path"` | 75 | EventLogPath string `json:"event_log_path"` | ||
| 76 | SessionPath string `json:"session_path"` | 76 | SessionPath string `json:"session_path"` | ||
| 77 | + | // ResumePrompt is a follow-up accepted by subagent_resume but not yet | |||
| 78 | + | // acknowledged by the child with a turn.started event. It is durable so a | |||
| 79 | + | // host exit while the worker is queued does not lose the manager signal. | |||
| 80 | + | ResumePrompt string `json:"resume_prompt,omitempty"` | |||
| 81 | + | ResumePromptAt time.Time `json:"resume_prompt_at,omitempty"` | |||
| 77 | 82 | ||||
| 78 | // SessionID, when non-empty, scopes the agent to a particular | 83 | // SessionID, when non-empty, scopes the agent to a particular | ||
| 79 | // host zut session: the dashboard only shows agents whose | 84 | // host zut session: the dashboard only shows agents whose | ||
| @@ -97,6 +102,8 @@ func writeAgentMeta(stateDir string, a *Agent) error { | |||||
| 97 | status := a.status | 102 | status := a.status | ||
| 98 | activity := a.activity | 103 | activity := a.activity | ||
| 99 | finished := a.finished | 104 | finished := a.finished | ||
| 105 | + | resumePrompt := a.ResumePrompt | |||
| 106 | + | resumePromptAt := a.ResumePromptAt | |||
| 100 | errText := "" | 107 | errText := "" | ||
| 101 | if a.lastErr != nil { | 108 | if a.lastErr != nil { | ||
| 102 | errText = a.lastErr.Error() | 109 | errText = a.lastErr.Error() | ||
| @@ -156,6 +163,8 @@ func writeAgentMeta(stateDir string, a *Agent) error { | |||||
| 156 | InboxPath: a.InboxPath, | 163 | InboxPath: a.InboxPath, | ||
| 157 | EventLogPath: a.EventLogPath, | 164 | EventLogPath: a.EventLogPath, | ||
| 158 | SessionPath: a.SessionPath, | 165 | SessionPath: a.SessionPath, | ||
| 166 | + | ResumePrompt: resumePrompt, | |||
| 167 | + | ResumePromptAt: resumePromptAt, | |||
| 159 | SessionID: a.SessionID, | 168 | SessionID: a.SessionID, | ||
| 160 | } | 169 | } | ||
| 161 | b, err := json.MarshalIndent(m, "", " ") | 170 | b, err := json.MarshalIndent(m, "", " ") | ||
| @@ -440,6 +449,8 @@ func (f *Supervisor) buildDetachedAgent(m agentMeta) *Agent { | |||||
| 440 | InboxPath: m.InboxPath, | 449 | InboxPath: m.InboxPath, | ||
| 441 | EventLogPath: m.EventLogPath, | 450 | EventLogPath: m.EventLogPath, | ||
| 442 | SessionPath: m.SessionPath, | 451 | SessionPath: m.SessionPath, | ||
| 452 | + | ResumePrompt: m.ResumePrompt, | |||
| 453 | + | ResumePromptAt: m.ResumePromptAt, | |||
| 443 | SessionID: m.SessionID, | 454 | SessionID: m.SessionID, | ||
| 444 | inbox: NewInbox(m.InboxPath), | 455 | inbox: NewInbox(m.InboxPath), | ||
| 445 | status: status, | 456 | status: status, | ||
| @@ -531,6 +542,9 @@ func (a *Agent) loadTranscript() error { | |||||
| 531 | if err != nil { | 542 | if err != nil { | ||
| 532 | return fmt.Errorf("read subagent transcript: %w", err) | 543 | return fmt.Errorf("read subagent transcript: %w", err) | ||
| 533 | } | 544 | } | ||
| 545 | + | if prompt, acceptedAt := a.resumePromptInfo(); prompt != "" && resumePromptAcknowledged(events, acceptedAt) { | |||
| 546 | + | a.clearResumePrompt() | |||
| 547 | + | } | |||
| 534 | a.mu.Lock() | 548 | a.mu.Lock() | ||
| 535 | if a.transcriptLoaded { | 549 | if a.transcriptLoaded { | ||
| 536 | a.mu.Unlock() | 550 | a.mu.Unlock() | ||
| @@ -569,6 +583,18 @@ func (a *Agent) loadTranscript() error { | |||||
| 569 | // Status precedence: explicit lifecycle events (agent_stopped) win | 583 | // Status precedence: explicit lifecycle events (agent_stopped) win | ||
| 570 | // over inferred ones (assistant_message → idle). If the log contains | 584 | // over inferred ones (assistant_message → idle). If the log contains | ||
| 571 | // no terminator we keep status=StatusDetached so the user can resume. | 585 | // no terminator we keep status=StatusDetached so the user can resume. | ||
| 586 | + | func resumePromptAcknowledged(events []Event, acceptedAt time.Time) bool { | |||
| 587 | + | if acceptedAt.IsZero() { | |||
| 588 | + | return false | |||
| 589 | + | } | |||
| 590 | + | for _, ev := range events { | |||
| 591 | + | if (ev.Type == EventTurnStarted || ev.Type == "turn_start") && !ev.Time.Before(acceptedAt) { | |||
| 592 | + | return true | |||
| 593 | + | } | |||
| 594 | + | } | |||
| 595 | + | return false | |||
| 596 | + | } | |||
| 597 | + | ||||
| 572 | func replayEventsIntoAgent(a *Agent, evs []Event) { | 598 | func replayEventsIntoAgent(a *Agent, evs []Event) { | ||
| 573 | terminal := false | 599 | terminal := false | ||
| 574 | for _, ev := range evs { | 600 | for _, ev := range evs { | ||
| @@ -698,22 +724,76 @@ func replayEventTranscript(a *Agent, ev Event) { | |||||
| 698 | // Killed). Resuming a still-running agent returns an error so two | 724 | // Killed). Resuming a still-running agent returns an error so two | ||
| 699 | // runners don't race for the same session. | 725 | // runners don't race for the same session. | ||
| 700 | func (f *Supervisor) Resume(ctx context.Context, id string) (*Agent, error) { | 726 | func (f *Supervisor) Resume(ctx context.Context, id string) (*Agent, error) { | ||
| 701 | - | return f.resume(ctx, id, true) | 727 | + | return f.resume(ctx, id, true, "") |
| 702 | } | 728 | } | ||
| 703 | 729 | ||||
| 704 | // ResumeSession continues the existing child session without replaying the | 730 | // ResumeSession continues the existing child session without replaying the | ||
| 705 | // original task. It is the explicit canonical spelling for Resume. | 731 | // original task. It is the explicit canonical spelling for Resume. | ||
| 706 | func (f *Supervisor) ResumeSession(ctx context.Context, id string) (*Agent, error) { | 732 | func (f *Supervisor) ResumeSession(ctx context.Context, id string) (*Agent, error) { | ||
| 707 | - | return f.resume(ctx, id, true) | 733 | + | return f.resume(ctx, id, true, "") |
| 734 | + | } | |||
| 735 | + | ||||
| 736 | + | // ResumeWithPrompt continues an agent with a manager follow-up while retaining | |||
| 737 | + | // its existing session. An idle live worker receives the turn through its | |||
| 738 | + | // inbox; a terminal worker is restarted with the follow-up as its initial | |||
| 739 | + | // prompt, avoiding a race with inbox listener startup. | |||
| 740 | + | func (f *Supervisor) ResumeWithPrompt(ctx context.Context, id, prompt string) (*Agent, error) { | |||
| 741 | + | if strings.TrimSpace(prompt) == "" { | |||
| 742 | + | return nil, fmt.Errorf("subagent: follow-up prompt is required") | |||
| 743 | + | } | |||
| 744 | + | ||||
| 745 | + | f.operationMu.Lock() | |||
| 746 | + | existing := f.Get(id) | |||
| 747 | + | if existing == nil { | |||
| 748 | + | f.operationMu.Unlock() | |||
| 749 | + | return nil, fmt.Errorf("subagent: no such agent %q", id) | |||
| 750 | + | } | |||
| 751 | + | status := existing.Status() | |||
| 752 | + | if status == StatusRunning { | |||
| 753 | + | if existing.ProcessState() != ProcessAlive || existing.TurnState() != TurnIdle { | |||
| 754 | + | f.operationMu.Unlock() | |||
| 755 | + | return nil, fmt.Errorf("subagent: agent %s is still working; wait for it to become idle before sending a follow-up", existing.ID) | |||
| 756 | + | } | |||
| 757 | + | if existing.inbox == nil { | |||
| 758 | + | f.operationMu.Unlock() | |||
| 759 | + | return nil, fmt.Errorf("subagent: agent %s has no inbox", existing.ID) | |||
| 760 | + | } | |||
| 761 | + | turnID := existing.CurrentTurnID() | |||
| 762 | + | // Reserve the worker before writing to its inbox. A second manager call | |||
| 763 | + | // must not enqueue another turn in the small interval before the worker | |||
| 764 | + | // reports its turn.start event. Persist the prompt first so a host exit | |||
| 765 | + | // before that event can resume and redeliver it. | |||
| 766 | + | previousPrompt, previousPromptAt := existing.setResumePrompt(prompt, f.cfg.Now()) | |||
| 767 | + | existing.setTurnState(TurnQueued, turnID) | |||
| 768 | + | inbox := existing.inbox | |||
| 769 | + | command := NewCommand(CommandTurnStart, existing.ID, turnID, TurnStartPayload{Prompt: prompt}) | |||
| 770 | + | f.persistAgent(existing) | |||
| 771 | + | f.operationMu.Unlock() | |||
| 772 | + | ||||
| 773 | + | if err := inbox.SendCommandContext(ctx, command); err != nil { | |||
| 774 | + | f.operationMu.Lock() | |||
| 775 | + | if existing.Status() == StatusRunning && existing.TurnState() == TurnQueued && existing.CurrentTurnID() == turnID { | |||
| 776 | + | existing.setTurnState(TurnIdle, turnID) | |||
| 777 | + | existing.setResumePrompt(previousPrompt, previousPromptAt) | |||
| 778 | + | f.persistAgent(existing) | |||
| 779 | + | } | |||
| 780 | + | f.operationMu.Unlock() | |||
| 781 | + | return nil, fmt.Errorf("subagent: send follow-up to %s: %w", existing.ID, err) | |||
| 782 | + | } | |||
| 783 | + | return existing, nil | |||
| 784 | + | } | |||
| 785 | + | f.operationMu.Unlock() | |||
| 786 | + | ||||
| 787 | + | return f.resume(ctx, id, true, prompt) | |||
| 708 | } | 788 | } | ||
| 709 | 789 | ||||
| 710 | // RestartTask intentionally runs the stored original task again in a fresh | 790 | // RestartTask intentionally runs the stored original task again in a fresh | ||
| 711 | // worker attempt. Callers should use ResumeSession for normal recovery. | 791 | // worker attempt. Callers should use ResumeSession for normal recovery. | ||
| 712 | func (f *Supervisor) RestartTask(ctx context.Context, id string) (*Agent, error) { | 792 | func (f *Supervisor) RestartTask(ctx context.Context, id string) (*Agent, error) { | ||
| 713 | - | return f.resume(ctx, id, false) | 793 | + | return f.resume(ctx, id, false, "") |
| 714 | } | 794 | } | ||
| 715 | 795 | ||||
| 716 | - | func (f *Supervisor) resume(ctx context.Context, id string, resuming bool) (*Agent, error) { | 796 | + | func (f *Supervisor) resume(ctx context.Context, id string, resuming bool, resumePrompt string) (*Agent, error) { |
| 717 | f.operationMu.Lock() | 797 | f.operationMu.Lock() | ||
| 718 | defer f.operationMu.Unlock() | 798 | defer f.operationMu.Unlock() | ||
| 719 | if ctx == nil { | 799 | if ctx == nil { | ||
| @@ -741,6 +821,14 @@ func (f *Supervisor) resume(ctx context.Context, id string, resuming bool) (*Age | |||||
| 741 | if inboxLive(existing.InboxPath) { | 821 | if inboxLive(existing.InboxPath) { | ||
| 742 | return nil, fmt.Errorf("subagent: agent %s still has a live worker; send shutdown or stop it first", existing.ID) | 822 | return nil, fmt.Errorf("subagent: agent %s still has a live worker; send shutdown or stop it first", existing.ID) | ||
| 743 | } | 823 | } | ||
| 824 | + | // A fresh Reload does not hydrate its event log. Before replaying a pending | |||
| 825 | + | // follow-up, hydrate it to see whether the child already acknowledged the | |||
| 826 | + | // turn before the previous host exited. | |||
| 827 | + | if resuming && existing.resumePrompt() != "" { | |||
| 828 | + | if err := existing.loadTranscript(); err != nil { | |||
| 829 | + | return nil, fmt.Errorf("subagent: replay pending follow-up: %w", err) | |||
| 830 | + | } | |||
| 831 | + | } | |||
| 744 | stateDir := existing.stateDirectory(f.cfg.Root) | 832 | stateDir := existing.stateDirectory(f.cfg.Root) | ||
| 745 | lease, err := acquireAgentLease(stateDir) | 833 | lease, err := acquireAgentLease(stateDir) | ||
| 746 | if err != nil { | 834 | if err != nil { | ||
| @@ -762,7 +850,18 @@ func (f *Supervisor) resume(ctx context.Context, id string, resuming bool) (*Age | |||||
| 762 | if err != nil { | 850 | if err != nil { | ||
| 763 | return nil, fmt.Errorf("subagent inbox path: %w", err) | 851 | return nil, fmt.Errorf("subagent inbox path: %w", err) | ||
| 764 | } | 852 | } | ||
| 853 | + | now := f.cfg.Now() | |||
| 765 | fastMode := existing.FastMode | 854 | fastMode := existing.FastMode | ||
| 855 | + | pendingResumePrompt := "" | |||
| 856 | + | pendingResumePromptAt := time.Time{} | |||
| 857 | + | if resuming { | |||
| 858 | + | pendingResumePrompt = firstNonEmpty(resumePrompt, existing.resumePrompt()) | |||
| 859 | + | if resumePrompt != "" { | |||
| 860 | + | pendingResumePromptAt = now | |||
| 861 | + | } else if pendingResumePrompt != "" { | |||
| 862 | + | _, pendingResumePromptAt = existing.resumePromptInfo() | |||
| 863 | + | } | |||
| 864 | + | } | |||
| 766 | m := agentMeta{ | 865 | m := agentMeta{ | ||
| 767 | ID: existing.ID, Task: existing.Task, | 866 | ID: existing.ID, Task: existing.Task, | ||
| 768 | OriginalTask: existing.OriginalTask, | 867 | OriginalTask: existing.OriginalTask, | ||
| @@ -779,7 +878,9 @@ func (f *Supervisor) resume(ctx context.Context, id string, resuming bool) (*Age | |||||
| 779 | CurrentTurnID: existingSnapshot.CurrentTurnID, Attempt: existingSnapshot.Attempt, | 878 | CurrentTurnID: existingSnapshot.CurrentTurnID, Attempt: existingSnapshot.Attempt, | ||
| 780 | SessionID: existing.SessionID, | 879 | SessionID: existing.SessionID, | ||
| 781 | InboxPath: inboxPath, EventLogPath: existing.EventLogPath, | 880 | InboxPath: inboxPath, EventLogPath: existing.EventLogPath, | ||
| 782 | - | SessionPath: existing.SessionPath, | 881 | + | SessionPath: existing.SessionPath, |
| 882 | + | ResumePrompt: pendingResumePrompt, | |||
| 883 | + | ResumePromptAt: pendingResumePromptAt, | |||
| 783 | } | 884 | } | ||
| 784 | 885 | ||||
| 785 | if len(m.Tools) == 0 && len(f.cfg.Policy.AllowedTools) > 0 { | 886 | if len(m.Tools) == 0 && len(f.cfg.Policy.AllowedTools) > 0 { | ||
| @@ -805,7 +906,6 @@ func (f *Supervisor) resume(ctx context.Context, id string, resuming bool) (*Age | |||||
| 805 | if err := validateWorkspaceRoot(repositoryRoot, workspaceMode, allowedRoots); err != nil { | 906 | if err := validateWorkspaceRoot(repositoryRoot, workspaceMode, allowedRoots); err != nil { | ||
| 806 | return nil, err | 907 | return nil, err | ||
| 807 | } | 908 | } | ||
| 808 | - | now := f.cfg.Now() | |||
| 809 | maxTurns := m.MaxTurns | 909 | maxTurns := m.MaxTurns | ||
| 810 | if maxTurns <= 0 { | 910 | if maxTurns <= 0 { | ||
| 811 | maxTurns = f.cfg.Policy.MaxTurns | 911 | maxTurns = f.cfg.Policy.MaxTurns | ||
| @@ -868,6 +968,8 @@ func (f *Supervisor) resume(ctx context.Context, id string, resuming bool) (*Age | |||||
| 868 | EventLogPath: m.EventLogPath, | 968 | EventLogPath: m.EventLogPath, | ||
| 869 | SessionPath: m.SessionPath, | 969 | SessionPath: m.SessionPath, | ||
| 870 | Resuming: resuming, | 970 | Resuming: resuming, | ||
| 971 | + | ResumePrompt: pendingResumePrompt, | |||
| 972 | + | ResumePromptAt: pendingResumePromptAt, | |||
| 871 | inbox: NewInbox(m.InboxPath), | 973 | inbox: NewInbox(m.InboxPath), | ||
| 872 | status: StatusPending, | 974 | status: StatusPending, | ||
| 873 | activity: "resuming", | 975 | activity: "resuming", | ||
M packages/agent/subagents/persist_test.go +211 -0
| @@ -583,6 +583,217 @@ func TestResumeSetsResumingFlag(t *testing.T) { | |||
| 583 | 583 | } | |
| 584 | 584 | } | |
| 585 | 585 | ||
| 586 | + | func TestReloadClearsResumePromptAcknowledgedInEventLog(t *testing.T) { | |
| 587 | + | root := t.TempDir() | |
| 588 | + | stateDir := filepath.Join(root, "agents", "acknowledged-follow-up") | |
| 589 | + | if err := os.MkdirAll(stateDir, 0o700); err != nil { | |
| 590 | + | t.Fatal(err) | |
| 591 | + | } | |
| 592 | + | m := agentMeta{ | |
| 593 | + | ID: "acknowledged-follow-up", | |
| 594 | + | Task: "review the implementation", | |
| 595 | + | Dir: root, | |
| 596 | + | Started: time.Now().Add(-time.Minute), | |
| 597 | + | Status: StatusPending, | |
| 598 | + | InboxPath: filepath.Join(stateDir, "in.sock"), | |
| 599 | + | EventLogPath: filepath.Join(stateDir, "events.jsonl"), | |
| 600 | + | SessionPath: filepath.Join(stateDir, "session.json"), | |
| 601 | + | ResumePrompt: "I applied your review. What do you think now?", | |
| 602 | + | ResumePromptAt: time.Now(), | |
| 603 | + | } | |
| 604 | + | data, err := json.Marshal(m) | |
| 605 | + | if err != nil { | |
| 606 | + | t.Fatal(err) | |
| 607 | + | } | |
| 608 | + | if err := os.WriteFile(metaPath(stateDir), data, 0o600); err != nil { | |
| 609 | + | t.Fatal(err) | |
| 610 | + | } | |
| 611 | + | log, err := OpenEventLog(m.EventLogPath) | |
| 612 | + | if err != nil { | |
| 613 | + | t.Fatal(err) | |
| 614 | + | } | |
| 615 | + | if err := log.Append(NewEvent(EventTurnStarted, map[string]any{"step": float64(1)})); err != nil { | |
| 616 | + | t.Fatal(err) | |
| 617 | + | } | |
| 618 | + | if err := log.Close(); err != nil { | |
| 619 | + | t.Fatal(err) | |
| 620 | + | } | |
| 621 | + | ||
| 622 | + | started := make(chan *Agent, 1) | |
| 623 | + | f := New(Config{ | |
| 624 | + | Root: root, RepoRoot: root, | |
| 625 | + | NewRunner: func(a *Agent) Runner { | |
| 626 | + | return RunnerFunc(func(ctx context.Context, _ Sink) error { | |
| 627 | + | started <- a | |
| 628 | + | <-ctx.Done() | |
| 629 | + | return ctx.Err() | |
| 630 | + | }) | |
| 631 | + | }, | |
| 632 | + | }) | |
| 633 | + | t.Cleanup(f.StopAll) | |
| 634 | + | if loaded, errs := f.Reload(); loaded != 1 || len(errs) != 0 { | |
| 635 | + | t.Fatalf("reload = (%d, %v), want (1, no errors)", loaded, errs) | |
| 636 | + | } | |
| 637 | + | resumed, err := f.ResumeSession(context.Background(), m.ID) | |
| 638 | + | if err != nil { | |
| 639 | + | t.Fatal(err) | |
| 640 | + | } | |
| 641 | + | select { | |
| 642 | + | case got := <-started: | |
| 643 | + | if got != resumed || got.resumePrompt() != "" { | |
| 644 | + | t.Fatalf("acknowledged follow-up was replayed as %q", got.resumePrompt()) | |
| 645 | + | } | |
| 646 | + | case <-time.After(time.Second): | |
| 647 | + | t.Fatal("resumed runner did not start") | |
| 648 | + | } | |
| 649 | + | } | |
| 650 | + | ||
| 651 | + | func TestReloadRetainsQueuedResumePromptUntilTurnStarts(t *testing.T) { | |
| 652 | + | root := t.TempDir() | |
| 653 | + | const ( | |
| 654 | + | id = "queued-follow-up" | |
| 655 | + | original = "review the implementation" | |
| 656 | + | followUp = "I applied your review. What do you think now?" | |
| 657 | + | ) | |
| 658 | + | stateDir := filepath.Join(root, "agents", id) | |
| 659 | + | if err := os.MkdirAll(stateDir, 0o700); err != nil { | |
| 660 | + | t.Fatal(err) | |
| 661 | + | } | |
| 662 | + | m := agentMeta{ | |
| 663 | + | ID: id, | |
| 664 | + | Task: original, | |
| 665 | + | OriginalTask: original, | |
| 666 | + | Dir: root, | |
| 667 | + | Started: time.Now().Add(-time.Minute), | |
| 668 | + | Status: StatusPending, | |
| 669 | + | ProcessState: ProcessPending, | |
| 670 | + | TurnState: TurnQueued, | |
| 671 | + | InboxPath: filepath.Join(stateDir, "in.sock"), | |
| 672 | + | EventLogPath: filepath.Join(stateDir, "events.jsonl"), | |
| 673 | + | SessionPath: filepath.Join(stateDir, "session.json"), | |
| 674 | + | ResumePrompt: followUp, | |
| 675 | + | ResumePromptAt: time.Now(), | |
| 676 | + | } | |
| 677 | + | data, err := json.Marshal(m) | |
| 678 | + | if err != nil { | |
| 679 | + | t.Fatal(err) | |
| 680 | + | } | |
| 681 | + | if err := os.WriteFile(metaPath(stateDir), data, 0o600); err != nil { | |
| 682 | + | t.Fatal(err) | |
| 683 | + | } | |
| 684 | + | if err := os.WriteFile(m.EventLogPath, nil, 0o600); err != nil { | |
| 685 | + | t.Fatal(err) | |
| 686 | + | } | |
| 687 | + | ||
| 688 | + | started := make(chan *Agent, 1) | |
| 689 | + | f := New(Config{ | |
| 690 | + | Root: root, RepoRoot: root, | |
| 691 | + | NewRunner: func(a *Agent) Runner { | |
| 692 | + | return RunnerFunc(func(ctx context.Context, _ Sink) error { | |
| 693 | + | started <- a | |
| 694 | + | <-ctx.Done() | |
| 695 | + | return ctx.Err() | |
| 696 | + | }) | |
| 697 | + | }, | |
| 698 | + | }) | |
| 699 | + | t.Cleanup(f.StopAll) | |
| 700 | + | if loaded, errs := f.Reload(); loaded != 1 || len(errs) != 0 { | |
| 701 | + | t.Fatalf("reload = (%d, %v), want (1, no errors)", loaded, errs) | |
| 702 | + | } | |
| 703 | + | loaded := f.Get(id) | |
| 704 | + | if loaded == nil { | |
| 705 | + | t.Fatal("reloaded agent missing") | |
| 706 | + | } | |
| 707 | + | if got := loaded.resumePrompt(); got != followUp { | |
| 708 | + | t.Fatalf("reloaded pending prompt = %q, want %q", got, followUp) | |
| 709 | + | } | |
| 710 | + | ||
| 711 | + | resumed, err := f.ResumeSession(context.Background(), id) | |
| 712 | + | if err != nil { | |
| 713 | + | t.Fatal(err) | |
| 714 | + | } | |
| 715 | + | select { | |
| 716 | + | case got := <-started: | |
| 717 | + | if got != resumed || got.resumePrompt() != followUp { | |
| 718 | + | t.Fatalf("resumed runner = %p prompt %q, want %p prompt %q", got, got.resumePrompt(), resumed, followUp) | |
| 719 | + | } | |
| 720 | + | case <-time.After(time.Second): | |
| 721 | + | t.Fatal("resumed runner did not start") | |
| 722 | + | } | |
| 723 | + | ||
| 724 | + | updateAgentFromEvent(resumed, NewEvent(EventTurnStarted, map[string]any{"step": float64(1)})) | |
| 725 | + | persisted, err := readAgentMeta(stateDir) | |
| 726 | + | if err != nil { | |
| 727 | + | t.Fatal(err) | |
| 728 | + | } | |
| 729 | + | if resumed.resumePrompt() != "" || persisted.ResumePrompt != "" { | |
| 730 | + | t.Fatalf("pending prompt not cleared after turn start: agent %q meta %q", resumed.resumePrompt(), persisted.ResumePrompt) | |
| 731 | + | } | |
| 732 | + | } | |
| 733 | + | ||
| 734 | + | func TestResumeWithPromptRetainsSessionAndStartsFollowUp(t *testing.T) { | |
| 735 | + | root := t.TempDir() | |
| 736 | + | started := make(chan *Agent, 2) | |
| 737 | + | f := New(Config{ | |
| 738 | + | Root: root, RepoRoot: root, | |
| 739 | + | NewRunner: func(a *Agent) Runner { | |
| 740 | + | return RunnerFunc(func(ctx context.Context, _ Sink) error { | |
| 741 | + | started <- a | |
| 742 | + | <-ctx.Done() | |
| 743 | + | return ctx.Err() | |
| 744 | + | }) | |
| 745 | + | }, | |
| 746 | + | }) | |
| 747 | + | defer f.StopAll() | |
| 748 | + | ||
| 749 | + | first, err := f.Spawn(context.Background(), "review the patch") | |
| 750 | + | if err != nil { | |
| 751 | + | t.Fatal(err) | |
| 752 | + | } | |
| 753 | + | select { | |
| 754 | + | case got := <-started: | |
| 755 | + | if got != first { | |
| 756 | + | t.Fatalf("initial runner agent = %p, want %p", got, first) | |
| 757 | + | } | |
| 758 | + | case <-time.After(time.Second): | |
| 759 | + | t.Fatal("initial runner did not start") | |
| 760 | + | } | |
| 761 | + | if err := f.Stop(first.ID); err != nil { | |
| 762 | + | t.Fatal(err) | |
| 763 | + | } | |
| 764 | + | first.Wait() | |
| 765 | + | ||
| 766 | + | const followUp = "I applied your feedback. Please review it again." | |
| 767 | + | resumed, err := f.ResumeWithPrompt(context.Background(), first.ID, followUp) | |
| 768 | + | if err != nil { | |
| 769 | + | t.Fatalf("resume with prompt: %v", err) | |
| 770 | + | } | |
| 771 | + | if resumed.ID != first.ID || resumed.SessionPath != first.SessionPath { | |
| 772 | + | t.Fatalf("resumed agent = id %q session %q, want id %q session %q", resumed.ID, resumed.SessionPath, first.ID, first.SessionPath) | |
| 773 | + | } | |
| 774 | + | if !resumed.Resuming || resumed.ResumePrompt != followUp || resumed.ResumePromptAt.IsZero() { | |
| 775 | + | t.Fatalf("resumed lifecycle = resuming %t prompt %q accepted %s, want true, prompt %q, and timestamp", resumed.Resuming, resumed.ResumePrompt, resumed.ResumePromptAt, followUp) | |
| 776 | + | } | |
| 777 | + | persisted, err := readAgentMeta(filepath.Join(root, "agents", first.ID)) | |
| 778 | + | if err != nil { | |
| 779 | + | t.Fatal(err) | |
| 780 | + | } | |
| 781 | + | if persisted.ResumePrompt != followUp || persisted.ResumePromptAt.IsZero() { | |
| 782 | + | t.Fatalf("persisted follow-up = prompt %q accepted %s, want prompt %q and timestamp", persisted.ResumePrompt, persisted.ResumePromptAt, followUp) | |
| 783 | + | } | |
| 784 | + | select { | |
| 785 | + | case got := <-started: | |
| 786 | + | if got != resumed { | |
| 787 | + | t.Fatalf("follow-up runner agent = %p, want %p", got, resumed) | |
| 788 | + | } | |
| 789 | + | if got.ResumePrompt != followUp { | |
| 790 | + | t.Fatalf("runner follow-up = %q, want %q", got.ResumePrompt, followUp) | |
| 791 | + | } | |
| 792 | + | case <-time.After(time.Second): | |
| 793 | + | t.Fatal("resumed runner did not start") | |
| 794 | + | } | |
| 795 | + | } | |
| 796 | + | ||
| 586 | 797 | // TestResumeRejectsRunningAgent prevents the user from double-running | |
| 587 | 798 | // an agent: two runners on the same session.json would race. | |
| 588 | 799 | func TestResumeRejectsRunningAgent(t *testing.T) { | |
| @@ -583,6 +583,217 @@ func TestResumeSetsResumingFlag(t *testing.T) { | |||||
| 583 | } | 583 | } | ||
| 584 | } | 584 | } | ||
| 585 | 585 | ||||
| 586 | + | func TestReloadClearsResumePromptAcknowledgedInEventLog(t *testing.T) { | |||
| 587 | + | root := t.TempDir() | |||
| 588 | + | stateDir := filepath.Join(root, "agents", "acknowledged-follow-up") | |||
| 589 | + | if err := os.MkdirAll(stateDir, 0o700); err != nil { | |||
| 590 | + | t.Fatal(err) | |||
| 591 | + | } | |||
| 592 | + | m := agentMeta{ | |||
| 593 | + | ID: "acknowledged-follow-up", | |||
| 594 | + | Task: "review the implementation", | |||
| 595 | + | Dir: root, | |||
| 596 | + | Started: time.Now().Add(-time.Minute), | |||
| 597 | + | Status: StatusPending, | |||
| 598 | + | InboxPath: filepath.Join(stateDir, "in.sock"), | |||
| 599 | + | EventLogPath: filepath.Join(stateDir, "events.jsonl"), | |||
| 600 | + | SessionPath: filepath.Join(stateDir, "session.json"), | |||
| 601 | + | ResumePrompt: "I applied your review. What do you think now?", | |||
| 602 | + | ResumePromptAt: time.Now(), | |||
| 603 | + | } | |||
| 604 | + | data, err := json.Marshal(m) | |||
| 605 | + | if err != nil { | |||
| 606 | + | t.Fatal(err) | |||
| 607 | + | } | |||
| 608 | + | if err := os.WriteFile(metaPath(stateDir), data, 0o600); err != nil { | |||
| 609 | + | t.Fatal(err) | |||
| 610 | + | } | |||
| 611 | + | log, err := OpenEventLog(m.EventLogPath) | |||
| 612 | + | if err != nil { | |||
| 613 | + | t.Fatal(err) | |||
| 614 | + | } | |||
| 615 | + | if err := log.Append(NewEvent(EventTurnStarted, map[string]any{"step": float64(1)})); err != nil { | |||
| 616 | + | t.Fatal(err) | |||
| 617 | + | } | |||
| 618 | + | if err := log.Close(); err != nil { | |||
| 619 | + | t.Fatal(err) | |||
| 620 | + | } | |||
| 621 | + | ||||
| 622 | + | started := make(chan *Agent, 1) | |||
| 623 | + | f := New(Config{ | |||
| 624 | + | Root: root, RepoRoot: root, | |||
| 625 | + | NewRunner: func(a *Agent) Runner { | |||
| 626 | + | return RunnerFunc(func(ctx context.Context, _ Sink) error { | |||
| 627 | + | started <- a | |||
| 628 | + | <-ctx.Done() | |||
| 629 | + | return ctx.Err() | |||
| 630 | + | }) | |||
| 631 | + | }, | |||
| 632 | + | }) | |||
| 633 | + | t.Cleanup(f.StopAll) | |||
| 634 | + | if loaded, errs := f.Reload(); loaded != 1 || len(errs) != 0 { | |||
| 635 | + | t.Fatalf("reload = (%d, %v), want (1, no errors)", loaded, errs) | |||
| 636 | + | } | |||
| 637 | + | resumed, err := f.ResumeSession(context.Background(), m.ID) | |||
| 638 | + | if err != nil { | |||
| 639 | + | t.Fatal(err) | |||
| 640 | + | } | |||
| 641 | + | select { | |||
| 642 | + | case got := <-started: | |||
| 643 | + | if got != resumed || got.resumePrompt() != "" { | |||
| 644 | + | t.Fatalf("acknowledged follow-up was replayed as %q", got.resumePrompt()) | |||
| 645 | + | } | |||
| 646 | + | case <-time.After(time.Second): | |||
| 647 | + | t.Fatal("resumed runner did not start") | |||
| 648 | + | } | |||
| 649 | + | } | |||
| 650 | + | ||||
| 651 | + | func TestReloadRetainsQueuedResumePromptUntilTurnStarts(t *testing.T) { | |||
| 652 | + | root := t.TempDir() | |||
| 653 | + | const ( | |||
| 654 | + | id = "queued-follow-up" | |||
| 655 | + | original = "review the implementation" | |||
| 656 | + | followUp = "I applied your review. What do you think now?" | |||
| 657 | + | ) | |||
| 658 | + | stateDir := filepath.Join(root, "agents", id) | |||
| 659 | + | if err := os.MkdirAll(stateDir, 0o700); err != nil { | |||
| 660 | + | t.Fatal(err) | |||
| 661 | + | } | |||
| 662 | + | m := agentMeta{ | |||
| 663 | + | ID: id, | |||
| 664 | + | Task: original, | |||
| 665 | + | OriginalTask: original, | |||
| 666 | + | Dir: root, | |||
| 667 | + | Started: time.Now().Add(-time.Minute), | |||
| 668 | + | Status: StatusPending, | |||
| 669 | + | ProcessState: ProcessPending, | |||
| 670 | + | TurnState: TurnQueued, | |||
| 671 | + | InboxPath: filepath.Join(stateDir, "in.sock"), | |||
| 672 | + | EventLogPath: filepath.Join(stateDir, "events.jsonl"), | |||
| 673 | + | SessionPath: filepath.Join(stateDir, "session.json"), | |||
| 674 | + | ResumePrompt: followUp, | |||
| 675 | + | ResumePromptAt: time.Now(), | |||
| 676 | + | } | |||
| 677 | + | data, err := json.Marshal(m) | |||
| 678 | + | if err != nil { | |||
| 679 | + | t.Fatal(err) | |||
| 680 | + | } | |||
| 681 | + | if err := os.WriteFile(metaPath(stateDir), data, 0o600); err != nil { | |||
| 682 | + | t.Fatal(err) | |||
| 683 | + | } | |||
| 684 | + | if err := os.WriteFile(m.EventLogPath, nil, 0o600); err != nil { | |||
| 685 | + | t.Fatal(err) | |||
| 686 | + | } | |||
| 687 | + | ||||
| 688 | + | started := make(chan *Agent, 1) | |||
| 689 | + | f := New(Config{ | |||
| 690 | + | Root: root, RepoRoot: root, | |||
| 691 | + | NewRunner: func(a *Agent) Runner { | |||
| 692 | + | return RunnerFunc(func(ctx context.Context, _ Sink) error { | |||
| 693 | + | started <- a | |||
| 694 | + | <-ctx.Done() | |||
| 695 | + | return ctx.Err() | |||
| 696 | + | }) | |||
| 697 | + | }, | |||
| 698 | + | }) | |||
| 699 | + | t.Cleanup(f.StopAll) | |||
| 700 | + | if loaded, errs := f.Reload(); loaded != 1 || len(errs) != 0 { | |||
| 701 | + | t.Fatalf("reload = (%d, %v), want (1, no errors)", loaded, errs) | |||
| 702 | + | } | |||
| 703 | + | loaded := f.Get(id) | |||
| 704 | + | if loaded == nil { | |||
| 705 | + | t.Fatal("reloaded agent missing") | |||
| 706 | + | } | |||
| 707 | + | if got := loaded.resumePrompt(); got != followUp { | |||
| 708 | + | t.Fatalf("reloaded pending prompt = %q, want %q", got, followUp) | |||
| 709 | + | } | |||
| 710 | + | ||||
| 711 | + | resumed, err := f.ResumeSession(context.Background(), id) | |||
| 712 | + | if err != nil { | |||
| 713 | + | t.Fatal(err) | |||
| 714 | + | } | |||
| 715 | + | select { | |||
| 716 | + | case got := <-started: | |||
| 717 | + | if got != resumed || got.resumePrompt() != followUp { | |||
| 718 | + | t.Fatalf("resumed runner = %p prompt %q, want %p prompt %q", got, got.resumePrompt(), resumed, followUp) | |||
| 719 | + | } | |||
| 720 | + | case <-time.After(time.Second): | |||
| 721 | + | t.Fatal("resumed runner did not start") | |||
| 722 | + | } | |||
| 723 | + | ||||
| 724 | + | updateAgentFromEvent(resumed, NewEvent(EventTurnStarted, map[string]any{"step": float64(1)})) | |||
| 725 | + | persisted, err := readAgentMeta(stateDir) | |||
| 726 | + | if err != nil { | |||
| 727 | + | t.Fatal(err) | |||
| 728 | + | } | |||
| 729 | + | if resumed.resumePrompt() != "" || persisted.ResumePrompt != "" { | |||
| 730 | + | t.Fatalf("pending prompt not cleared after turn start: agent %q meta %q", resumed.resumePrompt(), persisted.ResumePrompt) | |||
| 731 | + | } | |||
| 732 | + | } | |||
| 733 | + | ||||
| 734 | + | func TestResumeWithPromptRetainsSessionAndStartsFollowUp(t *testing.T) { | |||
| 735 | + | root := t.TempDir() | |||
| 736 | + | started := make(chan *Agent, 2) | |||
| 737 | + | f := New(Config{ | |||
| 738 | + | Root: root, RepoRoot: root, | |||
| 739 | + | NewRunner: func(a *Agent) Runner { | |||
| 740 | + | return RunnerFunc(func(ctx context.Context, _ Sink) error { | |||
| 741 | + | started <- a | |||
| 742 | + | <-ctx.Done() | |||
| 743 | + | return ctx.Err() | |||
| 744 | + | }) | |||
| 745 | + | }, | |||
| 746 | + | }) | |||
| 747 | + | defer f.StopAll() | |||
| 748 | + | ||||
| 749 | + | first, err := f.Spawn(context.Background(), "review the patch") | |||
| 750 | + | if err != nil { | |||
| 751 | + | t.Fatal(err) | |||
| 752 | + | } | |||
| 753 | + | select { | |||
| 754 | + | case got := <-started: | |||
| 755 | + | if got != first { | |||
| 756 | + | t.Fatalf("initial runner agent = %p, want %p", got, first) | |||
| 757 | + | } | |||
| 758 | + | case <-time.After(time.Second): | |||
| 759 | + | t.Fatal("initial runner did not start") | |||
| 760 | + | } | |||
| 761 | + | if err := f.Stop(first.ID); err != nil { | |||
| 762 | + | t.Fatal(err) | |||
| 763 | + | } | |||
| 764 | + | first.Wait() | |||
| 765 | + | ||||
| 766 | + | const followUp = "I applied your feedback. Please review it again." | |||
| 767 | + | resumed, err := f.ResumeWithPrompt(context.Background(), first.ID, followUp) | |||
| 768 | + | if err != nil { | |||
| 769 | + | t.Fatalf("resume with prompt: %v", err) | |||
| 770 | + | } | |||
| 771 | + | if resumed.ID != first.ID || resumed.SessionPath != first.SessionPath { | |||
| 772 | + | t.Fatalf("resumed agent = id %q session %q, want id %q session %q", resumed.ID, resumed.SessionPath, first.ID, first.SessionPath) | |||
| 773 | + | } | |||
| 774 | + | if !resumed.Resuming || resumed.ResumePrompt != followUp || resumed.ResumePromptAt.IsZero() { | |||
| 775 | + | t.Fatalf("resumed lifecycle = resuming %t prompt %q accepted %s, want true, prompt %q, and timestamp", resumed.Resuming, resumed.ResumePrompt, resumed.ResumePromptAt, followUp) | |||
| 776 | + | } | |||
| 777 | + | persisted, err := readAgentMeta(filepath.Join(root, "agents", first.ID)) | |||
| 778 | + | if err != nil { | |||
| 779 | + | t.Fatal(err) | |||
| 780 | + | } | |||
| 781 | + | if persisted.ResumePrompt != followUp || persisted.ResumePromptAt.IsZero() { | |||
| 782 | + | t.Fatalf("persisted follow-up = prompt %q accepted %s, want prompt %q and timestamp", persisted.ResumePrompt, persisted.ResumePromptAt, followUp) | |||
| 783 | + | } | |||
| 784 | + | select { | |||
| 785 | + | case got := <-started: | |||
| 786 | + | if got != resumed { | |||
| 787 | + | t.Fatalf("follow-up runner agent = %p, want %p", got, resumed) | |||
| 788 | + | } | |||
| 789 | + | if got.ResumePrompt != followUp { | |||
| 790 | + | t.Fatalf("runner follow-up = %q, want %q", got.ResumePrompt, followUp) | |||
| 791 | + | } | |||
| 792 | + | case <-time.After(time.Second): | |||
| 793 | + | t.Fatal("resumed runner did not start") | |||
| 794 | + | } | |||
| 795 | + | } | |||
| 796 | + | ||||
| 586 | // TestResumeRejectsRunningAgent prevents the user from double-running | 797 | // TestResumeRejectsRunningAgent prevents the user from double-running | ||
| 587 | // an agent: two runners on the same session.json would race. | 798 | // an agent: two runners on the same session.json would race. | ||
| 588 | func TestResumeRejectsRunningAgent(t *testing.T) { | 799 | func TestResumeRejectsRunningAgent(t *testing.T) { | ||
A packages/agent/subagents/resume_prompt_test.go +133 -0
| @@ -0,0 +1,133 @@ | |||
| 1 | + | package subagents | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "context" | |
| 5 | + | "path/filepath" | |
| 6 | + | "runtime" | |
| 7 | + | "testing" | |
| 8 | + | "time" | |
| 9 | + | ) | |
| 10 | + | ||
| 11 | + | func TestResumeWithPromptDeliversFollowUpToIdleWorker(t *testing.T) { | |
| 12 | + | if runtime.GOOS == "windows" { | |
| 13 | + | t.Skip("subagent inbox transport uses Unix-domain sockets") | |
| 14 | + | } | |
| 15 | + | ||
| 16 | + | root := shortSocketDir(t) | |
| 17 | + | ready := make(chan struct{}, 1) | |
| 18 | + | commands := make(chan Envelope, 2) | |
| 19 | + | f := New(Config{ | |
| 20 | + | Root: root, RepoRoot: root, | |
| 21 | + | NewRunner: func(a *Agent) Runner { | |
| 22 | + | return RunnerFunc(func(ctx context.Context, _ Sink) error { | |
| 23 | + | listener, err := Listen(a.InboxPath) | |
| 24 | + | if err != nil { | |
| 25 | + | return err | |
| 26 | + | } | |
| 27 | + | defer listener.Close() | |
| 28 | + | a.setProcessState(ProcessAlive) | |
| 29 | + | a.setTurnState(TurnIdle, "") | |
| 30 | + | ready <- struct{}{} | |
| 31 | + | for { | |
| 32 | + | select { | |
| 33 | + | case <-ctx.Done(): | |
| 34 | + | return ctx.Err() | |
| 35 | + | case line, ok := <-listener.Lines(): | |
| 36 | + | if !ok { | |
| 37 | + | return nil | |
| 38 | + | } | |
| 39 | + | command, err := ParseCommand(line) | |
| 40 | + | if err != nil { | |
| 41 | + | return err | |
| 42 | + | } | |
| 43 | + | if command.Type == CommandAgentShutdown { | |
| 44 | + | return nil | |
| 45 | + | } | |
| 46 | + | commands <- command | |
| 47 | + | } | |
| 48 | + | } | |
| 49 | + | }) | |
| 50 | + | }, | |
| 51 | + | }) | |
| 52 | + | defer f.StopAll() | |
| 53 | + | ||
| 54 | + | a, err := f.Spawn(context.Background(), "review the implementation") | |
| 55 | + | if err != nil { | |
| 56 | + | t.Fatal(err) | |
| 57 | + | } | |
| 58 | + | select { | |
| 59 | + | case <-ready: | |
| 60 | + | case <-time.After(time.Second): | |
| 61 | + | t.Fatal("idle worker did not open its inbox") | |
| 62 | + | } | |
| 63 | + | ||
| 64 | + | const followUp = "I applied your review. What do you think now?" | |
| 65 | + | continued, err := f.ResumeWithPrompt(context.Background(), a.ID, followUp) | |
| 66 | + | if err != nil { | |
| 67 | + | t.Fatalf("continue idle worker: %v", err) | |
| 68 | + | } | |
| 69 | + | if continued != a { | |
| 70 | + | t.Fatalf("continued agent = %p, want existing worker %p", continued, a) | |
| 71 | + | } | |
| 72 | + | if got := a.TurnState(); got != TurnQueued { | |
| 73 | + | t.Fatalf("turn state after sending follow-up = %s, want %s", got, TurnQueued) | |
| 74 | + | } | |
| 75 | + | if _, err := f.ResumeWithPrompt(context.Background(), a.ID, "duplicate follow-up"); err == nil { | |
| 76 | + | t.Fatal("second follow-up succeeded while the first was queued") | |
| 77 | + | } | |
| 78 | + | select { | |
| 79 | + | case command := <-commands: | |
| 80 | + | if command.Type != CommandTurnStart { | |
| 81 | + | t.Fatalf("command type = %q, want %q", command.Type, CommandTurnStart) | |
| 82 | + | } | |
| 83 | + | var payload TurnStartPayload | |
| 84 | + | if err := command.DecodePayload(&payload); err != nil { | |
| 85 | + | t.Fatal(err) | |
| 86 | + | } | |
| 87 | + | if payload.Prompt != followUp { | |
| 88 | + | t.Fatalf("follow-up prompt = %q, want %q", payload.Prompt, followUp) | |
| 89 | + | } | |
| 90 | + | case <-time.After(time.Second): | |
| 91 | + | t.Fatal("idle worker did not receive the follow-up") | |
| 92 | + | } | |
| 93 | + | ||
| 94 | + | persisted, err := readAgentMeta(filepath.Join(root, "agents", a.ID)) | |
| 95 | + | if err != nil { | |
| 96 | + | t.Fatal(err) | |
| 97 | + | } | |
| 98 | + | if persisted.ResumePrompt != followUp || persisted.ResumePromptAt.IsZero() { | |
| 99 | + | t.Fatalf("queued follow-up was not persisted: prompt %q accepted %s", persisted.ResumePrompt, persisted.ResumePromptAt) | |
| 100 | + | } | |
| 101 | + | if err := f.Stop(a.ID); err != nil { | |
| 102 | + | t.Fatal(err) | |
| 103 | + | } | |
| 104 | + | a.Wait() | |
| 105 | + | ||
| 106 | + | restarted := make(chan *Agent, 1) | |
| 107 | + | f2 := New(Config{ | |
| 108 | + | Root: root, RepoRoot: root, | |
| 109 | + | NewRunner: func(agent *Agent) Runner { | |
| 110 | + | return RunnerFunc(func(ctx context.Context, _ Sink) error { | |
| 111 | + | restarted <- agent | |
| 112 | + | <-ctx.Done() | |
| 113 | + | return ctx.Err() | |
| 114 | + | }) | |
| 115 | + | }, | |
| 116 | + | }) | |
| 117 | + | defer f2.StopAll() | |
| 118 | + | if loaded, errs := f2.Reload(); loaded != 1 || len(errs) != 0 { | |
| 119 | + | t.Fatalf("reload = (%d, %v), want (1, no errors)", loaded, errs) | |
| 120 | + | } | |
| 121 | + | resumed, err := f2.ResumeSession(context.Background(), a.ID) | |
| 122 | + | if err != nil { | |
| 123 | + | t.Fatal(err) | |
| 124 | + | } | |
| 125 | + | select { | |
| 126 | + | case got := <-restarted: | |
| 127 | + | if got != resumed || got.resumePrompt() != followUp { | |
| 128 | + | t.Fatalf("unacknowledged follow-up was not replayed: agent %p prompt %q", got, got.resumePrompt()) | |
| 129 | + | } | |
| 130 | + | case <-time.After(time.Second): | |
| 131 | + | t.Fatal("restarted worker did not receive the follow-up") | |
| 132 | + | } | |
| 133 | + | } | |
| @@ -0,0 +1,133 @@ | |||||
| 1 | + | package subagents | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "context" | |||
| 5 | + | "path/filepath" | |||
| 6 | + | "runtime" | |||
| 7 | + | "testing" | |||
| 8 | + | "time" | |||
| 9 | + | ) | |||
| 10 | + | ||||
| 11 | + | func TestResumeWithPromptDeliversFollowUpToIdleWorker(t *testing.T) { | |||
| 12 | + | if runtime.GOOS == "windows" { | |||
| 13 | + | t.Skip("subagent inbox transport uses Unix-domain sockets") | |||
| 14 | + | } | |||
| 15 | + | ||||
| 16 | + | root := shortSocketDir(t) | |||
| 17 | + | ready := make(chan struct{}, 1) | |||
| 18 | + | commands := make(chan Envelope, 2) | |||
| 19 | + | f := New(Config{ | |||
| 20 | + | Root: root, RepoRoot: root, | |||
| 21 | + | NewRunner: func(a *Agent) Runner { | |||
| 22 | + | return RunnerFunc(func(ctx context.Context, _ Sink) error { | |||
| 23 | + | listener, err := Listen(a.InboxPath) | |||
| 24 | + | if err != nil { | |||
| 25 | + | return err | |||
| 26 | + | } | |||
| 27 | + | defer listener.Close() | |||
| 28 | + | a.setProcessState(ProcessAlive) | |||
| 29 | + | a.setTurnState(TurnIdle, "") | |||
| 30 | + | ready <- struct{}{} | |||
| 31 | + | for { | |||
| 32 | + | select { | |||
| 33 | + | case <-ctx.Done(): | |||
| 34 | + | return ctx.Err() | |||
| 35 | + | case line, ok := <-listener.Lines(): | |||
| 36 | + | if !ok { | |||
| 37 | + | return nil | |||
| 38 | + | } | |||
| 39 | + | command, err := ParseCommand(line) | |||
| 40 | + | if err != nil { | |||
| 41 | + | return err | |||
| 42 | + | } | |||
| 43 | + | if command.Type == CommandAgentShutdown { | |||
| 44 | + | return nil | |||
| 45 | + | } | |||
| 46 | + | commands <- command | |||
| 47 | + | } | |||
| 48 | + | } | |||
| 49 | + | }) | |||
| 50 | + | }, | |||
| 51 | + | }) | |||
| 52 | + | defer f.StopAll() | |||
| 53 | + | ||||
| 54 | + | a, err := f.Spawn(context.Background(), "review the implementation") | |||
| 55 | + | if err != nil { | |||
| 56 | + | t.Fatal(err) | |||
| 57 | + | } | |||
| 58 | + | select { | |||
| 59 | + | case <-ready: | |||
| 60 | + | case <-time.After(time.Second): | |||
| 61 | + | t.Fatal("idle worker did not open its inbox") | |||
| 62 | + | } | |||
| 63 | + | ||||
| 64 | + | const followUp = "I applied your review. What do you think now?" | |||
| 65 | + | continued, err := f.ResumeWithPrompt(context.Background(), a.ID, followUp) | |||
| 66 | + | if err != nil { | |||
| 67 | + | t.Fatalf("continue idle worker: %v", err) | |||
| 68 | + | } | |||
| 69 | + | if continued != a { | |||
| 70 | + | t.Fatalf("continued agent = %p, want existing worker %p", continued, a) | |||
| 71 | + | } | |||
| 72 | + | if got := a.TurnState(); got != TurnQueued { | |||
| 73 | + | t.Fatalf("turn state after sending follow-up = %s, want %s", got, TurnQueued) | |||
| 74 | + | } | |||
| 75 | + | if _, err := f.ResumeWithPrompt(context.Background(), a.ID, "duplicate follow-up"); err == nil { | |||
| 76 | + | t.Fatal("second follow-up succeeded while the first was queued") | |||
| 77 | + | } | |||
| 78 | + | select { | |||
| 79 | + | case command := <-commands: | |||
| 80 | + | if command.Type != CommandTurnStart { | |||
| 81 | + | t.Fatalf("command type = %q, want %q", command.Type, CommandTurnStart) | |||
| 82 | + | } | |||
| 83 | + | var payload TurnStartPayload | |||
| 84 | + | if err := command.DecodePayload(&payload); err != nil { | |||
| 85 | + | t.Fatal(err) | |||
| 86 | + | } | |||
| 87 | + | if payload.Prompt != followUp { | |||
| 88 | + | t.Fatalf("follow-up prompt = %q, want %q", payload.Prompt, followUp) | |||
| 89 | + | } | |||
| 90 | + | case <-time.After(time.Second): | |||
| 91 | + | t.Fatal("idle worker did not receive the follow-up") | |||
| 92 | + | } | |||
| 93 | + | ||||
| 94 | + | persisted, err := readAgentMeta(filepath.Join(root, "agents", a.ID)) | |||
| 95 | + | if err != nil { | |||
| 96 | + | t.Fatal(err) | |||
| 97 | + | } | |||
| 98 | + | if persisted.ResumePrompt != followUp || persisted.ResumePromptAt.IsZero() { | |||
| 99 | + | t.Fatalf("queued follow-up was not persisted: prompt %q accepted %s", persisted.ResumePrompt, persisted.ResumePromptAt) | |||
| 100 | + | } | |||
| 101 | + | if err := f.Stop(a.ID); err != nil { | |||
| 102 | + | t.Fatal(err) | |||
| 103 | + | } | |||
| 104 | + | a.Wait() | |||
| 105 | + | ||||
| 106 | + | restarted := make(chan *Agent, 1) | |||
| 107 | + | f2 := New(Config{ | |||
| 108 | + | Root: root, RepoRoot: root, | |||
| 109 | + | NewRunner: func(agent *Agent) Runner { | |||
| 110 | + | return RunnerFunc(func(ctx context.Context, _ Sink) error { | |||
| 111 | + | restarted <- agent | |||
| 112 | + | <-ctx.Done() | |||
| 113 | + | return ctx.Err() | |||
| 114 | + | }) | |||
| 115 | + | }, | |||
| 116 | + | }) | |||
| 117 | + | defer f2.StopAll() | |||
| 118 | + | if loaded, errs := f2.Reload(); loaded != 1 || len(errs) != 0 { | |||
| 119 | + | t.Fatalf("reload = (%d, %v), want (1, no errors)", loaded, errs) | |||
| 120 | + | } | |||
| 121 | + | resumed, err := f2.ResumeSession(context.Background(), a.ID) | |||
| 122 | + | if err != nil { | |||
| 123 | + | t.Fatal(err) | |||
| 124 | + | } | |||
| 125 | + | select { | |||
| 126 | + | case got := <-restarted: | |||
| 127 | + | if got != resumed || got.resumePrompt() != followUp { | |||
| 128 | + | t.Fatalf("unacknowledged follow-up was not replayed: agent %p prompt %q", got, got.resumePrompt()) | |||
| 129 | + | } | |||
| 130 | + | case <-time.After(time.Second): | |||
| 131 | + | t.Fatal("restarted worker did not receive the follow-up") | |||
| 132 | + | } | |||
| 133 | + | } | |||
M packages/agent/subagents/runner.go +6 -8
| @@ -84,17 +84,14 @@ type subagentWorkerArgsOpts struct { | |||
| 84 | 84 | // side effects. | |
| 85 | 85 | // | |
| 86 | 86 | // On Spawn (Resuming==false) we pass the task so the child's first | |
| 87 | - | // turn runs immediately. On Resume (Resuming==true) we omit it: the | |
| 88 | - | // child reopens the existing session file, loads the prior | |
| 89 | - | // conversation, and just waits on the inbox for the next prompt. | |
| 90 | - | // Re-firing the task on every resume produces a duplicate turn that | |
| 91 | - | // collides with whatever the user types next, surfacing the | |
| 92 | - | // "agent busy; send 'cancel' first" error between two assistant | |
| 93 | - | // messages — which is exactly the bug this helper fixes. | |
| 87 | + | // turn runs immediately. On Resume (Resuming==true) we omit the original task | |
| 88 | + | // so the child reopens the existing session file without replaying it. A | |
| 89 | + | // caller can instead supply ResumePrompt to begin exactly one new follow-up | |
| 90 | + | // turn without racing the inbox listener startup. | |
| 94 | 91 | func defaultChildArgs(exe string, a *Agent, sessionPath, inboxPath string) []string { | |
| 95 | 92 | task := a.Task | |
| 96 | 93 | if a.Resuming { | |
| 97 | - | task = "" | |
| 94 | + | task = a.resumePrompt() | |
| 98 | 95 | } | |
| 99 | 96 | return subagentWorkerArgs(subagentWorkerArgsOpts{ | |
| 100 | 97 | Exe: exe, | |
| @@ -624,6 +621,7 @@ func updateAgentFromEvent(a *Agent, ev Event) { | |||
| 624 | 621 | } | |
| 625 | 622 | a.setProcessState(ProcessAlive) | |
| 626 | 623 | a.setTurnState(TurnRunning, turnID) | |
| 624 | + | a.clearResumePrompt() | |
| 627 | 625 | persist = true | |
| 628 | 626 | case EventTurnResult, "turn_result": | |
| 629 | 627 | if result, err := decodeTurnResultEvent(ev, a.ID, a.maxOutputBytes, a.maxOutputLines); err == nil { | |
| @@ -84,17 +84,14 @@ type subagentWorkerArgsOpts struct { | |||||
| 84 | // side effects. | 84 | // side effects. | ||
| 85 | // | 85 | // | ||
| 86 | // On Spawn (Resuming==false) we pass the task so the child's first | 86 | // On Spawn (Resuming==false) we pass the task so the child's first | ||
| 87 | - | // turn runs immediately. On Resume (Resuming==true) we omit it: the | 87 | + | // turn runs immediately. On Resume (Resuming==true) we omit the original task |
| 88 | - | // child reopens the existing session file, loads the prior | 88 | + | // so the child reopens the existing session file without replaying it. A |
| 89 | - | // conversation, and just waits on the inbox for the next prompt. | 89 | + | // caller can instead supply ResumePrompt to begin exactly one new follow-up |
| 90 | - | // Re-firing the task on every resume produces a duplicate turn that | 90 | + | // turn without racing the inbox listener startup. |
| 91 | - | // collides with whatever the user types next, surfacing the | |||
| 92 | - | // "agent busy; send 'cancel' first" error between two assistant | |||
| 93 | - | // messages — which is exactly the bug this helper fixes. | |||
| 94 | func defaultChildArgs(exe string, a *Agent, sessionPath, inboxPath string) []string { | 91 | func defaultChildArgs(exe string, a *Agent, sessionPath, inboxPath string) []string { | ||
| 95 | task := a.Task | 92 | task := a.Task | ||
| 96 | if a.Resuming { | 93 | if a.Resuming { | ||
| 97 | - | task = "" | 94 | + | task = a.resumePrompt() |
| 98 | } | 95 | } | ||
| 99 | return subagentWorkerArgs(subagentWorkerArgsOpts{ | 96 | return subagentWorkerArgs(subagentWorkerArgsOpts{ | ||
| 100 | Exe: exe, | 97 | Exe: exe, | ||
| @@ -624,6 +621,7 @@ func updateAgentFromEvent(a *Agent, ev Event) { | |||||
| 624 | } | 621 | } | ||
| 625 | a.setProcessState(ProcessAlive) | 622 | a.setProcessState(ProcessAlive) | ||
| 626 | a.setTurnState(TurnRunning, turnID) | 623 | a.setTurnState(TurnRunning, turnID) | ||
| 624 | + | a.clearResumePrompt() | |||
| 627 | persist = true | 625 | persist = true | ||
| 628 | case EventTurnResult, "turn_result": | 626 | case EventTurnResult, "turn_result": | ||
| 629 | if result, err := decodeTurnResultEvent(ev, a.ID, a.maxOutputBytes, a.maxOutputLines); err == nil { | 627 | if result, err := decodeTurnResultEvent(ev, a.ID, a.maxOutputBytes, a.maxOutputLines); err == nil { | ||
M packages/agent/subagents/runner_test.go +18 -0
| @@ -364,6 +364,24 @@ func TestDefaultChildArgsResumeOmitsTask(t *testing.T) { | |||
| 364 | 364 | } | |
| 365 | 365 | } | |
| 366 | 366 | ||
| 367 | + | func TestDefaultChildArgsResumeUsesFollowUpPrompt(t *testing.T) { | |
| 368 | + | a := &Agent{ | |
| 369 | + | Dir: "/wt", | |
| 370 | + | Task: "review the change", | |
| 371 | + | Resuming: true, | |
| 372 | + | ResumePrompt: "I applied your feedback. Please review it again.", | |
| 373 | + | } | |
| 374 | + | args := defaultChildArgs("/zut", a, "/s.json", "/in.sock") | |
| 375 | + | if got := args[len(args)-1]; got != a.ResumePrompt { | |
| 376 | + | t.Fatalf("resume argv last = %q, want follow-up prompt %q\n%v", got, a.ResumePrompt, args) | |
| 377 | + | } | |
| 378 | + | for _, v := range args[:len(args)-1] { | |
| 379 | + | if v == a.Task { | |
| 380 | + | t.Fatalf("resume argv contains original task; it would duplicate the first turn\n%v", args) | |
| 381 | + | } | |
| 382 | + | } | |
| 383 | + | } | |
| 384 | + | ||
| 367 | 385 | func TestUpdateAgentFromEventIgnoresProviderToolLoopTurnEnd(t *testing.T) { | |
| 368 | 386 | a := &Agent{} | |
| 369 | 387 | a.setTurnState(TurnRunning, "turn-1") | |
| @@ -364,6 +364,24 @@ func TestDefaultChildArgsResumeOmitsTask(t *testing.T) { | |||||
| 364 | } | 364 | } | ||
| 365 | } | 365 | } | ||
| 366 | 366 | ||||
| 367 | + | func TestDefaultChildArgsResumeUsesFollowUpPrompt(t *testing.T) { | |||
| 368 | + | a := &Agent{ | |||
| 369 | + | Dir: "/wt", | |||
| 370 | + | Task: "review the change", | |||
| 371 | + | Resuming: true, | |||
| 372 | + | ResumePrompt: "I applied your feedback. Please review it again.", | |||
| 373 | + | } | |||
| 374 | + | args := defaultChildArgs("/zut", a, "/s.json", "/in.sock") | |||
| 375 | + | if got := args[len(args)-1]; got != a.ResumePrompt { | |||
| 376 | + | t.Fatalf("resume argv last = %q, want follow-up prompt %q\n%v", got, a.ResumePrompt, args) | |||
| 377 | + | } | |||
| 378 | + | for _, v := range args[:len(args)-1] { | |||
| 379 | + | if v == a.Task { | |||
| 380 | + | t.Fatalf("resume argv contains original task; it would duplicate the first turn\n%v", args) | |||
| 381 | + | } | |||
| 382 | + | } | |||
| 383 | + | } | |||
| 384 | + | ||||
| 367 | func TestUpdateAgentFromEventIgnoresProviderToolLoopTurnEnd(t *testing.T) { | 385 | func TestUpdateAgentFromEventIgnoresProviderToolLoopTurnEnd(t *testing.T) { | ||
| 368 | a := &Agent{} | 386 | a := &Agent{} | ||
| 369 | a.setTurnState(TurnRunning, "turn-1") | 387 | a.setTurnState(TurnRunning, "turn-1") | ||
M packages/agent/subagents/supervisor.go +4 -2
| @@ -865,7 +865,7 @@ func (f *Supervisor) stop(ctx context.Context, id string) error { | |||
| 865 | 865 | } | |
| 866 | 866 | graceful := false | |
| 867 | 867 | if a.inbox != nil { | |
| 868 | - | err := a.inbox.SendCommand(NewCommand(CommandAgentShutdown, a.ID, a.CurrentTurnID(), AgentShutdownPayload{})) | |
| 868 | + | err := a.inbox.SendCommandContext(ctx, NewCommand(CommandAgentShutdown, a.ID, a.CurrentTurnID(), AgentShutdownPayload{})) | |
| 869 | 869 | if err == nil { | |
| 870 | 870 | graceful = true | |
| 871 | 871 | } | |
| @@ -918,7 +918,7 @@ func (f *Supervisor) stopDetached(ctx context.Context, a *Agent, path string, in | |||
| 918 | 918 | if err := ctx.Err(); err != nil { | |
| 919 | 919 | return err | |
| 920 | 920 | } | |
| 921 | - | if err := inbox.SendCommand(NewCommand(CommandAgentShutdown, a.ID, a.CurrentTurnID(), AgentShutdownPayload{})); err != nil { | |
| 921 | + | if err := inbox.SendCommandContext(ctx, NewCommand(CommandAgentShutdown, a.ID, a.CurrentTurnID(), AgentShutdownPayload{})); err != nil { | |
| 922 | 922 | if ctxErr := ctx.Err(); ctxErr != nil { | |
| 923 | 923 | return ctxErr | |
| 924 | 924 | } | |
| @@ -966,7 +966,9 @@ func (f *Supervisor) markDetachedKilled(a *Agent) { | |||
| 966 | 966 | a.setProcessState(ProcessKilled) | |
| 967 | 967 | a.setTurnState(TurnCanceled, a.CurrentTurnID()) | |
| 968 | 968 | a.setProcessPID(0) | |
| 969 | + | f.ensureResult(a, StatusKilled, context.Canceled) | |
| 969 | 970 | f.persistAgent(a) | |
| 971 | + | a.closeDone() | |
| 970 | 972 | } | |
| 971 | 973 | ||
| 972 | 974 | // StopAll cancels every running agent. Used on shutdown. | |
| @@ -865,7 +865,7 @@ func (f *Supervisor) stop(ctx context.Context, id string) error { | |||||
| 865 | } | 865 | } | ||
| 866 | graceful := false | 866 | graceful := false | ||
| 867 | if a.inbox != nil { | 867 | if a.inbox != nil { | ||
| 868 | - | err := a.inbox.SendCommand(NewCommand(CommandAgentShutdown, a.ID, a.CurrentTurnID(), AgentShutdownPayload{})) | 868 | + | err := a.inbox.SendCommandContext(ctx, NewCommand(CommandAgentShutdown, a.ID, a.CurrentTurnID(), AgentShutdownPayload{})) |
| 869 | if err == nil { | 869 | if err == nil { | ||
| 870 | graceful = true | 870 | graceful = true | ||
| 871 | } | 871 | } | ||
| @@ -918,7 +918,7 @@ func (f *Supervisor) stopDetached(ctx context.Context, a *Agent, path string, in | |||||
| 918 | if err := ctx.Err(); err != nil { | 918 | if err := ctx.Err(); err != nil { | ||
| 919 | return err | 919 | return err | ||
| 920 | } | 920 | } | ||
| 921 | - | if err := inbox.SendCommand(NewCommand(CommandAgentShutdown, a.ID, a.CurrentTurnID(), AgentShutdownPayload{})); err != nil { | 921 | + | if err := inbox.SendCommandContext(ctx, NewCommand(CommandAgentShutdown, a.ID, a.CurrentTurnID(), AgentShutdownPayload{})); err != nil { |
| 922 | if ctxErr := ctx.Err(); ctxErr != nil { | 922 | if ctxErr := ctx.Err(); ctxErr != nil { | ||
| 923 | return ctxErr | 923 | return ctxErr | ||
| 924 | } | 924 | } | ||
| @@ -966,7 +966,9 @@ func (f *Supervisor) markDetachedKilled(a *Agent) { | |||||
| 966 | a.setProcessState(ProcessKilled) | 966 | a.setProcessState(ProcessKilled) | ||
| 967 | a.setTurnState(TurnCanceled, a.CurrentTurnID()) | 967 | a.setTurnState(TurnCanceled, a.CurrentTurnID()) | ||
| 968 | a.setProcessPID(0) | 968 | a.setProcessPID(0) | ||
| 969 | + | f.ensureResult(a, StatusKilled, context.Canceled) | |||
| 969 | f.persistAgent(a) | 970 | f.persistAgent(a) | ||
| 971 | + | a.closeDone() | |||
| 970 | } | 972 | } | ||
| 971 | 973 | ||||
| 972 | // StopAll cancels every running agent. Used on shutdown. | 974 | // StopAll cancels every running agent. Used on shutdown. | ||
M packages/agent/subagents/supervisor_test.go +121 -0
| @@ -3,6 +3,8 @@ package subagents | |||
| 3 | 3 | import ( | |
| 4 | 4 | "context" | |
| 5 | 5 | "errors" | |
| 6 | + | "net" | |
| 7 | + | "os" | |
| 6 | 8 | "path/filepath" | |
| 7 | 9 | "runtime" | |
| 8 | 10 | "strings" | |
| @@ -286,6 +288,125 @@ func TestStopContextCancelsDetachedWaitWithoutHoldingOperationLock(t *testing.T) | |||
| 286 | 288 | } | |
| 287 | 289 | } | |
| 288 | 290 | ||
| 291 | + | func TestStopDetachedWorkerClosesWaiters(t *testing.T) { | |
| 292 | + | if runtime.GOOS == "windows" { | |
| 293 | + | t.Skip("subagent inbox transport uses Unix-domain sockets") | |
| 294 | + | } | |
| 295 | + | path := filepath.Join(shortSocketDir(t), "agent.sock") | |
| 296 | + | listener, err := Listen(path) | |
| 297 | + | if err != nil { | |
| 298 | + | t.Fatal(err) | |
| 299 | + | } | |
| 300 | + | ||
| 301 | + | root := t.TempDir() | |
| 302 | + | f := New(Config{Root: root, RepoRoot: root}) | |
| 303 | + | stateDir := f.agentStateDir("detached-agent") | |
| 304 | + | if err := os.MkdirAll(stateDir, 0o700); err != nil { | |
| 305 | + | t.Fatal(err) | |
| 306 | + | } | |
| 307 | + | a := &Agent{ | |
| 308 | + | ID: "detached-agent", | |
| 309 | + | stateDir: stateDir, | |
| 310 | + | InboxPath: path, | |
| 311 | + | inbox: NewInbox(path), | |
| 312 | + | status: StatusDetached, | |
| 313 | + | done: make(chan struct{}), | |
| 314 | + | } | |
| 315 | + | defer a.inbox.Close() | |
| 316 | + | f.mu.Lock() | |
| 317 | + | f.agents[a.ID] = a | |
| 318 | + | f.order = append(f.order, a.ID) | |
| 319 | + | f.mu.Unlock() | |
| 320 | + | ||
| 321 | + | stopped := make(chan error, 1) | |
| 322 | + | go func() { stopped <- f.StopContext(context.Background(), a.ID) }() | |
| 323 | + | select { | |
| 324 | + | case msg := <-listener.Lines(): | |
| 325 | + | if msg == "" { | |
| 326 | + | t.Fatal("shutdown command was empty") | |
| 327 | + | } | |
| 328 | + | case <-time.After(time.Second): | |
| 329 | + | t.Fatal("detached worker did not receive shutdown") | |
| 330 | + | } | |
| 331 | + | if err := listener.Close(); err != nil { | |
| 332 | + | t.Fatal(err) | |
| 333 | + | } | |
| 334 | + | select { | |
| 335 | + | case err := <-stopped: | |
| 336 | + | if err != nil { | |
| 337 | + | t.Fatalf("stop detached worker: %v", err) | |
| 338 | + | } | |
| 339 | + | case <-time.After(time.Second): | |
| 340 | + | t.Fatal("detached worker stop did not finish") | |
| 341 | + | } | |
| 342 | + | select { | |
| 343 | + | case <-a.done: | |
| 344 | + | case <-time.After(time.Second): | |
| 345 | + | t.Fatal("detached worker stop did not close waiters") | |
| 346 | + | } | |
| 347 | + | result, err := readTurnResult(stateDir) | |
| 348 | + | if err != nil { | |
| 349 | + | t.Fatal(err) | |
| 350 | + | } | |
| 351 | + | if result.AgentID != a.ID || result.Status != ResultCanceled { | |
| 352 | + | t.Fatalf("detached worker result = agent %q status %q, want agent %q status %q", result.AgentID, result.Status, a.ID, ResultCanceled) | |
| 353 | + | } | |
| 354 | + | } | |
| 355 | + | ||
| 356 | + | func TestStopDetachedWorkerHonorsCanceledShutdownSend(t *testing.T) { | |
| 357 | + | if runtime.GOOS == "windows" { | |
| 358 | + | t.Skip("subagent inbox transport uses Unix-domain sockets") | |
| 359 | + | } | |
| 360 | + | path := filepath.Join(shortSocketDir(t), "agent.sock") | |
| 361 | + | listener, err := Listen(path) | |
| 362 | + | if err != nil { | |
| 363 | + | t.Fatal(err) | |
| 364 | + | } | |
| 365 | + | defer listener.Close() | |
| 366 | + | ||
| 367 | + | local, remote := net.Pipe() | |
| 368 | + | defer remote.Close() | |
| 369 | + | conn := &blockingWriteConn{ | |
| 370 | + | Conn: local, | |
| 371 | + | started: make(chan struct{}), | |
| 372 | + | release: make(chan struct{}), | |
| 373 | + | } | |
| 374 | + | inbox := NewInbox(path) | |
| 375 | + | inbox.conn = conn | |
| 376 | + | defer inbox.Close() | |
| 377 | + | ||
| 378 | + | f := New(Config{Root: t.TempDir()}) | |
| 379 | + | a := &Agent{ | |
| 380 | + | ID: "detached-cancel", | |
| 381 | + | InboxPath: path, | |
| 382 | + | inbox: inbox, | |
| 383 | + | status: StatusDetached, | |
| 384 | + | done: make(chan struct{}), | |
| 385 | + | } | |
| 386 | + | f.mu.Lock() | |
| 387 | + | f.agents[a.ID] = a | |
| 388 | + | f.order = append(f.order, a.ID) | |
| 389 | + | f.mu.Unlock() | |
| 390 | + | ||
| 391 | + | ctx, cancel := context.WithCancel(context.Background()) | |
| 392 | + | stopped := make(chan error, 1) | |
| 393 | + | go func() { stopped <- f.StopContext(ctx, a.ID) }() | |
| 394 | + | select { | |
| 395 | + | case <-conn.started: | |
| 396 | + | case <-time.After(time.Second): | |
| 397 | + | t.Fatal("detached worker shutdown send did not start") | |
| 398 | + | } | |
| 399 | + | cancel() | |
| 400 | + | select { | |
| 401 | + | case err := <-stopped: | |
| 402 | + | if !errors.Is(err, context.Canceled) { | |
| 403 | + | t.Fatalf("stop detached worker error = %v, want context canceled", err) | |
| 404 | + | } | |
| 405 | + | case <-time.After(time.Second): | |
| 406 | + | t.Fatal("detached worker shutdown send did not honor cancellation") | |
| 407 | + | } | |
| 408 | + | } | |
| 409 | + | ||
| 289 | 410 | func TestStopAfterDoneIsNoop(t *testing.T) { | |
| 290 | 411 | f := newTestSupervisor(t, func(a *Agent) Runner { | |
| 291 | 412 | return RunnerFunc(func(ctx context.Context, sink Sink) error { return nil }) | |
| @@ -3,6 +3,8 @@ package subagents | |||||
| 3 | import ( | 3 | import ( | ||
| 4 | "context" | 4 | "context" | ||
| 5 | "errors" | 5 | "errors" | ||
| 6 | + | "net" | |||
| 7 | + | "os" | |||
| 6 | "path/filepath" | 8 | "path/filepath" | ||
| 7 | "runtime" | 9 | "runtime" | ||
| 8 | "strings" | 10 | "strings" | ||
| @@ -286,6 +288,125 @@ func TestStopContextCancelsDetachedWaitWithoutHoldingOperationLock(t *testing.T) | |||||
| 286 | } | 288 | } | ||
| 287 | } | 289 | } | ||
| 288 | 290 | ||||
| 291 | + | func TestStopDetachedWorkerClosesWaiters(t *testing.T) { | |||
| 292 | + | if runtime.GOOS == "windows" { | |||
| 293 | + | t.Skip("subagent inbox transport uses Unix-domain sockets") | |||
| 294 | + | } | |||
| 295 | + | path := filepath.Join(shortSocketDir(t), "agent.sock") | |||
| 296 | + | listener, err := Listen(path) | |||
| 297 | + | if err != nil { | |||
| 298 | + | t.Fatal(err) | |||
| 299 | + | } | |||
| 300 | + | ||||
| 301 | + | root := t.TempDir() | |||
| 302 | + | f := New(Config{Root: root, RepoRoot: root}) | |||
| 303 | + | stateDir := f.agentStateDir("detached-agent") | |||
| 304 | + | if err := os.MkdirAll(stateDir, 0o700); err != nil { | |||
| 305 | + | t.Fatal(err) | |||
| 306 | + | } | |||
| 307 | + | a := &Agent{ | |||
| 308 | + | ID: "detached-agent", | |||
| 309 | + | stateDir: stateDir, | |||
| 310 | + | InboxPath: path, | |||
| 311 | + | inbox: NewInbox(path), | |||
| 312 | + | status: StatusDetached, | |||
| 313 | + | done: make(chan struct{}), | |||
| 314 | + | } | |||
| 315 | + | defer a.inbox.Close() | |||
| 316 | + | f.mu.Lock() | |||
| 317 | + | f.agents[a.ID] = a | |||
| 318 | + | f.order = append(f.order, a.ID) | |||
| 319 | + | f.mu.Unlock() | |||
| 320 | + | ||||
| 321 | + | stopped := make(chan error, 1) | |||
| 322 | + | go func() { stopped <- f.StopContext(context.Background(), a.ID) }() | |||
| 323 | + | select { | |||
| 324 | + | case msg := <-listener.Lines(): | |||
| 325 | + | if msg == "" { | |||
| 326 | + | t.Fatal("shutdown command was empty") | |||
| 327 | + | } | |||
| 328 | + | case <-time.After(time.Second): | |||
| 329 | + | t.Fatal("detached worker did not receive shutdown") | |||
| 330 | + | } | |||
| 331 | + | if err := listener.Close(); err != nil { | |||
| 332 | + | t.Fatal(err) | |||
| 333 | + | } | |||
| 334 | + | select { | |||
| 335 | + | case err := <-stopped: | |||
| 336 | + | if err != nil { | |||
| 337 | + | t.Fatalf("stop detached worker: %v", err) | |||
| 338 | + | } | |||
| 339 | + | case <-time.After(time.Second): | |||
| 340 | + | t.Fatal("detached worker stop did not finish") | |||
| 341 | + | } | |||
| 342 | + | select { | |||
| 343 | + | case <-a.done: | |||
| 344 | + | case <-time.After(time.Second): | |||
| 345 | + | t.Fatal("detached worker stop did not close waiters") | |||
| 346 | + | } | |||
| 347 | + | result, err := readTurnResult(stateDir) | |||
| 348 | + | if err != nil { | |||
| 349 | + | t.Fatal(err) | |||
| 350 | + | } | |||
| 351 | + | if result.AgentID != a.ID || result.Status != ResultCanceled { | |||
| 352 | + | t.Fatalf("detached worker result = agent %q status %q, want agent %q status %q", result.AgentID, result.Status, a.ID, ResultCanceled) | |||
| 353 | + | } | |||
| 354 | + | } | |||
| 355 | + | ||||
| 356 | + | func TestStopDetachedWorkerHonorsCanceledShutdownSend(t *testing.T) { | |||
| 357 | + | if runtime.GOOS == "windows" { | |||
| 358 | + | t.Skip("subagent inbox transport uses Unix-domain sockets") | |||
| 359 | + | } | |||
| 360 | + | path := filepath.Join(shortSocketDir(t), "agent.sock") | |||
| 361 | + | listener, err := Listen(path) | |||
| 362 | + | if err != nil { | |||
| 363 | + | t.Fatal(err) | |||
| 364 | + | } | |||
| 365 | + | defer listener.Close() | |||
| 366 | + | ||||
| 367 | + | local, remote := net.Pipe() | |||
| 368 | + | defer remote.Close() | |||
| 369 | + | conn := &blockingWriteConn{ | |||
| 370 | + | Conn: local, | |||
| 371 | + | started: make(chan struct{}), | |||
| 372 | + | release: make(chan struct{}), | |||
| 373 | + | } | |||
| 374 | + | inbox := NewInbox(path) | |||
| 375 | + | inbox.conn = conn | |||
| 376 | + | defer inbox.Close() | |||
| 377 | + | ||||
| 378 | + | f := New(Config{Root: t.TempDir()}) | |||
| 379 | + | a := &Agent{ | |||
| 380 | + | ID: "detached-cancel", | |||
| 381 | + | InboxPath: path, | |||
| 382 | + | inbox: inbox, | |||
| 383 | + | status: StatusDetached, | |||
| 384 | + | done: make(chan struct{}), | |||
| 385 | + | } | |||
| 386 | + | f.mu.Lock() | |||
| 387 | + | f.agents[a.ID] = a | |||
| 388 | + | f.order = append(f.order, a.ID) | |||
| 389 | + | f.mu.Unlock() | |||
| 390 | + | ||||
| 391 | + | ctx, cancel := context.WithCancel(context.Background()) | |||
| 392 | + | stopped := make(chan error, 1) | |||
| 393 | + | go func() { stopped <- f.StopContext(ctx, a.ID) }() | |||
| 394 | + | select { | |||
| 395 | + | case <-conn.started: | |||
| 396 | + | case <-time.After(time.Second): | |||
| 397 | + | t.Fatal("detached worker shutdown send did not start") | |||
| 398 | + | } | |||
| 399 | + | cancel() | |||
| 400 | + | select { | |||
| 401 | + | case err := <-stopped: | |||
| 402 | + | if !errors.Is(err, context.Canceled) { | |||
| 403 | + | t.Fatalf("stop detached worker error = %v, want context canceled", err) | |||
| 404 | + | } | |||
| 405 | + | case <-time.After(time.Second): | |||
| 406 | + | t.Fatal("detached worker shutdown send did not honor cancellation") | |||
| 407 | + | } | |||
| 408 | + | } | |||
| 409 | + | ||||
| 289 | func TestStopAfterDoneIsNoop(t *testing.T) { | 410 | func TestStopAfterDoneIsNoop(t *testing.T) { | ||
| 290 | f := newTestSupervisor(t, func(a *Agent) Runner { | 411 | f := newTestSupervisor(t, func(a *Agent) Runner { | ||
| 291 | return RunnerFunc(func(ctx context.Context, sink Sink) error { return nil }) | 412 | return RunnerFunc(func(ctx context.Context, sink Sink) error { return nil }) | ||
A packages/agent/tools/subagent_lifecycle_test.go +171 -0
| @@ -0,0 +1,171 @@ | |||
| 1 | + | package tools | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "context" | |
| 5 | + | "encoding/json" | |
| 6 | + | "path/filepath" | |
| 7 | + | "testing" | |
| 8 | + | "time" | |
| 9 | + | ||
| 10 | + | "github.com/bnema/zut/packages/agent/subagents" | |
| 11 | + | ) | |
| 12 | + | ||
| 13 | + | func TestSubagentStopTerminatesStuckWorker(t *testing.T) { | |
| 14 | + | root := t.TempDir() | |
| 15 | + | started := make(chan struct{}, 1) | |
| 16 | + | manager := subagents.New(subagents.Config{ | |
| 17 | + | Root: filepath.Join(root, "subagents"), | |
| 18 | + | RepoRoot: root, | |
| 19 | + | NewRunner: func(*subagents.Agent) subagents.Runner { | |
| 20 | + | return subagents.RunnerFunc(func(ctx context.Context, _ subagents.Sink) error { | |
| 21 | + | started <- struct{}{} | |
| 22 | + | <-ctx.Done() | |
| 23 | + | return ctx.Err() | |
| 24 | + | }) | |
| 25 | + | }, | |
| 26 | + | }) | |
| 27 | + | t.Cleanup(manager.StopAll) | |
| 28 | + | ||
| 29 | + | agent, err := manager.Spawn(context.Background(), "investigate a stuck operation") | |
| 30 | + | if err != nil { | |
| 31 | + | t.Fatal(err) | |
| 32 | + | } | |
| 33 | + | select { | |
| 34 | + | case <-started: | |
| 35 | + | case <-time.After(time.Second): | |
| 36 | + | t.Fatal("subagent did not start") | |
| 37 | + | } | |
| 38 | + | ||
| 39 | + | var tracked *subagents.Agent | |
| 40 | + | tool := &SubagentStopTool{ | |
| 41 | + | Supervisor: manager, | |
| 42 | + | Enabled: func() bool { return true }, | |
| 43 | + | OnStopRequested: func(got *subagents.Agent) { | |
| 44 | + | tracked = got | |
| 45 | + | }, | |
| 46 | + | } | |
| 47 | + | args, err := json.Marshal(subagentStopArgs{AgentID: agent.ID}) | |
| 48 | + | if err != nil { | |
| 49 | + | t.Fatal(err) | |
| 50 | + | } | |
| 51 | + | res, err := tool.Execute(context.Background(), args, nil) | |
| 52 | + | if err != nil { | |
| 53 | + | t.Fatal(err) | |
| 54 | + | } | |
| 55 | + | if res.IsError { | |
| 56 | + | t.Fatalf("unexpected tool error: %s", textResult(res.Content)) | |
| 57 | + | } | |
| 58 | + | if tracked != agent { | |
| 59 | + | t.Fatalf("stop callback agent = %p, want %p", tracked, agent) | |
| 60 | + | } | |
| 61 | + | agent.Wait() | |
| 62 | + | if got := agent.Status(); got != subagents.StatusKilled { | |
| 63 | + | t.Fatalf("agent status = %s, want %s", got, subagents.StatusKilled) | |
| 64 | + | } | |
| 65 | + | ||
| 66 | + | var got struct { | |
| 67 | + | Action string `json:"action"` | |
| 68 | + | Agent struct { | |
| 69 | + | ID string `json:"agent_id"` | |
| 70 | + | State string `json:"state"` | |
| 71 | + | } `json:"agent"` | |
| 72 | + | } | |
| 73 | + | if err := json.Unmarshal([]byte(textResult(res.Content)), &got); err != nil { | |
| 74 | + | t.Fatalf("stop response JSON: %v", err) | |
| 75 | + | } | |
| 76 | + | if got.Action != "stop_requested" || got.Agent.ID != agent.ID || got.Agent.State != "cancelled" { | |
| 77 | + | t.Fatalf("stop response = %+v, want stop_requested cancelled agent %s", got, agent.ID) | |
| 78 | + | } | |
| 79 | + | } | |
| 80 | + | ||
| 81 | + | func TestSubagentResumeRestartsSessionWithFollowUp(t *testing.T) { | |
| 82 | + | root := t.TempDir() | |
| 83 | + | started := make(chan *subagents.Agent, 2) | |
| 84 | + | manager := subagents.New(subagents.Config{ | |
| 85 | + | Root: filepath.Join(root, "subagents"), | |
| 86 | + | RepoRoot: root, | |
| 87 | + | NewRunner: func(agent *subagents.Agent) subagents.Runner { | |
| 88 | + | return subagents.RunnerFunc(func(ctx context.Context, _ subagents.Sink) error { | |
| 89 | + | started <- agent | |
| 90 | + | <-ctx.Done() | |
| 91 | + | return ctx.Err() | |
| 92 | + | }) | |
| 93 | + | }, | |
| 94 | + | }) | |
| 95 | + | t.Cleanup(manager.StopAll) | |
| 96 | + | ||
| 97 | + | first, err := manager.Spawn(context.Background(), "review the implementation") | |
| 98 | + | if err != nil { | |
| 99 | + | t.Fatal(err) | |
| 100 | + | } | |
| 101 | + | select { | |
| 102 | + | case got := <-started: | |
| 103 | + | if got != first { | |
| 104 | + | t.Fatalf("first runner agent = %p, want %p", got, first) | |
| 105 | + | } | |
| 106 | + | case <-time.After(time.Second): | |
| 107 | + | t.Fatal("initial subagent did not start") | |
| 108 | + | } | |
| 109 | + | if err := manager.Stop(first.ID); err != nil { | |
| 110 | + | t.Fatal(err) | |
| 111 | + | } | |
| 112 | + | first.Wait() | |
| 113 | + | ||
| 114 | + | const prompt = "I applied your review. What do you think now?" | |
| 115 | + | var tracked *subagents.Agent | |
| 116 | + | var trackedPrompt string | |
| 117 | + | tool := &SubagentResumeTool{ | |
| 118 | + | Supervisor: manager, | |
| 119 | + | Enabled: func() bool { return true }, | |
| 120 | + | OnResumed: func(agent *subagents.Agent, gotPrompt string) { | |
| 121 | + | tracked = agent | |
| 122 | + | trackedPrompt = gotPrompt | |
| 123 | + | }, | |
| 124 | + | } | |
| 125 | + | args, err := json.Marshal(subagentResumeArgs{AgentID: first.ID, Prompt: prompt}) | |
| 126 | + | if err != nil { | |
| 127 | + | t.Fatal(err) | |
| 128 | + | } | |
| 129 | + | res, err := tool.Execute(context.Background(), args, nil) | |
| 130 | + | if err != nil { | |
| 131 | + | t.Fatal(err) | |
| 132 | + | } | |
| 133 | + | if res.IsError { | |
| 134 | + | t.Fatalf("unexpected tool error: %s", textResult(res.Content)) | |
| 135 | + | } | |
| 136 | + | trackedID := "" | |
| 137 | + | if tracked != nil { | |
| 138 | + | trackedID = tracked.ID | |
| 139 | + | } | |
| 140 | + | if trackedID != first.ID || trackedPrompt != prompt { | |
| 141 | + | t.Fatalf("resume callback = agent %q prompt %q, want agent %s prompt %q", trackedID, trackedPrompt, first.ID, prompt) | |
| 142 | + | } | |
| 143 | + | ||
| 144 | + | select { | |
| 145 | + | case resumed := <-started: | |
| 146 | + | if resumed.ID != first.ID { | |
| 147 | + | t.Fatalf("resumed id = %q, want %q", resumed.ID, first.ID) | |
| 148 | + | } | |
| 149 | + | if resumed.SessionPath != first.SessionPath { | |
| 150 | + | t.Fatalf("resumed session = %q, want %q", resumed.SessionPath, first.SessionPath) | |
| 151 | + | } | |
| 152 | + | if !resumed.Resuming || resumed.ResumePrompt != prompt { | |
| 153 | + | t.Fatalf("resumed lifecycle = resuming %t prompt %q, want true and %q", resumed.Resuming, resumed.ResumePrompt, prompt) | |
| 154 | + | } | |
| 155 | + | case <-time.After(time.Second): | |
| 156 | + | t.Fatal("resumed subagent did not start") | |
| 157 | + | } | |
| 158 | + | ||
| 159 | + | var got struct { | |
| 160 | + | Action string `json:"action"` | |
| 161 | + | Agent struct { | |
| 162 | + | ID string `json:"agent_id"` | |
| 163 | + | } `json:"agent"` | |
| 164 | + | } | |
| 165 | + | if err := json.Unmarshal([]byte(textResult(res.Content)), &got); err != nil { | |
| 166 | + | t.Fatalf("resume response JSON: %v", err) | |
| 167 | + | } | |
| 168 | + | if got.Action != "resumed" || got.Agent.ID != first.ID { | |
| 169 | + | t.Fatalf("resume response = %+v, want resumed agent %s", got, first.ID) | |
| 170 | + | } | |
| 171 | + | } | |
| @@ -0,0 +1,171 @@ | |||||
| 1 | + | package tools | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "context" | |||
| 5 | + | "encoding/json" | |||
| 6 | + | "path/filepath" | |||
| 7 | + | "testing" | |||
| 8 | + | "time" | |||
| 9 | + | ||||
| 10 | + | "github.com/bnema/zut/packages/agent/subagents" | |||
| 11 | + | ) | |||
| 12 | + | ||||
| 13 | + | func TestSubagentStopTerminatesStuckWorker(t *testing.T) { | |||
| 14 | + | root := t.TempDir() | |||
| 15 | + | started := make(chan struct{}, 1) | |||
| 16 | + | manager := subagents.New(subagents.Config{ | |||
| 17 | + | Root: filepath.Join(root, "subagents"), | |||
| 18 | + | RepoRoot: root, | |||
| 19 | + | NewRunner: func(*subagents.Agent) subagents.Runner { | |||
| 20 | + | return subagents.RunnerFunc(func(ctx context.Context, _ subagents.Sink) error { | |||
| 21 | + | started <- struct{}{} | |||
| 22 | + | <-ctx.Done() | |||
| 23 | + | return ctx.Err() | |||
| 24 | + | }) | |||
| 25 | + | }, | |||
| 26 | + | }) | |||
| 27 | + | t.Cleanup(manager.StopAll) | |||
| 28 | + | ||||
| 29 | + | agent, err := manager.Spawn(context.Background(), "investigate a stuck operation") | |||
| 30 | + | if err != nil { | |||
| 31 | + | t.Fatal(err) | |||
| 32 | + | } | |||
| 33 | + | select { | |||
| 34 | + | case <-started: | |||
| 35 | + | case <-time.After(time.Second): | |||
| 36 | + | t.Fatal("subagent did not start") | |||
| 37 | + | } | |||
| 38 | + | ||||
| 39 | + | var tracked *subagents.Agent | |||
| 40 | + | tool := &SubagentStopTool{ | |||
| 41 | + | Supervisor: manager, | |||
| 42 | + | Enabled: func() bool { return true }, | |||
| 43 | + | OnStopRequested: func(got *subagents.Agent) { | |||
| 44 | + | tracked = got | |||
| 45 | + | }, | |||
| 46 | + | } | |||
| 47 | + | args, err := json.Marshal(subagentStopArgs{AgentID: agent.ID}) | |||
| 48 | + | if err != nil { | |||
| 49 | + | t.Fatal(err) | |||
| 50 | + | } | |||
| 51 | + | res, err := tool.Execute(context.Background(), args, nil) | |||
| 52 | + | if err != nil { | |||
| 53 | + | t.Fatal(err) | |||
| 54 | + | } | |||
| 55 | + | if res.IsError { | |||
| 56 | + | t.Fatalf("unexpected tool error: %s", textResult(res.Content)) | |||
| 57 | + | } | |||
| 58 | + | if tracked != agent { | |||
| 59 | + | t.Fatalf("stop callback agent = %p, want %p", tracked, agent) | |||
| 60 | + | } | |||
| 61 | + | agent.Wait() | |||
| 62 | + | if got := agent.Status(); got != subagents.StatusKilled { | |||
| 63 | + | t.Fatalf("agent status = %s, want %s", got, subagents.StatusKilled) | |||
| 64 | + | } | |||
| 65 | + | ||||
| 66 | + | var got struct { | |||
| 67 | + | Action string `json:"action"` | |||
| 68 | + | Agent struct { | |||
| 69 | + | ID string `json:"agent_id"` | |||
| 70 | + | State string `json:"state"` | |||
| 71 | + | } `json:"agent"` | |||
| 72 | + | } | |||
| 73 | + | if err := json.Unmarshal([]byte(textResult(res.Content)), &got); err != nil { | |||
| 74 | + | t.Fatalf("stop response JSON: %v", err) | |||
| 75 | + | } | |||
| 76 | + | if got.Action != "stop_requested" || got.Agent.ID != agent.ID || got.Agent.State != "cancelled" { | |||
| 77 | + | t.Fatalf("stop response = %+v, want stop_requested cancelled agent %s", got, agent.ID) | |||
| 78 | + | } | |||
| 79 | + | } | |||
| 80 | + | ||||
| 81 | + | func TestSubagentResumeRestartsSessionWithFollowUp(t *testing.T) { | |||
| 82 | + | root := t.TempDir() | |||
| 83 | + | started := make(chan *subagents.Agent, 2) | |||
| 84 | + | manager := subagents.New(subagents.Config{ | |||
| 85 | + | Root: filepath.Join(root, "subagents"), | |||
| 86 | + | RepoRoot: root, | |||
| 87 | + | NewRunner: func(agent *subagents.Agent) subagents.Runner { | |||
| 88 | + | return subagents.RunnerFunc(func(ctx context.Context, _ subagents.Sink) error { | |||
| 89 | + | started <- agent | |||
| 90 | + | <-ctx.Done() | |||
| 91 | + | return ctx.Err() | |||
| 92 | + | }) | |||
| 93 | + | }, | |||
| 94 | + | }) | |||
| 95 | + | t.Cleanup(manager.StopAll) | |||
| 96 | + | ||||
| 97 | + | first, err := manager.Spawn(context.Background(), "review the implementation") | |||
| 98 | + | if err != nil { | |||
| 99 | + | t.Fatal(err) | |||
| 100 | + | } | |||
| 101 | + | select { | |||
| 102 | + | case got := <-started: | |||
| 103 | + | if got != first { | |||
| 104 | + | t.Fatalf("first runner agent = %p, want %p", got, first) | |||
| 105 | + | } | |||
| 106 | + | case <-time.After(time.Second): | |||
| 107 | + | t.Fatal("initial subagent did not start") | |||
| 108 | + | } | |||
| 109 | + | if err := manager.Stop(first.ID); err != nil { | |||
| 110 | + | t.Fatal(err) | |||
| 111 | + | } | |||
| 112 | + | first.Wait() | |||
| 113 | + | ||||
| 114 | + | const prompt = "I applied your review. What do you think now?" | |||
| 115 | + | var tracked *subagents.Agent | |||
| 116 | + | var trackedPrompt string | |||
| 117 | + | tool := &SubagentResumeTool{ | |||
| 118 | + | Supervisor: manager, | |||
| 119 | + | Enabled: func() bool { return true }, | |||
| 120 | + | OnResumed: func(agent *subagents.Agent, gotPrompt string) { | |||
| 121 | + | tracked = agent | |||
| 122 | + | trackedPrompt = gotPrompt | |||
| 123 | + | }, | |||
| 124 | + | } | |||
| 125 | + | args, err := json.Marshal(subagentResumeArgs{AgentID: first.ID, Prompt: prompt}) | |||
| 126 | + | if err != nil { | |||
| 127 | + | t.Fatal(err) | |||
| 128 | + | } | |||
| 129 | + | res, err := tool.Execute(context.Background(), args, nil) | |||
| 130 | + | if err != nil { | |||
| 131 | + | t.Fatal(err) | |||
| 132 | + | } | |||
| 133 | + | if res.IsError { | |||
| 134 | + | t.Fatalf("unexpected tool error: %s", textResult(res.Content)) | |||
| 135 | + | } | |||
| 136 | + | trackedID := "" | |||
| 137 | + | if tracked != nil { | |||
| 138 | + | trackedID = tracked.ID | |||
| 139 | + | } | |||
| 140 | + | if trackedID != first.ID || trackedPrompt != prompt { | |||
| 141 | + | t.Fatalf("resume callback = agent %q prompt %q, want agent %s prompt %q", trackedID, trackedPrompt, first.ID, prompt) | |||
| 142 | + | } | |||
| 143 | + | ||||
| 144 | + | select { | |||
| 145 | + | case resumed := <-started: | |||
| 146 | + | if resumed.ID != first.ID { | |||
| 147 | + | t.Fatalf("resumed id = %q, want %q", resumed.ID, first.ID) | |||
| 148 | + | } | |||
| 149 | + | if resumed.SessionPath != first.SessionPath { | |||
| 150 | + | t.Fatalf("resumed session = %q, want %q", resumed.SessionPath, first.SessionPath) | |||
| 151 | + | } | |||
| 152 | + | if !resumed.Resuming || resumed.ResumePrompt != prompt { | |||
| 153 | + | t.Fatalf("resumed lifecycle = resuming %t prompt %q, want true and %q", resumed.Resuming, resumed.ResumePrompt, prompt) | |||
| 154 | + | } | |||
| 155 | + | case <-time.After(time.Second): | |||
| 156 | + | t.Fatal("resumed subagent did not start") | |||
| 157 | + | } | |||
| 158 | + | ||||
| 159 | + | var got struct { | |||
| 160 | + | Action string `json:"action"` | |||
| 161 | + | Agent struct { | |||
| 162 | + | ID string `json:"agent_id"` | |||
| 163 | + | } `json:"agent"` | |||
| 164 | + | } | |||
| 165 | + | if err := json.Unmarshal([]byte(textResult(res.Content)), &got); err != nil { | |||
| 166 | + | t.Fatalf("resume response JSON: %v", err) | |||
| 167 | + | } | |||
| 168 | + | if got.Action != "resumed" || got.Agent.ID != first.ID { | |||
| 169 | + | t.Fatalf("resume response = %+v, want resumed agent %s", got, first.ID) | |||
| 170 | + | } | |||
| 171 | + | } | |||
A packages/agent/tools/subagent_resume.go +102 -0
| @@ -0,0 +1,102 @@ | |||
| 1 | + | package tools | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "context" | |
| 5 | + | "encoding/json" | |
| 6 | + | "fmt" | |
| 7 | + | "strings" | |
| 8 | + | ||
| 9 | + | "github.com/bnema/zut/packages/agent/subagents" | |
| 10 | + | "github.com/bnema/zut/packages/core" | |
| 11 | + | ) | |
| 12 | + | ||
| 13 | + | // SubagentResumeTool gives a completed or stopped sub-agent a follow-up turn | |
| 14 | + | // while preserving its existing session context. For an idle live worker, the | |
| 15 | + | // follow-up is delivered directly. For a terminal worker, the supervisor | |
| 16 | + | // restarts the retained session with the follow-up as its initial turn. | |
| 17 | + | type SubagentResumeTool struct { | |
| 18 | + | Supervisor *subagents.Supervisor | |
| 19 | + | Enabled func() bool | |
| 20 | + | ||
| 21 | + | // OnResumed receives the worker and follow-up after a successful resume. | |
| 22 | + | // It runs synchronously, so callbacks must only register non-blocking | |
| 23 | + | // tracking work. The interactive host uses it to watch this new delegated | |
| 24 | + | // turn and deliver its outcome back to the manager automatically. | |
| 25 | + | OnResumed func(agent *subagents.Agent, prompt string) | |
| 26 | + | } | |
| 27 | + | ||
| 28 | + | type subagentResumeArgs struct { | |
| 29 | + | AgentID string `json:"agent_id"` | |
| 30 | + | Prompt string `json:"prompt"` | |
| 31 | + | } | |
| 32 | + | ||
| 33 | + | const subagentResumeSchema = `{ | |
| 34 | + | "type": "object", | |
| 35 | + | "properties": { | |
| 36 | + | "agent_id": { | |
| 37 | + | "type": "string", | |
| 38 | + | "description": "Worker id or unique id prefix for the sub-agent to continue." | |
| 39 | + | }, | |
| 40 | + | "prompt": { | |
| 41 | + | "type": "string", | |
| 42 | + | "description": "New manager follow-up for the sub-agent. Its earlier task and conversation remain available in the retained session." | |
| 43 | + | } | |
| 44 | + | }, | |
| 45 | + | "required": ["agent_id", "prompt"] | |
| 46 | + | }` | |
| 47 | + | ||
| 48 | + | func (t *SubagentResumeTool) Name() string { return "subagent_resume" } | |
| 49 | + | ||
| 50 | + | func (t *SubagentResumeTool) Description() string { | |
| 51 | + | return "Send a follow-up to an idle sub-agent, or restart a stopped one with its prior session context preserved." | |
| 52 | + | } | |
| 53 | + | ||
| 54 | + | func (t *SubagentResumeTool) Schema() json.RawMessage { | |
| 55 | + | return json.RawMessage(subagentResumeSchema) | |
| 56 | + | } | |
| 57 | + | ||
| 58 | + | func (t *SubagentResumeTool) Execute(ctx context.Context, raw json.RawMessage, _ func(string)) (core.ToolResult, error) { | |
| 59 | + | if ctx != nil { | |
| 60 | + | select { | |
| 61 | + | case <-ctx.Done(): | |
| 62 | + | return core.ToolResult{}, ctx.Err() | |
| 63 | + | default: | |
| 64 | + | } | |
| 65 | + | } | |
| 66 | + | prefix := t.Name() | |
| 67 | + | if t.Supervisor == nil { | |
| 68 | + | return protocolToolError(prefix + ": subagent supervisor not available in this mode") | |
| 69 | + | } | |
| 70 | + | if t.Enabled == nil || !t.Enabled() { | |
| 71 | + | return protocolToolError(prefix + ": auto-subagents is disabled. Ask the user to enable it from /settings before managing workers.") | |
| 72 | + | } | |
| 73 | + | ||
| 74 | + | var args subagentResumeArgs | |
| 75 | + | if err := json.Unmarshal(raw, &args); err != nil { | |
| 76 | + | return core.ToolResult{}, fmt.Errorf("invalid args: %w", err) | |
| 77 | + | } | |
| 78 | + | id := strings.TrimSpace(args.AgentID) | |
| 79 | + | if id == "" { | |
| 80 | + | return protocolToolError(prefix + ": agent_id is required") | |
| 81 | + | } | |
| 82 | + | if strings.TrimSpace(args.Prompt) == "" { | |
| 83 | + | return protocolToolError(prefix + ": prompt is required") | |
| 84 | + | } | |
| 85 | + | snapshot, ok := findSubagentStatusSnapshot(t.Supervisor.SnapshotAll(), id) | |
| 86 | + | if !ok { | |
| 87 | + | return protocolToolError(fmt.Sprintf("%s: no such agent %q", prefix, id)) | |
| 88 | + | } | |
| 89 | + | resumed, err := t.Supervisor.ResumeWithPrompt(ctx, snapshot.ID, args.Prompt) | |
| 90 | + | if err != nil { | |
| 91 | + | return protocolToolError(prefix + ": " + err.Error()) | |
| 92 | + | } | |
| 93 | + | if t.OnResumed != nil { | |
| 94 | + | t.OnResumed(resumed, args.Prompt) | |
| 95 | + | } | |
| 96 | + | ||
| 97 | + | snapshot, ok = findSubagentStatusSnapshot(t.Supervisor.SnapshotAll(), snapshot.ID) | |
| 98 | + | if !ok { | |
| 99 | + | return core.ToolResult{}, fmt.Errorf("%s: resumed agent disappeared from supervisor", prefix) | |
| 100 | + | } | |
| 101 | + | return renderSubagentAction("resumed", snapshot) | |
| 102 | + | } | |
| @@ -0,0 +1,102 @@ | |||||
| 1 | + | package tools | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "context" | |||
| 5 | + | "encoding/json" | |||
| 6 | + | "fmt" | |||
| 7 | + | "strings" | |||
| 8 | + | ||||
| 9 | + | "github.com/bnema/zut/packages/agent/subagents" | |||
| 10 | + | "github.com/bnema/zut/packages/core" | |||
| 11 | + | ) | |||
| 12 | + | ||||
| 13 | + | // SubagentResumeTool gives a completed or stopped sub-agent a follow-up turn | |||
| 14 | + | // while preserving its existing session context. For an idle live worker, the | |||
| 15 | + | // follow-up is delivered directly. For a terminal worker, the supervisor | |||
| 16 | + | // restarts the retained session with the follow-up as its initial turn. | |||
| 17 | + | type SubagentResumeTool struct { | |||
| 18 | + | Supervisor *subagents.Supervisor | |||
| 19 | + | Enabled func() bool | |||
| 20 | + | ||||
| 21 | + | // OnResumed receives the worker and follow-up after a successful resume. | |||
| 22 | + | // It runs synchronously, so callbacks must only register non-blocking | |||
| 23 | + | // tracking work. The interactive host uses it to watch this new delegated | |||
| 24 | + | // turn and deliver its outcome back to the manager automatically. | |||
| 25 | + | OnResumed func(agent *subagents.Agent, prompt string) | |||
| 26 | + | } | |||
| 27 | + | ||||
| 28 | + | type subagentResumeArgs struct { | |||
| 29 | + | AgentID string `json:"agent_id"` | |||
| 30 | + | Prompt string `json:"prompt"` | |||
| 31 | + | } | |||
| 32 | + | ||||
| 33 | + | const subagentResumeSchema = `{ | |||
| 34 | + | "type": "object", | |||
| 35 | + | "properties": { | |||
| 36 | + | "agent_id": { | |||
| 37 | + | "type": "string", | |||
| 38 | + | "description": "Worker id or unique id prefix for the sub-agent to continue." | |||
| 39 | + | }, | |||
| 40 | + | "prompt": { | |||
| 41 | + | "type": "string", | |||
| 42 | + | "description": "New manager follow-up for the sub-agent. Its earlier task and conversation remain available in the retained session." | |||
| 43 | + | } | |||
| 44 | + | }, | |||
| 45 | + | "required": ["agent_id", "prompt"] | |||
| 46 | + | }` | |||
| 47 | + | ||||
| 48 | + | func (t *SubagentResumeTool) Name() string { return "subagent_resume" } | |||
| 49 | + | ||||
| 50 | + | func (t *SubagentResumeTool) Description() string { | |||
| 51 | + | return "Send a follow-up to an idle sub-agent, or restart a stopped one with its prior session context preserved." | |||
| 52 | + | } | |||
| 53 | + | ||||
| 54 | + | func (t *SubagentResumeTool) Schema() json.RawMessage { | |||
| 55 | + | return json.RawMessage(subagentResumeSchema) | |||
| 56 | + | } | |||
| 57 | + | ||||
| 58 | + | func (t *SubagentResumeTool) Execute(ctx context.Context, raw json.RawMessage, _ func(string)) (core.ToolResult, error) { | |||
| 59 | + | if ctx != nil { | |||
| 60 | + | select { | |||
| 61 | + | case <-ctx.Done(): | |||
| 62 | + | return core.ToolResult{}, ctx.Err() | |||
| 63 | + | default: | |||
| 64 | + | } | |||
| 65 | + | } | |||
| 66 | + | prefix := t.Name() | |||
| 67 | + | if t.Supervisor == nil { | |||
| 68 | + | return protocolToolError(prefix + ": subagent supervisor not available in this mode") | |||
| 69 | + | } | |||
| 70 | + | if t.Enabled == nil || !t.Enabled() { | |||
| 71 | + | return protocolToolError(prefix + ": auto-subagents is disabled. Ask the user to enable it from /settings before managing workers.") | |||
| 72 | + | } | |||
| 73 | + | ||||
| 74 | + | var args subagentResumeArgs | |||
| 75 | + | if err := json.Unmarshal(raw, &args); err != nil { | |||
| 76 | + | return core.ToolResult{}, fmt.Errorf("invalid args: %w", err) | |||
| 77 | + | } | |||
| 78 | + | id := strings.TrimSpace(args.AgentID) | |||
| 79 | + | if id == "" { | |||
| 80 | + | return protocolToolError(prefix + ": agent_id is required") | |||
| 81 | + | } | |||
| 82 | + | if strings.TrimSpace(args.Prompt) == "" { | |||
| 83 | + | return protocolToolError(prefix + ": prompt is required") | |||
| 84 | + | } | |||
| 85 | + | snapshot, ok := findSubagentStatusSnapshot(t.Supervisor.SnapshotAll(), id) | |||
| 86 | + | if !ok { | |||
| 87 | + | return protocolToolError(fmt.Sprintf("%s: no such agent %q", prefix, id)) | |||
| 88 | + | } | |||
| 89 | + | resumed, err := t.Supervisor.ResumeWithPrompt(ctx, snapshot.ID, args.Prompt) | |||
| 90 | + | if err != nil { | |||
| 91 | + | return protocolToolError(prefix + ": " + err.Error()) | |||
| 92 | + | } | |||
| 93 | + | if t.OnResumed != nil { | |||
| 94 | + | t.OnResumed(resumed, args.Prompt) | |||
| 95 | + | } | |||
| 96 | + | ||||
| 97 | + | snapshot, ok = findSubagentStatusSnapshot(t.Supervisor.SnapshotAll(), snapshot.ID) | |||
| 98 | + | if !ok { | |||
| 99 | + | return core.ToolResult{}, fmt.Errorf("%s: resumed agent disappeared from supervisor", prefix) | |||
| 100 | + | } | |||
| 101 | + | return renderSubagentAction("resumed", snapshot) | |||
| 102 | + | } | |||
A packages/agent/tools/subagent_stop.go +123 -0
| @@ -0,0 +1,123 @@ | |||
| 1 | + | package tools | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "context" | |
| 5 | + | "encoding/json" | |
| 6 | + | "fmt" | |
| 7 | + | "strings" | |
| 8 | + | ||
| 9 | + | "github.com/bnema/zut/packages/agent/subagents" | |
| 10 | + | "github.com/bnema/zut/packages/core" | |
| 11 | + | "github.com/bnema/zut/packages/provider" | |
| 12 | + | ) | |
| 13 | + | ||
| 14 | + | // SubagentStopTool lets the manager request termination of a stuck background | |
| 15 | + | // sub-agent. It uses the supervisor's graceful shutdown and force-stop | |
| 16 | + | // lifecycle rather than directly manipulating the worker process. | |
| 17 | + | type SubagentStopTool struct { | |
| 18 | + | Supervisor *subagents.Supervisor | |
| 19 | + | Enabled func() bool | |
| 20 | + | ||
| 21 | + | // OnStopRequested registers non-blocking completion tracking before the | |
| 22 | + | // shutdown request is sent. The interactive host uses it to deliver the | |
| 23 | + | // terminal result even for workers restored by Reload. | |
| 24 | + | OnStopRequested func(agent *subagents.Agent) | |
| 25 | + | } | |
| 26 | + | ||
| 27 | + | type subagentStopArgs struct { | |
| 28 | + | AgentID string `json:"agent_id"` | |
| 29 | + | } | |
| 30 | + | ||
| 31 | + | type subagentActionResponse struct { | |
| 32 | + | Action string `json:"action"` | |
| 33 | + | Agent subagentStatusEntry `json:"agent"` | |
| 34 | + | } | |
| 35 | + | ||
| 36 | + | const subagentStopSchema = `{ | |
| 37 | + | "type": "object", | |
| 38 | + | "properties": { | |
| 39 | + | "agent_id": { | |
| 40 | + | "type": "string", | |
| 41 | + | "description": "Worker id or unique id prefix for the stuck background sub-agent to terminate." | |
| 42 | + | } | |
| 43 | + | }, | |
| 44 | + | "required": ["agent_id"] | |
| 45 | + | }` | |
| 46 | + | ||
| 47 | + | func (t *SubagentStopTool) Name() string { return "subagent_stop" } | |
| 48 | + | ||
| 49 | + | func (t *SubagentStopTool) Description() string { | |
| 50 | + | return "Request termination of a stuck background sub-agent. It gracefully requests shutdown, then cancels or force-stops the worker when necessary." | |
| 51 | + | } | |
| 52 | + | ||
| 53 | + | func (t *SubagentStopTool) Schema() json.RawMessage { | |
| 54 | + | return json.RawMessage(subagentStopSchema) | |
| 55 | + | } | |
| 56 | + | ||
| 57 | + | func (t *SubagentStopTool) Execute(ctx context.Context, raw json.RawMessage, _ func(string)) (core.ToolResult, error) { | |
| 58 | + | if ctx != nil { | |
| 59 | + | select { | |
| 60 | + | case <-ctx.Done(): | |
| 61 | + | return core.ToolResult{}, ctx.Err() | |
| 62 | + | default: | |
| 63 | + | } | |
| 64 | + | } | |
| 65 | + | prefix := t.Name() | |
| 66 | + | if t.Supervisor == nil { | |
| 67 | + | return protocolToolError(prefix + ": subagent supervisor not available in this mode") | |
| 68 | + | } | |
| 69 | + | if t.Enabled == nil || !t.Enabled() { | |
| 70 | + | return protocolToolError(prefix + ": auto-subagents is disabled. Ask the user to enable it from /settings before managing workers.") | |
| 71 | + | } | |
| 72 | + | ||
| 73 | + | var args subagentStopArgs | |
| 74 | + | if err := json.Unmarshal(raw, &args); err != nil { | |
| 75 | + | return core.ToolResult{}, fmt.Errorf("invalid args: %w", err) | |
| 76 | + | } | |
| 77 | + | id := strings.TrimSpace(args.AgentID) | |
| 78 | + | if id == "" { | |
| 79 | + | return protocolToolError(prefix + ": agent_id is required") | |
| 80 | + | } | |
| 81 | + | snapshot, ok := findSubagentStatusSnapshot(t.Supervisor.SnapshotAll(), id) | |
| 82 | + | if !ok { | |
| 83 | + | return protocolToolError(fmt.Sprintf("%s: no such agent %q", prefix, id)) | |
| 84 | + | } | |
| 85 | + | if !subagentCanStop(snapshot) { | |
| 86 | + | return protocolToolError(fmt.Sprintf("%s: agent %s has no live worker to stop", prefix, snapshot.ID)) | |
| 87 | + | } | |
| 88 | + | if t.OnStopRequested != nil { | |
| 89 | + | if agent := t.Supervisor.Get(snapshot.ID); agent != nil { | |
| 90 | + | t.OnStopRequested(agent) | |
| 91 | + | } | |
| 92 | + | } | |
| 93 | + | if err := t.Supervisor.StopContext(ctx, snapshot.ID); err != nil { | |
| 94 | + | return core.ToolResult{}, fmt.Errorf("%s: %w", prefix, err) | |
| 95 | + | } | |
| 96 | + | ||
| 97 | + | snapshot, ok = findSubagentStatusSnapshot(t.Supervisor.SnapshotAll(), snapshot.ID) | |
| 98 | + | if !ok { | |
| 99 | + | return core.ToolResult{}, fmt.Errorf("%s: terminated agent disappeared from supervisor", prefix) | |
| 100 | + | } | |
| 101 | + | return renderSubagentAction("stop_requested", snapshot) | |
| 102 | + | } | |
| 103 | + | ||
| 104 | + | func subagentCanStop(snapshot subagents.AgentSnapshot) bool { | |
| 105 | + | return snapshot.Status == subagents.StatusPending || | |
| 106 | + | snapshot.Status == subagents.StatusRunning || | |
| 107 | + | (snapshot.Status == subagents.StatusDetached && snapshot.ProcessState == subagents.ProcessAlive) | |
| 108 | + | } | |
| 109 | + | ||
| 110 | + | func renderSubagentAction(action string, snapshot subagents.AgentSnapshot) (core.ToolResult, error) { | |
| 111 | + | response := subagentActionResponse{ | |
| 112 | + | Action: action, | |
| 113 | + | Agent: publicSubagentStatus(snapshot), | |
| 114 | + | } | |
| 115 | + | data, err := json.Marshal(response) | |
| 116 | + | if err != nil { | |
| 117 | + | return core.ToolResult{}, fmt.Errorf("subagent action: encode response: %w", err) | |
| 118 | + | } | |
| 119 | + | return core.ToolResult{ | |
| 120 | + | Content: []provider.Content{provider.TextBlock{Text: string(data)}}, | |
| 121 | + | Details: response, | |
| 122 | + | }, nil | |
| 123 | + | } | |
| @@ -0,0 +1,123 @@ | |||||
| 1 | + | package tools | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "context" | |||
| 5 | + | "encoding/json" | |||
| 6 | + | "fmt" | |||
| 7 | + | "strings" | |||
| 8 | + | ||||
| 9 | + | "github.com/bnema/zut/packages/agent/subagents" | |||
| 10 | + | "github.com/bnema/zut/packages/core" | |||
| 11 | + | "github.com/bnema/zut/packages/provider" | |||
| 12 | + | ) | |||
| 13 | + | ||||
| 14 | + | // SubagentStopTool lets the manager request termination of a stuck background | |||
| 15 | + | // sub-agent. It uses the supervisor's graceful shutdown and force-stop | |||
| 16 | + | // lifecycle rather than directly manipulating the worker process. | |||
| 17 | + | type SubagentStopTool struct { | |||
| 18 | + | Supervisor *subagents.Supervisor | |||
| 19 | + | Enabled func() bool | |||
| 20 | + | ||||
| 21 | + | // OnStopRequested registers non-blocking completion tracking before the | |||
| 22 | + | // shutdown request is sent. The interactive host uses it to deliver the | |||
| 23 | + | // terminal result even for workers restored by Reload. | |||
| 24 | + | OnStopRequested func(agent *subagents.Agent) | |||
| 25 | + | } | |||
| 26 | + | ||||
| 27 | + | type subagentStopArgs struct { | |||
| 28 | + | AgentID string `json:"agent_id"` | |||
| 29 | + | } | |||
| 30 | + | ||||
| 31 | + | type subagentActionResponse struct { | |||
| 32 | + | Action string `json:"action"` | |||
| 33 | + | Agent subagentStatusEntry `json:"agent"` | |||
| 34 | + | } | |||
| 35 | + | ||||
| 36 | + | const subagentStopSchema = `{ | |||
| 37 | + | "type": "object", | |||
| 38 | + | "properties": { | |||
| 39 | + | "agent_id": { | |||
| 40 | + | "type": "string", | |||
| 41 | + | "description": "Worker id or unique id prefix for the stuck background sub-agent to terminate." | |||
| 42 | + | } | |||
| 43 | + | }, | |||
| 44 | + | "required": ["agent_id"] | |||
| 45 | + | }` | |||
| 46 | + | ||||
| 47 | + | func (t *SubagentStopTool) Name() string { return "subagent_stop" } | |||
| 48 | + | ||||
| 49 | + | func (t *SubagentStopTool) Description() string { | |||
| 50 | + | return "Request termination of a stuck background sub-agent. It gracefully requests shutdown, then cancels or force-stops the worker when necessary." | |||
| 51 | + | } | |||
| 52 | + | ||||
| 53 | + | func (t *SubagentStopTool) Schema() json.RawMessage { | |||
| 54 | + | return json.RawMessage(subagentStopSchema) | |||
| 55 | + | } | |||
| 56 | + | ||||
| 57 | + | func (t *SubagentStopTool) Execute(ctx context.Context, raw json.RawMessage, _ func(string)) (core.ToolResult, error) { | |||
| 58 | + | if ctx != nil { | |||
| 59 | + | select { | |||
| 60 | + | case <-ctx.Done(): | |||
| 61 | + | return core.ToolResult{}, ctx.Err() | |||
| 62 | + | default: | |||
| 63 | + | } | |||
| 64 | + | } | |||
| 65 | + | prefix := t.Name() | |||
| 66 | + | if t.Supervisor == nil { | |||
| 67 | + | return protocolToolError(prefix + ": subagent supervisor not available in this mode") | |||
| 68 | + | } | |||
| 69 | + | if t.Enabled == nil || !t.Enabled() { | |||
| 70 | + | return protocolToolError(prefix + ": auto-subagents is disabled. Ask the user to enable it from /settings before managing workers.") | |||
| 71 | + | } | |||
| 72 | + | ||||
| 73 | + | var args subagentStopArgs | |||
| 74 | + | if err := json.Unmarshal(raw, &args); err != nil { | |||
| 75 | + | return core.ToolResult{}, fmt.Errorf("invalid args: %w", err) | |||
| 76 | + | } | |||
| 77 | + | id := strings.TrimSpace(args.AgentID) | |||
| 78 | + | if id == "" { | |||
| 79 | + | return protocolToolError(prefix + ": agent_id is required") | |||
| 80 | + | } | |||
| 81 | + | snapshot, ok := findSubagentStatusSnapshot(t.Supervisor.SnapshotAll(), id) | |||
| 82 | + | if !ok { | |||
| 83 | + | return protocolToolError(fmt.Sprintf("%s: no such agent %q", prefix, id)) | |||
| 84 | + | } | |||
| 85 | + | if !subagentCanStop(snapshot) { | |||
| 86 | + | return protocolToolError(fmt.Sprintf("%s: agent %s has no live worker to stop", prefix, snapshot.ID)) | |||
| 87 | + | } | |||
| 88 | + | if t.OnStopRequested != nil { | |||
| 89 | + | if agent := t.Supervisor.Get(snapshot.ID); agent != nil { | |||
| 90 | + | t.OnStopRequested(agent) | |||
| 91 | + | } | |||
| 92 | + | } | |||
| 93 | + | if err := t.Supervisor.StopContext(ctx, snapshot.ID); err != nil { | |||
| 94 | + | return core.ToolResult{}, fmt.Errorf("%s: %w", prefix, err) | |||
| 95 | + | } | |||
| 96 | + | ||||
| 97 | + | snapshot, ok = findSubagentStatusSnapshot(t.Supervisor.SnapshotAll(), snapshot.ID) | |||
| 98 | + | if !ok { | |||
| 99 | + | return core.ToolResult{}, fmt.Errorf("%s: terminated agent disappeared from supervisor", prefix) | |||
| 100 | + | } | |||
| 101 | + | return renderSubagentAction("stop_requested", snapshot) | |||
| 102 | + | } | |||
| 103 | + | ||||
| 104 | + | func subagentCanStop(snapshot subagents.AgentSnapshot) bool { | |||
| 105 | + | return snapshot.Status == subagents.StatusPending || | |||
| 106 | + | snapshot.Status == subagents.StatusRunning || | |||
| 107 | + | (snapshot.Status == subagents.StatusDetached && snapshot.ProcessState == subagents.ProcessAlive) | |||
| 108 | + | } | |||
| 109 | + | ||||
| 110 | + | func renderSubagentAction(action string, snapshot subagents.AgentSnapshot) (core.ToolResult, error) { | |||
| 111 | + | response := subagentActionResponse{ | |||
| 112 | + | Action: action, | |||
| 113 | + | Agent: publicSubagentStatus(snapshot), | |||
| 114 | + | } | |||
| 115 | + | data, err := json.Marshal(response) | |||
| 116 | + | if err != nil { | |||
| 117 | + | return core.ToolResult{}, fmt.Errorf("subagent action: encode response: %w", err) | |||
| 118 | + | } | |||
| 119 | + | return core.ToolResult{ | |||
| 120 | + | Content: []provider.Content{provider.TextBlock{Text: string(data)}}, | |||
| 121 | + | Details: response, | |||
| 122 | + | }, nil | |||
| 123 | + | } | |||