detached bd96a88

fix(agent): address orchestration review findings

commit
bd96a88783311826a9751d734cd37bc415e1aa11
author
brice · · 2026-08-08 20:01 UTC
parent
0c65e7a
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
20 files · +343 -310
M README.md +1 -1
@@ -256,7 +256,7 @@ When the sandbox is on (see `/jail`), filesystem tools and LSP workspace edits r
256256
257257 Workers inherit the parent CLI provider, model, and reasoning selection. A named profile from [docs/subagents.md](docs/subagents.md) may explicitly override its own provider/model/reasoning; profile metadata and the profile body are loaded only after the parent selects that profile. Print mode writes only the final synthesis; stream mode renders every parent turn to stdout while tool diagnostics remain on stderr; JSON mode retains every parent event as parseable JSONL, including completion updates as existing `user_message` events. Worker output is evidence for the next parent turn, not a host log.
258258
259-The supervisor enforces configured concurrency, per-parent concurrency, queue/deadline, active-turn, output, and cancellation-grace limits; cancellation stops the parent and workers through the normal graceful shutdown path. Workers cannot spawn descendants. See [docs/subagents.md](docs/subagents.md) for the profile manifest, limits, and unsupported combinations.
259+The supervisor enforces configured concurrency, per-parent concurrency, queue/deadline, active-turn, output, and cancellation-grace limits; a headless invocation permits at most 32 completion follow-up waves. Cancellation stops the parent and workers through the normal graceful shutdown path. Workers cannot spawn descendants. See [docs/subagents.md](docs/subagents.md) for the profile manifest, limits, and unsupported combinations.
260260
261261 When an initial print, stream, or JSON request exceeds the provider context window, zut compacts the existing transcript and continues the already-appended prompt once. Print still writes only the recovered final text, stream keeps assistant text on stdout and tool diagnostics on stderr, and JSON suppresses the recoverable first terminal error so stdout remains JSONL for the successful turn. A compaction failure or a second context-window error remains terminal; this is not a general autonomous-follow-up policy. The same one-shot recovery applies to subagent workers. It does not apply to RPC, SDK, bot, or Telegram requests.
262262
@@ -256,7 +256,7 @@ When the sandbox is on (see `/jail`), filesystem tools and LSP workspace edits r
256 256
257 Workers inherit the parent CLI provider, model, and reasoning selection. A named profile from [docs/subagents.md](docs/subagents.md) may explicitly override its own provider/model/reasoning; profile metadata and the profile body are loaded only after the parent selects that profile. Print mode writes only the final synthesis; stream mode renders every parent turn to stdout while tool diagnostics remain on stderr; JSON mode retains every parent event as parseable JSONL, including completion updates as existing `user_message` events. Worker output is evidence for the next parent turn, not a host log.257 Workers inherit the parent CLI provider, model, and reasoning selection. A named profile from [docs/subagents.md](docs/subagents.md) may explicitly override its own provider/model/reasoning; profile metadata and the profile body are loaded only after the parent selects that profile. Print mode writes only the final synthesis; stream mode renders every parent turn to stdout while tool diagnostics remain on stderr; JSON mode retains every parent event as parseable JSONL, including completion updates as existing `user_message` events. Worker output is evidence for the next parent turn, not a host log.
258 258
259-The supervisor enforces configured concurrency, per-parent concurrency, queue/deadline, active-turn, output, and cancellation-grace limits; cancellation stops the parent and workers through the normal graceful shutdown path. Workers cannot spawn descendants. See [docs/subagents.md](docs/subagents.md) for the profile manifest, limits, and unsupported combinations.259+The supervisor enforces configured concurrency, per-parent concurrency, queue/deadline, active-turn, output, and cancellation-grace limits; a headless invocation permits at most 32 completion follow-up waves. Cancellation stops the parent and workers through the normal graceful shutdown path. Workers cannot spawn descendants. See [docs/subagents.md](docs/subagents.md) for the profile manifest, limits, and unsupported combinations.
260 260
261 When an initial print, stream, or JSON request exceeds the provider context window, zut compacts the existing transcript and continues the already-appended prompt once. Print still writes only the recovered final text, stream keeps assistant text on stdout and tool diagnostics on stderr, and JSON suppresses the recoverable first terminal error so stdout remains JSONL for the successful turn. A compaction failure or a second context-window error remains terminal; this is not a general autonomous-follow-up policy. The same one-shot recovery applies to subagent workers. It does not apply to RPC, SDK, bot, or Telegram requests.261 When an initial print, stream, or JSON request exceeds the provider context window, zut compacts the existing transcript and continues the already-appended prompt once. Print still writes only the recovered final text, stream keeps assistant text on stdout and tool diagnostics on stderr, and JSON suppresses the recoverable first terminal error so stdout remains JSONL for the successful turn. A compaction failure or a second context-window error remains terminal; this is not a general autonomous-follow-up policy. The same one-shot recovery applies to subagent workers. It does not apply to RPC, SDK, bot, or Telegram requests.
262 262
M docs/subagents.md +1 -1
@@ -62,7 +62,7 @@ The parent CLI's `--provider`, `--model`, and `--reasoning` values are inherited
6262 zut -p --orchestrate --provider openai --model gpt-5 --reasoning high "delegate the implementation and synthesize the result"
6363 ```
6464
65-The parent runs completion-driven waves rather than polling. Configured concurrency and per-parent concurrency limits, queue and turn deadlines, maximum turns/output, and the graceful cancellation/shutdown period apply. Cancellation propagates to the parent and workers through the supervisor; partial worker evidence remains available when the worker reports it.
65+The parent runs completion-driven waves rather than polling, with at most 32 follow-up waves per invocation. Configured concurrency and per-parent concurrency limits, queue and turn deadlines, maximum turns/output, and the graceful cancellation/shutdown period apply. Cancellation propagates to the parent and workers through the supervisor; partial worker evidence remains available when the worker reports it.
6666
6767 Output follows the selected existing mode. Print writes only the final synthesis. Stream renders every parent turn on stdout and keeps tool diagnostics on stderr. JSON retains every event from every parent turn as parseable JSONL; completion reports are injected as ordinary `user_message` events, and a failed primary or handoff produces one terminal JSON error object rather than a host log.
6868
@@ -62,7 +62,7 @@ The parent CLI's `--provider`, `--model`, and `--reasoning` values are inherited
62 zut -p --orchestrate --provider openai --model gpt-5 --reasoning high "delegate the implementation and synthesize the result"62 zut -p --orchestrate --provider openai --model gpt-5 --reasoning high "delegate the implementation and synthesize the result"
63 ```63 ```
64 64
65-The parent runs completion-driven waves rather than polling. Configured concurrency and per-parent concurrency limits, queue and turn deadlines, maximum turns/output, and the graceful cancellation/shutdown period apply. Cancellation propagates to the parent and workers through the supervisor; partial worker evidence remains available when the worker reports it.65+The parent runs completion-driven waves rather than polling, with at most 32 follow-up waves per invocation. Configured concurrency and per-parent concurrency limits, queue and turn deadlines, maximum turns/output, and the graceful cancellation/shutdown period apply. Cancellation propagates to the parent and workers through the supervisor; partial worker evidence remains available when the worker reports it.
66 66
67 Output follows the selected existing mode. Print writes only the final synthesis. Stream renders every parent turn on stdout and keeps tool diagnostics on stderr. JSON retains every event from every parent turn as parseable JSONL; completion reports are injected as ordinary `user_message` events, and a failed primary or handoff produces one terminal JSON error object rather than a host log.67 Output follows the selected existing mode. Print writes only the final synthesis. Stream renders every parent turn on stdout and keeps tool diagnostics on stderr. JSON retains every event from every parent turn as parseable JSONL; completion reports are injected as ordinary `user_message` events, and a failed primary or handoff produces one terminal JSON error object rather than a host log.
68 68
M packages/agent/args_test.go +2 -0
@@ -39,6 +39,8 @@ func TestValidateOrchestrationArgs(t *testing.T) {
3939 {name: "profile", args: Args{Mode: ModePrint, Orchestrate: true, Subagent: "reviewer"}, want: "--subagent profiles"},
4040 {name: "stats", args: Args{Mode: ModePrint, Orchestrate: true, StatsPath: "stats.json"}, want: "--stats"},
4141 {name: "packaged", args: Args{Mode: ModePrint, Orchestrate: true, PermissionSet: &tools.PermissionSet{}}, want: "packaged"},
42+ {name: "packaged agent name", args: Args{Mode: ModePrint, Orchestrate: true, AgentName: "bundled"}, want: "packaged"},
43+ {name: "packaged agent data dir", args: Args{Mode: ModePrint, Orchestrate: true, AgentDataDir: "/tmp/agent-data"}, want: "packaged"},
4244 {name: "missing spawn", args: Args{Mode: ModePrint, Orchestrate: true, ToolsSet: true, Tools: []string{"read"}}, want: "subagent_spawn"},
4345 } {
4446 t.Run(tc.name, func(t *testing.T) {
@@ -39,6 +39,8 @@ func TestValidateOrchestrationArgs(t *testing.T) {
39 {name: "profile", args: Args{Mode: ModePrint, Orchestrate: true, Subagent: "reviewer"}, want: "--subagent profiles"},39 {name: "profile", args: Args{Mode: ModePrint, Orchestrate: true, Subagent: "reviewer"}, want: "--subagent profiles"},
40 {name: "stats", args: Args{Mode: ModePrint, Orchestrate: true, StatsPath: "stats.json"}, want: "--stats"},40 {name: "stats", args: Args{Mode: ModePrint, Orchestrate: true, StatsPath: "stats.json"}, want: "--stats"},
41 {name: "packaged", args: Args{Mode: ModePrint, Orchestrate: true, PermissionSet: &tools.PermissionSet{}}, want: "packaged"},41 {name: "packaged", args: Args{Mode: ModePrint, Orchestrate: true, PermissionSet: &tools.PermissionSet{}}, want: "packaged"},
42+ {name: "packaged agent name", args: Args{Mode: ModePrint, Orchestrate: true, AgentName: "bundled"}, want: "packaged"},
43+ {name: "packaged agent data dir", args: Args{Mode: ModePrint, Orchestrate: true, AgentDataDir: "/tmp/agent-data"}, want: "packaged"},
42 {name: "missing spawn", args: Args{Mode: ModePrint, Orchestrate: true, ToolsSet: true, Tools: []string{"read"}}, want: "subagent_spawn"},44 {name: "missing spawn", args: Args{Mode: ModePrint, Orchestrate: true, ToolsSet: true, Tools: []string{"read"}}, want: "subagent_spawn"},
43 } {45 } {
44 t.Run(tc.name, func(t *testing.T) {46 t.Run(tc.name, func(t *testing.T) {
M packages/agent/build.go +6 -4
@@ -863,10 +863,12 @@ func Resolve(args Args, requireCred bool) (Resolved, error) {
863863 // Headless orchestration owns its prompt contract rather than inheriting
864864 // the interactive setting. Discover and append the compact manifest once;
865865 // child profile bodies remain private to the selected child.
866- homeDir, _ := os.UserHomeDir()
867- profiles, _ := subagents.Discover(args.CWD, homeDir)
868- if subagentsAddendum := subagents.SystemPromptAddendum(profiles); subagentsAddendum != "" {
869- append_ = append(append_, subagentsAddendum)
866+ if autoSubagentsToolAllowed(args) {
867+ homeDir, _ := os.UserHomeDir()
868+ profiles, _ := subagents.Discover(args.CWD, homeDir)
869+ if subagentsAddendum := subagents.SystemPromptAddendum(profiles); subagentsAddendum != "" {
870+ append_ = append(append_, subagentsAddendum)
871+ }
870872 }
871873 append_ = append(append_, AutoSubagentsSystemAddendumFor(
872874 autoSubagentsToolAllowed(args),
@@ -863,10 +863,12 @@ func Resolve(args Args, requireCred bool) (Resolved, error) {
863 // Headless orchestration owns its prompt contract rather than inheriting863 // Headless orchestration owns its prompt contract rather than inheriting
864 // the interactive setting. Discover and append the compact manifest once;864 // the interactive setting. Discover and append the compact manifest once;
865 // child profile bodies remain private to the selected child.865 // child profile bodies remain private to the selected child.
866- homeDir, _ := os.UserHomeDir()866+ if autoSubagentsToolAllowed(args) {
867- profiles, _ := subagents.Discover(args.CWD, homeDir)867+ homeDir, _ := os.UserHomeDir()
868- if subagentsAddendum := subagents.SystemPromptAddendum(profiles); subagentsAddendum != "" {868+ profiles, _ := subagents.Discover(args.CWD, homeDir)
869- append_ = append(append_, subagentsAddendum)869+ if subagentsAddendum := subagents.SystemPromptAddendum(profiles); subagentsAddendum != "" {
870+ append_ = append(append_, subagentsAddendum)
871+ }
870 }872 }
871 append_ = append(append_, AutoSubagentsSystemAddendumFor(873 append_ = append(append_, AutoSubagentsSystemAddendumFor(
872 autoSubagentsToolAllowed(args),874 autoSubagentsToolAllowed(args),
M packages/agent/build_test.go +5 -4
@@ -361,10 +361,10 @@ func TestResolveOrchestratedParentPromptScopesManifestAndContract(t *testing.T)
361361 t.Fatal(err)
362362 }
363363 if got := strings.Count(parent.SystemPrompt, "[subagents_list]"); got != 1 {
364- t.Fatalf("parent profile manifest count = %d, want 1\\n%s", got, parent.SystemPrompt)
364+ t.Fatalf("parent profile manifest count = %d, want 1\n%s", got, parent.SystemPrompt)
365365 }
366366 if !strings.Contains(parent.SystemPrompt, "primary-agent orchestrator") {
367- t.Fatalf("parent prompt omitted strict orchestrator contract:\\n%s", parent.SystemPrompt)
367+ t.Fatalf("parent prompt omitted strict orchestrator contract:\n%s", parent.SystemPrompt)
368368 }
369369
370370 child, err := Resolve(Args{CWD: project, Mode: ModePrint, Orchestrate: true, Subagent: "reviewer"}, false)
@@ -372,10 +372,10 @@ func TestResolveOrchestratedParentPromptScopesManifestAndContract(t *testing.T)
372372 t.Fatal(err)
373373 }
374374 if strings.Contains(child.SystemPrompt, "primary-agent orchestrator") || strings.Contains(child.SystemPrompt, "[subagents_list]") {
375- t.Fatalf("selected child inherited parent orchestration instructions:\\n%s", child.SystemPrompt)
375+ t.Fatalf("selected child inherited parent orchestration instructions:\n%s", child.SystemPrompt)
376376 }
377377 if !strings.Contains(child.SystemPrompt, "Review only.") {
378- t.Fatalf("selected child profile prompt missing:\\n%s", child.SystemPrompt)
378+ t.Fatalf("selected child profile prompt missing:\n%s", child.SystemPrompt)
379379 }
380380 }
381381
@@ -466,6 +466,7 @@ func TestResolveKeepsStrictContractButOmitsProfilesWhenSpawnIsUnavailable(t *tes
466466 {name: "read allowlist", args: Args{CWD: project, Tools: []string{"read"}, ToolsSet: true}},
467467 {name: "status allowlist", args: Args{CWD: project, Tools: []string{"subagent_status"}, ToolsSet: true}},
468468 {name: "permission set", args: Args{CWD: project, PermissionSet: &tools.PermissionSet{}}},
469+ {name: "headless orchestration", args: Args{CWD: project, Mode: ModePrint, Orchestrate: true, NoTools: true}},
469470 }
470471 for _, tc := range cases {
471472 t.Run(tc.name, func(t *testing.T) {
@@ -361,10 +361,10 @@ func TestResolveOrchestratedParentPromptScopesManifestAndContract(t *testing.T)
361 t.Fatal(err)361 t.Fatal(err)
362 }362 }
363 if got := strings.Count(parent.SystemPrompt, "[subagents_list]"); got != 1 {363 if got := strings.Count(parent.SystemPrompt, "[subagents_list]"); got != 1 {
364- t.Fatalf("parent profile manifest count = %d, want 1\\n%s", got, parent.SystemPrompt)364+ t.Fatalf("parent profile manifest count = %d, want 1\n%s", got, parent.SystemPrompt)
365 }365 }
366 if !strings.Contains(parent.SystemPrompt, "primary-agent orchestrator") {366 if !strings.Contains(parent.SystemPrompt, "primary-agent orchestrator") {
367- t.Fatalf("parent prompt omitted strict orchestrator contract:\\n%s", parent.SystemPrompt)367+ t.Fatalf("parent prompt omitted strict orchestrator contract:\n%s", parent.SystemPrompt)
368 }368 }
369 369
370 child, err := Resolve(Args{CWD: project, Mode: ModePrint, Orchestrate: true, Subagent: "reviewer"}, false)370 child, err := Resolve(Args{CWD: project, Mode: ModePrint, Orchestrate: true, Subagent: "reviewer"}, false)
@@ -372,10 +372,10 @@ func TestResolveOrchestratedParentPromptScopesManifestAndContract(t *testing.T)
372 t.Fatal(err)372 t.Fatal(err)
373 }373 }
374 if strings.Contains(child.SystemPrompt, "primary-agent orchestrator") || strings.Contains(child.SystemPrompt, "[subagents_list]") {374 if strings.Contains(child.SystemPrompt, "primary-agent orchestrator") || strings.Contains(child.SystemPrompt, "[subagents_list]") {
375- t.Fatalf("selected child inherited parent orchestration instructions:\\n%s", child.SystemPrompt)375+ t.Fatalf("selected child inherited parent orchestration instructions:\n%s", child.SystemPrompt)
376 }376 }
377 if !strings.Contains(child.SystemPrompt, "Review only.") {377 if !strings.Contains(child.SystemPrompt, "Review only.") {
378- t.Fatalf("selected child profile prompt missing:\\n%s", child.SystemPrompt)378+ t.Fatalf("selected child profile prompt missing:\n%s", child.SystemPrompt)
379 }379 }
380 }380 }
381 381
@@ -466,6 +466,7 @@ func TestResolveKeepsStrictContractButOmitsProfilesWhenSpawnIsUnavailable(t *tes
466 {name: "read allowlist", args: Args{CWD: project, Tools: []string{"read"}, ToolsSet: true}},466 {name: "read allowlist", args: Args{CWD: project, Tools: []string{"read"}, ToolsSet: true}},
467 {name: "status allowlist", args: Args{CWD: project, Tools: []string{"subagent_status"}, ToolsSet: true}},467 {name: "status allowlist", args: Args{CWD: project, Tools: []string{"subagent_status"}, ToolsSet: true}},
468 {name: "permission set", args: Args{CWD: project, PermissionSet: &tools.PermissionSet{}}},468 {name: "permission set", args: Args{CWD: project, PermissionSet: &tools.PermissionSet{}}},
469+ {name: "headless orchestration", args: Args{CWD: project, Mode: ModePrint, Orchestrate: true, NoTools: true}},
469 }470 }
470 for _, tc := range cases {471 for _, tc := range cases {
471 t.Run(tc.name, func(t *testing.T) {472 t.Run(tc.name, func(t *testing.T) {
M packages/agent/cli.go +26 -5
@@ -1488,19 +1488,30 @@ func runInteractive(ctx context.Context, args Args, version string) (runErr erro
14881488 WebSearchGuard: webSearchGuard,
14891489 ActiveProvider: activeProviderForSubagents,
14901490 ActiveModel: activeModelForSubagents,
1491- ActiveReasoning: func() string { return r.Reasoning },
14921491 OnSpawned: onSpawnedSupervisor,
14931492 OnResumed: onResumedSupervisor,
14941493 BeforeResumed: onBeforeResumedSupervisor,
14951494 OnStopRequested: onStopRequestedSupervisor,
14961495 })
14971496 subagentsMgr = runtime.Supervisor()
1497+ reloadDone := make(chan struct{})
1498+ var reloadErrs []error
14981499 if subagentsMgr != nil {
14991500 // Replaying historical event logs can take seconds. Populate the
15001501 // detached-agent dashboard without blocking the first interactive paint.
1501- go func() { _, _ = subagentsMgr.Reload() }()
1502+ go func() {
1503+ _, reloadErrs = subagentsMgr.Reload()
1504+ close(reloadDone)
1505+ }()
1506+ } else {
1507+ close(reloadDone)
15021508 }
1503- defer func() { _ = runtime.Close(context.Background()) }()
1509+ defer func() {
1510+ // Reload may attach detached workers, so let it finish before shutdown
1511+ // snapshots the supervisor's worker set.
1512+ <-reloadDone
1513+ _ = closeSubagentRuntimeFresh(runtime)
1514+ }()
15041515
15051516 prepareInteractiveRegistry := runtime.PrepareRegistry
15061517 prepareResolvedInteractiveRegistry := runtime.PrepareResolvedRegistry
@@ -2285,6 +2296,7 @@ func runInteractive(ctx context.Context, args Args, version string) (runErr erro
22852296 AuthMethod: r.AuthMethod,
22862297 BaseURL: r.BaseURL,
22872298 Reasoning: r.Reasoning,
2299+ OnReasoningChanged: runtime.SetReasoning,
22882300 SystemPrompt: r.SystemPrompt,
22892301 Tools: r.ToolRegistry,
22902302 MaxSteps: r.MaxSteps,
@@ -2495,6 +2507,14 @@ func runInteractive(ctx context.Context, args Args, version string) (runErr erro
24952507 },
24962508 })
24972509 extHooks.attachInteractive(iv)
2510+ go func() {
2511+ <-reloadDone
2512+ for _, reloadErr := range reloadErrs {
2513+ if reloadErr != nil {
2514+ iv.ReportError(fmt.Errorf("reload subagents: %w", reloadErr))
2515+ }
2516+ }
2517+ }()
24982518
24992519 // Bind the interactive TUI as the Confirmer. We deferred this
25002520 // until now because the gate is constructed before the TUI
@@ -2546,8 +2566,9 @@ func runInteractive(ctx context.Context, args Args, version string) (runErr erro
25462566 closeAgentLSP(finalAg)
25472567 closeResolvedLSP(r)
25482568 // Stop supervised workers before exiting. os.Exit skips deferred
2549- // cleanup, so close the shared runtime explicitly on a TERM or HUP.
2550- _ = runtime.Close(context.Background())
2569+ // cleanup, so wait for reload and close the shared runtime explicitly.
2570+ <-reloadDone
2571+ _ = closeSubagentRuntimeFresh(runtime)
25512572 // Exit cleanly. Re-raising the signal would skip os.Exit's
25522573 // at-exit hooks; explicit exit is fine because we've already
25532574 // flushed the only at-risk state (the session file) and stopped
@@ -1488,19 +1488,30 @@ func runInteractive(ctx context.Context, args Args, version string) (runErr erro
1488 WebSearchGuard: webSearchGuard,1488 WebSearchGuard: webSearchGuard,
1489 ActiveProvider: activeProviderForSubagents,1489 ActiveProvider: activeProviderForSubagents,
1490 ActiveModel: activeModelForSubagents,1490 ActiveModel: activeModelForSubagents,
1491- ActiveReasoning: func() string { return r.Reasoning },
1492 OnSpawned: onSpawnedSupervisor,1491 OnSpawned: onSpawnedSupervisor,
1493 OnResumed: onResumedSupervisor,1492 OnResumed: onResumedSupervisor,
1494 BeforeResumed: onBeforeResumedSupervisor,1493 BeforeResumed: onBeforeResumedSupervisor,
1495 OnStopRequested: onStopRequestedSupervisor,1494 OnStopRequested: onStopRequestedSupervisor,
1496 })1495 })
1497 subagentsMgr = runtime.Supervisor()1496 subagentsMgr = runtime.Supervisor()
1497+ reloadDone := make(chan struct{})
1498+ var reloadErrs []error
1498 if subagentsMgr != nil {1499 if subagentsMgr != nil {
1499 // Replaying historical event logs can take seconds. Populate the1500 // Replaying historical event logs can take seconds. Populate the
1500 // detached-agent dashboard without blocking the first interactive paint.1501 // detached-agent dashboard without blocking the first interactive paint.
1501- go func() { _, _ = subagentsMgr.Reload() }()1502+ go func() {
1503+ _, reloadErrs = subagentsMgr.Reload()
1504+ close(reloadDone)
1505+ }()
1506+ } else {
1507+ close(reloadDone)
1502 }1508 }
1503- defer func() { _ = runtime.Close(context.Background()) }()1509+ defer func() {
1510+ // Reload may attach detached workers, so let it finish before shutdown
1511+ // snapshots the supervisor's worker set.
1512+ <-reloadDone
1513+ _ = closeSubagentRuntimeFresh(runtime)
1514+ }()
1504 1515
1505 prepareInteractiveRegistry := runtime.PrepareRegistry1516 prepareInteractiveRegistry := runtime.PrepareRegistry
1506 prepareResolvedInteractiveRegistry := runtime.PrepareResolvedRegistry1517 prepareResolvedInteractiveRegistry := runtime.PrepareResolvedRegistry
@@ -2285,6 +2296,7 @@ func runInteractive(ctx context.Context, args Args, version string) (runErr erro
2285 AuthMethod: r.AuthMethod,2296 AuthMethod: r.AuthMethod,
2286 BaseURL: r.BaseURL,2297 BaseURL: r.BaseURL,
2287 Reasoning: r.Reasoning,2298 Reasoning: r.Reasoning,
2299+ OnReasoningChanged: runtime.SetReasoning,
2288 SystemPrompt: r.SystemPrompt,2300 SystemPrompt: r.SystemPrompt,
2289 Tools: r.ToolRegistry,2301 Tools: r.ToolRegistry,
2290 MaxSteps: r.MaxSteps,2302 MaxSteps: r.MaxSteps,
@@ -2495,6 +2507,14 @@ func runInteractive(ctx context.Context, args Args, version string) (runErr erro
2495 },2507 },
2496 })2508 })
2497 extHooks.attachInteractive(iv)2509 extHooks.attachInteractive(iv)
2510+ go func() {
2511+ <-reloadDone
2512+ for _, reloadErr := range reloadErrs {
2513+ if reloadErr != nil {
2514+ iv.ReportError(fmt.Errorf("reload subagents: %w", reloadErr))
2515+ }
2516+ }
2517+ }()
2498 2518
2499 // Bind the interactive TUI as the Confirmer. We deferred this2519 // Bind the interactive TUI as the Confirmer. We deferred this
2500 // until now because the gate is constructed before the TUI2520 // until now because the gate is constructed before the TUI
@@ -2546,8 +2566,9 @@ func runInteractive(ctx context.Context, args Args, version string) (runErr erro
2546 closeAgentLSP(finalAg)2566 closeAgentLSP(finalAg)
2547 closeResolvedLSP(r)2567 closeResolvedLSP(r)
2548 // Stop supervised workers before exiting. os.Exit skips deferred2568 // Stop supervised workers before exiting. os.Exit skips deferred
2549- // cleanup, so close the shared runtime explicitly on a TERM or HUP.2569+ // cleanup, so wait for reload and close the shared runtime explicitly.
2550- _ = runtime.Close(context.Background())2570+ <-reloadDone
2571+ _ = closeSubagentRuntimeFresh(runtime)
2551 // Exit cleanly. Re-raising the signal would skip os.Exit's2572 // Exit cleanly. Re-raising the signal would skip os.Exit's
2552 // at-exit hooks; explicit exit is fine because we've already2573 // at-exit hooks; explicit exit is fine because we've already
2553 // flushed the only at-risk state (the session file) and stopped2574 // flushed the only at-risk state (the session file) and stopped
M packages/agent/modes/auto_subagents_test.go +6 -0
@@ -288,6 +288,12 @@ func TestCompletionDeliveryHoldBatchesFastAndLateRegistrations(t *testing.T) {
288288 if !strings.Contains(update, "task: first task") || !strings.Contains(update, "task: second task") {
289289 t.Fatalf("batched update omitted one task: %q", update)
290290 }
291+ iv.mu.Lock()
292+ queued := len(iv.queued)
293+ iv.mu.Unlock()
294+ if queued != 1 {
295+ t.Fatalf("queued updates = %d; want exactly one batched update", queued)
296+ }
291297 }
292298
293299 func TestCompleteSupervisorWatchReportsTurnOutcomeOnce(t *testing.T) {
@@ -288,6 +288,12 @@ func TestCompletionDeliveryHoldBatchesFastAndLateRegistrations(t *testing.T) {
288 if !strings.Contains(update, "task: first task") || !strings.Contains(update, "task: second task") {288 if !strings.Contains(update, "task: first task") || !strings.Contains(update, "task: second task") {
289 t.Fatalf("batched update omitted one task: %q", update)289 t.Fatalf("batched update omitted one task: %q", update)
290 }290 }
291+ iv.mu.Lock()
292+ queued := len(iv.queued)
293+ iv.mu.Unlock()
294+ if queued != 1 {
295+ t.Fatalf("queued updates = %d; want exactly one batched update", queued)
296+ }
291 }297 }
292 298
293 func TestCompleteSupervisorWatchReportsTurnOutcomeOnce(t *testing.T) {299 func TestCompleteSupervisorWatchReportsTurnOutcomeOnce(t *testing.T) {
M packages/agent/modes/interactive.go +10 -2
@@ -345,6 +345,9 @@ type InteractiveConfig struct {
345345 // It should update config.json and (if there's an active session)
346346 // write a new meta row so resume picks up the same model.
347347 PersistModel func(providerName, model string)
348+ // OnReasoningChanged updates host-owned runtime defaults after the setting
349+ // has been persisted successfully.
350+ OnReasoningChanged func(level string)
348351
349352 // InitialSessionTitle is the persisted title of the session loaded before
350353 // the TUI starts. It is shown in the terminal without another model call.
@@ -920,8 +923,7 @@ func NewInteractive(cfg InteractiveConfig) *Interactive {
920923 startupSkillNames = append(startupSkillNames, cfg.StartupSkillNames...)
921924 }
922925 i := &Interactive{
923- cfg: cfg,
924- completionTracker: subagents.NewCompletionTracker(),
926+ cfg: cfg,
925927 view: &tui.View{
926928 Theme: cfg.Theme,
927929 ImageProto: effectiveImageProtocol(cfg.InlineImagesEnabled),
@@ -5632,6 +5634,9 @@ func (i *Interactive) applyReasoningSetting(level string) {
56325634 return
56335635 }
56345636 }
5637+ if i.cfg.OnReasoningChanged != nil {
5638+ i.cfg.OnReasoningChanged(level)
5639+ }
56355640 i.mu.Lock()
56365641 if i.agent != nil {
56375642 i.agent.Reasoning = level
@@ -8750,6 +8755,8 @@ func (i *Interactive) trackSubagentWorker(a *subagents.Agent, task string, follo
87508755 }
87518756
87528757 func (i *Interactive) ensureCompletionTracker() *subagents.CompletionTracker {
8758+ // Tests and lightweight embedders may construct Interactive with a struct
8759+ // literal instead of NewInteractive, so retain this lazy fallback.
87538760 i.completionDeliveryMu.Lock()
87548761 defer i.completionDeliveryMu.Unlock()
87558762 if i.completionTracker == nil {
@@ -8766,6 +8773,7 @@ func (i *Interactive) requestCompletionDelivery() {
87668773 if i == nil {
87678774 return
87688775 }
8776+ i.ensureCompletionTracker()
87698777 i.completionDeliveryMu.Lock()
87708778 i.completionDeliveryRequest = true
87718779 if i.completionDeliveryRunning || i.completionDeliveryHolds != 0 {
@@ -345,6 +345,9 @@ type InteractiveConfig struct {
345 // It should update config.json and (if there's an active session)345 // It should update config.json and (if there's an active session)
346 // write a new meta row so resume picks up the same model.346 // write a new meta row so resume picks up the same model.
347 PersistModel func(providerName, model string)347 PersistModel func(providerName, model string)
348+ // OnReasoningChanged updates host-owned runtime defaults after the setting
349+ // has been persisted successfully.
350+ OnReasoningChanged func(level string)
348 351
349 // InitialSessionTitle is the persisted title of the session loaded before352 // InitialSessionTitle is the persisted title of the session loaded before
350 // the TUI starts. It is shown in the terminal without another model call.353 // the TUI starts. It is shown in the terminal without another model call.
@@ -920,8 +923,7 @@ func NewInteractive(cfg InteractiveConfig) *Interactive {
920 startupSkillNames = append(startupSkillNames, cfg.StartupSkillNames...)923 startupSkillNames = append(startupSkillNames, cfg.StartupSkillNames...)
921 }924 }
922 i := &Interactive{925 i := &Interactive{
923- cfg: cfg,926+ cfg: cfg,
924- completionTracker: subagents.NewCompletionTracker(),
925 view: &tui.View{927 view: &tui.View{
926 Theme: cfg.Theme,928 Theme: cfg.Theme,
927 ImageProto: effectiveImageProtocol(cfg.InlineImagesEnabled),929 ImageProto: effectiveImageProtocol(cfg.InlineImagesEnabled),
@@ -5632,6 +5634,9 @@ func (i *Interactive) applyReasoningSetting(level string) {
5632 return5634 return
5633 }5635 }
5634 }5636 }
5637+ if i.cfg.OnReasoningChanged != nil {
5638+ i.cfg.OnReasoningChanged(level)
5639+ }
5635 i.mu.Lock()5640 i.mu.Lock()
5636 if i.agent != nil {5641 if i.agent != nil {
5637 i.agent.Reasoning = level5642 i.agent.Reasoning = level
@@ -8750,6 +8755,8 @@ func (i *Interactive) trackSubagentWorker(a *subagents.Agent, task string, follo
8750 }8755 }
8751 8756
8752 func (i *Interactive) ensureCompletionTracker() *subagents.CompletionTracker {8757 func (i *Interactive) ensureCompletionTracker() *subagents.CompletionTracker {
8758+ // Tests and lightweight embedders may construct Interactive with a struct
8759+ // literal instead of NewInteractive, so retain this lazy fallback.
8753 i.completionDeliveryMu.Lock()8760 i.completionDeliveryMu.Lock()
8754 defer i.completionDeliveryMu.Unlock()8761 defer i.completionDeliveryMu.Unlock()
8755 if i.completionTracker == nil {8762 if i.completionTracker == nil {
@@ -8766,6 +8773,7 @@ func (i *Interactive) requestCompletionDelivery() {
8766 if i == nil {8773 if i == nil {
8767 return8774 return
8768 }8775 }
8776+ i.ensureCompletionTracker()
8769 i.completionDeliveryMu.Lock()8777 i.completionDeliveryMu.Lock()
8770 i.completionDeliveryRequest = true8778 i.completionDeliveryRequest = true
8771 if i.completionDeliveryRunning || i.completionDeliveryHolds != 0 {8779 if i.completionDeliveryRunning || i.completionDeliveryHolds != 0 {
M packages/agent/modes/json.go +8 -1
@@ -5,6 +5,7 @@ import (
55 "encoding/json"
66 "errors"
77 "io"
8+ "sync"
89
910 "github.com/bnema/zut/packages/core"
1011 "github.com/bnema/zut/packages/provider"
@@ -24,8 +25,11 @@ func RunJSON(ctx context.Context, ag *core.Agent, prompt string, images []provid
2425 // a successful checkpoint.
2526 func RunJSONWithContextRecovery(ctx context.Context, ag *core.Agent, prompt string, images []provider.ImageBlock, out io.Writer, persistCompaction func([]provider.Message) error) (ContextRecoveryResult, error) {
2627 enc := json.NewEncoder(out)
28+ var writeMu sync.Mutex
2729 var writeErr error
2830 write := func(v any) {
31+ writeMu.Lock()
32+ defer writeMu.Unlock()
2933 if writeErr == nil {
3034 writeErr = enc.Encode(v)
3135 }
@@ -47,7 +51,10 @@ func RunJSONWithContextRecovery(ctx context.Context, ag *core.Agent, prompt stri
4751 if runErr != nil {
4852 write(map[string]any{"type": "error", "message": runErr.Error()})
4953 }
50- return recovery, errors.Join(runErr, writeErr)
54+ writeMu.Lock()
55+ outputErr := writeErr
56+ writeMu.Unlock()
57+ return recovery, errors.Join(runErr, outputErr)
5158 }
5259
5360 // EventToJSON converts an AgentEvent to a JSON-friendly map. The on-wire
@@ -5,6 +5,7 @@ import (
5 "encoding/json"5 "encoding/json"
6 "errors"6 "errors"
7 "io"7 "io"
8+ "sync"
8 9
9 "github.com/bnema/zut/packages/core"10 "github.com/bnema/zut/packages/core"
10 "github.com/bnema/zut/packages/provider"11 "github.com/bnema/zut/packages/provider"
@@ -24,8 +25,11 @@ func RunJSON(ctx context.Context, ag *core.Agent, prompt string, images []provid
24 // a successful checkpoint.25 // a successful checkpoint.
25 func RunJSONWithContextRecovery(ctx context.Context, ag *core.Agent, prompt string, images []provider.ImageBlock, out io.Writer, persistCompaction func([]provider.Message) error) (ContextRecoveryResult, error) {26 func RunJSONWithContextRecovery(ctx context.Context, ag *core.Agent, prompt string, images []provider.ImageBlock, out io.Writer, persistCompaction func([]provider.Message) error) (ContextRecoveryResult, error) {
26 enc := json.NewEncoder(out)27 enc := json.NewEncoder(out)
28+ var writeMu sync.Mutex
27 var writeErr error29 var writeErr error
28 write := func(v any) {30 write := func(v any) {
31+ writeMu.Lock()
32+ defer writeMu.Unlock()
29 if writeErr == nil {33 if writeErr == nil {
30 writeErr = enc.Encode(v)34 writeErr = enc.Encode(v)
31 }35 }
@@ -47,7 +51,10 @@ func RunJSONWithContextRecovery(ctx context.Context, ag *core.Agent, prompt stri
47 if runErr != nil {51 if runErr != nil {
48 write(map[string]any{"type": "error", "message": runErr.Error()})52 write(map[string]any{"type": "error", "message": runErr.Error()})
49 }53 }
50- return recovery, errors.Join(runErr, writeErr)54+ writeMu.Lock()
55+ outputErr := writeErr
56+ writeMu.Unlock()
57+ return recovery, errors.Join(runErr, outputErr)
51 }58 }
52 59
53 // EventToJSON converts an AgentEvent to a JSON-friendly map. The on-wire60 // EventToJSON converts an AgentEvent to a JSON-friendly map. The on-wire
M packages/agent/orchestration_cli_e2e_test.go +37 -12
@@ -18,6 +18,22 @@ import (
1818 "time"
1919 )
2020
21+var (
22+ orchestrationBuildOnce sync.Once
23+ orchestrationBuildDir string
24+ orchestrationBuildBinary string
25+ orchestrationBuildOutput []byte
26+ orchestrationBuildErr error
27+)
28+
29+func TestMain(m *testing.M) {
30+ code := m.Run()
31+ if orchestrationBuildDir != "" {
32+ _ = os.RemoveAll(orchestrationBuildDir)
33+ }
34+ os.Exit(code)
35+}
36+
2137 func TestOrchestratedCLITwoDelegationWavesAcrossHeadlessModes(t *testing.T) {
2238 if runtime.GOOS == "windows" {
2339 t.Skip("signal and subprocess semantics differ on Windows")
@@ -82,7 +98,7 @@ func TestOrchestratedCLICancellation(t *testing.T) {
8298 t.Skip("skip CLI orchestration end-to-end test in -short mode")
8399 }
84100 binary := buildOrchestrationCLI(t)
85- server, workerReady, releaseWorker := newCancellationProvider(t)
101+ server, workerReady, releaseWorker := newCancellationProvider()
86102 defer server.Close()
87103 defer close(releaseWorker)
88104
@@ -142,7 +158,7 @@ func TestOrchestratedCLIParentFailure(t *testing.T) {
142158 binary := buildOrchestrationCLI(t)
143159
144160 t.Run("failure-jsonl", func(t *testing.T) {
145- server := newFailingProvider(t)
161+ server := newFailingProvider()
146162 defer server.Close()
147163 cmd := exec.Command(binary, "--orchestrate", "--json", "--provider", "openai", "--model", "gpt-5", "--base-url", server.URL, "--api-key", "test-key", "fail")
148164 cmd.Env = orchestrationCLIEnv(binary, t.TempDir())
@@ -226,7 +242,7 @@ func (p *twoWaveProvider) serveHTTP(w http.ResponseWriter, r *http.Request) {
226242 writeText(w, fmt.Sprintf("worker result %d", worker))
227243 }
228244
229-func newCancellationProvider(t *testing.T) (*httptest.Server, <-chan struct{}, chan<- struct{}) {
245+func newCancellationProvider() (*httptest.Server, <-chan struct{}, chan<- struct{}) {
230246 workerReady := make(chan struct{})
231247 releaseWorker := make(chan struct{})
232248 var once sync.Once
@@ -250,7 +266,7 @@ func newCancellationProvider(t *testing.T) (*httptest.Server, <-chan struct{}, c
250266 return server, workerReady, releaseWorker
251267 }
252268
253-func newFailingProvider(t *testing.T) *httptest.Server {
269+func newFailingProvider() *httptest.Server {
254270 var mu sync.Mutex
255271 parentCalls := 0
256272 return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
@@ -388,18 +404,27 @@ func assertJSONOrchestrationWaves(t *testing.T, events []map[string]any) {
388404 }
389405
390406 func orchestrationCLIEnv(binary, home string) []string {
407+ // Restrict PATH so spawned workers resolve the same freshly built test
408+ // binary instead of any installed zut executable on the host.
391409 return append(os.Environ(), "ZUT_HOME="+home, "PATH="+filepath.Dir(binary))
392410 }
393411
394412 func buildOrchestrationCLI(t *testing.T) string {
395413 t.Helper()
396- out := filepath.Join(t.TempDir(), "zut")
397- root := filepath.Join("..", "..")
398- cmd := exec.Command("go", "build", "-o", out, "./cmd/zut")
399- cmd.Dir = root
400- cmd.Env = append(os.Environ(), "CGO_ENABLED=0")
401- if output, err := cmd.CombinedOutput(); err != nil {
402- t.Fatalf("build zut: %v\n%s", err, output)
414+ orchestrationBuildOnce.Do(func() {
415+ orchestrationBuildDir, orchestrationBuildErr = os.MkdirTemp("", "zut-orchestration-cli-")
416+ if orchestrationBuildErr != nil {
417+ return
418+ }
419+ orchestrationBuildBinary = filepath.Join(orchestrationBuildDir, "zut")
420+ root := filepath.Join("..", "..")
421+ cmd := exec.Command("go", "build", "-o", orchestrationBuildBinary, "./cmd/zut")
422+ cmd.Dir = root
423+ cmd.Env = append(os.Environ(), "CGO_ENABLED=0")
424+ orchestrationBuildOutput, orchestrationBuildErr = cmd.CombinedOutput()
425+ })
426+ if orchestrationBuildErr != nil {
427+ t.Fatalf("build zut: %v\n%s", orchestrationBuildErr, orchestrationBuildOutput)
403428 }
404- return out
429+ return orchestrationBuildBinary
405430 }
@@ -18,6 +18,22 @@ import (
18 "time"18 "time"
19 )19 )
20 20
21+var (
22+ orchestrationBuildOnce sync.Once
23+ orchestrationBuildDir string
24+ orchestrationBuildBinary string
25+ orchestrationBuildOutput []byte
26+ orchestrationBuildErr error
27+)
28+
29+func TestMain(m *testing.M) {
30+ code := m.Run()
31+ if orchestrationBuildDir != "" {
32+ _ = os.RemoveAll(orchestrationBuildDir)
33+ }
34+ os.Exit(code)
35+}
36+
21 func TestOrchestratedCLITwoDelegationWavesAcrossHeadlessModes(t *testing.T) {37 func TestOrchestratedCLITwoDelegationWavesAcrossHeadlessModes(t *testing.T) {
22 if runtime.GOOS == "windows" {38 if runtime.GOOS == "windows" {
23 t.Skip("signal and subprocess semantics differ on Windows")39 t.Skip("signal and subprocess semantics differ on Windows")
@@ -82,7 +98,7 @@ func TestOrchestratedCLICancellation(t *testing.T) {
82 t.Skip("skip CLI orchestration end-to-end test in -short mode")98 t.Skip("skip CLI orchestration end-to-end test in -short mode")
83 }99 }
84 binary := buildOrchestrationCLI(t)100 binary := buildOrchestrationCLI(t)
85- server, workerReady, releaseWorker := newCancellationProvider(t)101+ server, workerReady, releaseWorker := newCancellationProvider()
86 defer server.Close()102 defer server.Close()
87 defer close(releaseWorker)103 defer close(releaseWorker)
88 104
@@ -142,7 +158,7 @@ func TestOrchestratedCLIParentFailure(t *testing.T) {
142 binary := buildOrchestrationCLI(t)158 binary := buildOrchestrationCLI(t)
143 159
144 t.Run("failure-jsonl", func(t *testing.T) {160 t.Run("failure-jsonl", func(t *testing.T) {
145- server := newFailingProvider(t)161+ server := newFailingProvider()
146 defer server.Close()162 defer server.Close()
147 cmd := exec.Command(binary, "--orchestrate", "--json", "--provider", "openai", "--model", "gpt-5", "--base-url", server.URL, "--api-key", "test-key", "fail")163 cmd := exec.Command(binary, "--orchestrate", "--json", "--provider", "openai", "--model", "gpt-5", "--base-url", server.URL, "--api-key", "test-key", "fail")
148 cmd.Env = orchestrationCLIEnv(binary, t.TempDir())164 cmd.Env = orchestrationCLIEnv(binary, t.TempDir())
@@ -226,7 +242,7 @@ func (p *twoWaveProvider) serveHTTP(w http.ResponseWriter, r *http.Request) {
226 writeText(w, fmt.Sprintf("worker result %d", worker))242 writeText(w, fmt.Sprintf("worker result %d", worker))
227 }243 }
228 244
229-func newCancellationProvider(t *testing.T) (*httptest.Server, <-chan struct{}, chan<- struct{}) {245+func newCancellationProvider() (*httptest.Server, <-chan struct{}, chan<- struct{}) {
230 workerReady := make(chan struct{})246 workerReady := make(chan struct{})
231 releaseWorker := make(chan struct{})247 releaseWorker := make(chan struct{})
232 var once sync.Once248 var once sync.Once
@@ -250,7 +266,7 @@ func newCancellationProvider(t *testing.T) (*httptest.Server, <-chan struct{}, c
250 return server, workerReady, releaseWorker266 return server, workerReady, releaseWorker
251 }267 }
252 268
253-func newFailingProvider(t *testing.T) *httptest.Server {269+func newFailingProvider() *httptest.Server {
254 var mu sync.Mutex270 var mu sync.Mutex
255 parentCalls := 0271 parentCalls := 0
256 return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {272 return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
@@ -388,18 +404,27 @@ func assertJSONOrchestrationWaves(t *testing.T, events []map[string]any) {
388 }404 }
389 405
390 func orchestrationCLIEnv(binary, home string) []string {406 func orchestrationCLIEnv(binary, home string) []string {
407+ // Restrict PATH so spawned workers resolve the same freshly built test
408+ // binary instead of any installed zut executable on the host.
391 return append(os.Environ(), "ZUT_HOME="+home, "PATH="+filepath.Dir(binary))409 return append(os.Environ(), "ZUT_HOME="+home, "PATH="+filepath.Dir(binary))
392 }410 }
393 411
394 func buildOrchestrationCLI(t *testing.T) string {412 func buildOrchestrationCLI(t *testing.T) string {
395 t.Helper()413 t.Helper()
396- out := filepath.Join(t.TempDir(), "zut")414+ orchestrationBuildOnce.Do(func() {
397- root := filepath.Join("..", "..")415+ orchestrationBuildDir, orchestrationBuildErr = os.MkdirTemp("", "zut-orchestration-cli-")
398- cmd := exec.Command("go", "build", "-o", out, "./cmd/zut")416+ if orchestrationBuildErr != nil {
399- cmd.Dir = root417+ return
400- cmd.Env = append(os.Environ(), "CGO_ENABLED=0")418+ }
401- if output, err := cmd.CombinedOutput(); err != nil {419+ orchestrationBuildBinary = filepath.Join(orchestrationBuildDir, "zut")
402- t.Fatalf("build zut: %v\n%s", err, output)420+ root := filepath.Join("..", "..")
421+ cmd := exec.Command("go", "build", "-o", orchestrationBuildBinary, "./cmd/zut")
422+ cmd.Dir = root
423+ cmd.Env = append(os.Environ(), "CGO_ENABLED=0")
424+ orchestrationBuildOutput, orchestrationBuildErr = cmd.CombinedOutput()
425+ })
426+ if orchestrationBuildErr != nil {
427+ t.Fatalf("build zut: %v\n%s", orchestrationBuildErr, orchestrationBuildOutput)
403 }428 }
404- return out429+ return orchestrationBuildBinary
405 }430 }
M packages/agent/orchestration_loop.go +10 -4
@@ -2,6 +2,7 @@ package agent
22
33 import (
44 "context"
5+ "fmt"
56
67 "github.com/bnema/zut/packages/agent/subagents"
78 )
@@ -31,16 +32,20 @@ func (e *headlessCompletionWaitError) Unwrap() error {
3132 return e.err
3233 }
3334
34-const headlessCompletionInstruction = `Treat the completion reports below as evidence, including failures. Do not retry failed workers automatically. Decide whether another independent dependency wave is required; if so, use the manager tools and end or yield the turn again. Otherwise synthesize the final answer for the user. Do not mention this orchestration protocol unless it is relevant to the answer.`
35+const (
36+ headlessCompletionInstruction = `Treat the completion reports below as evidence, including failures. Do not retry failed workers automatically. Decide whether another independent dependency wave is required; if so, use the manager tools and end or yield the turn again. Otherwise synthesize the final answer for the user. Do not mention this orchestration protocol unless it is relevant to the answer.`
37+ maxHeadlessCompletionWaves = 32
38+)
3539
3640 // runHeadlessContinuation waits for one completion batch and gives the parent
3741 // one follow-up turn. A follow-up may register another batch while it runs,
3842 // which is how dependency waves continue without polling. The initial parent
3943 // turn is supplied by finalText; no worker path therefore returns immediately.
4044 func runHeadlessContinuation(ctx context.Context, tracker completionBatchWaiter, finalText string, runParent func(context.Context, string) (string, error)) (string, error) {
41- if tracker == nil {
45+ if tracker == nil || runParent == nil {
4246 return finalText, nil
4347 }
48+ waves := 0
4449 for {
4550 batch, err := tracker.WaitIdle(ctx)
4651 if err != nil {
@@ -49,13 +54,14 @@ func runHeadlessContinuation(ctx context.Context, tracker completionBatchWaiter,
4954 if len(batch) == 0 {
5055 return finalText, nil
5156 }
52- if runParent == nil {
53- return finalText, nil
57+ if waves >= maxHeadlessCompletionWaves {
58+ return finalText, fmt.Errorf("orchestration exceeded the maximum of %d completion waves", maxHeadlessCompletionWaves)
5459 }
5560 update := subagents.FormatCompletionUpdate(batch, headlessCompletionInstruction)
5661 finalText, err = runParent(ctx, update)
5762 if err != nil {
5863 return finalText, err
5964 }
65+ waves++
6066 }
6167 }
@@ -2,6 +2,7 @@ package agent
2 2
3 import (3 import (
4 "context"4 "context"
5+ "fmt"
5 6
6 "github.com/bnema/zut/packages/agent/subagents"7 "github.com/bnema/zut/packages/agent/subagents"
7 )8 )
@@ -31,16 +32,20 @@ func (e *headlessCompletionWaitError) Unwrap() error {
31 return e.err32 return e.err
32 }33 }
33 34
34-const headlessCompletionInstruction = `Treat the completion reports below as evidence, including failures. Do not retry failed workers automatically. Decide whether another independent dependency wave is required; if so, use the manager tools and end or yield the turn again. Otherwise synthesize the final answer for the user. Do not mention this orchestration protocol unless it is relevant to the answer.`35+const (
36+ headlessCompletionInstruction = `Treat the completion reports below as evidence, including failures. Do not retry failed workers automatically. Decide whether another independent dependency wave is required; if so, use the manager tools and end or yield the turn again. Otherwise synthesize the final answer for the user. Do not mention this orchestration protocol unless it is relevant to the answer.`
37+ maxHeadlessCompletionWaves = 32
38+)
35 39
36 // runHeadlessContinuation waits for one completion batch and gives the parent40 // runHeadlessContinuation waits for one completion batch and gives the parent
37 // one follow-up turn. A follow-up may register another batch while it runs,41 // one follow-up turn. A follow-up may register another batch while it runs,
38 // which is how dependency waves continue without polling. The initial parent42 // which is how dependency waves continue without polling. The initial parent
39 // turn is supplied by finalText; no worker path therefore returns immediately.43 // turn is supplied by finalText; no worker path therefore returns immediately.
40 func runHeadlessContinuation(ctx context.Context, tracker completionBatchWaiter, finalText string, runParent func(context.Context, string) (string, error)) (string, error) {44 func runHeadlessContinuation(ctx context.Context, tracker completionBatchWaiter, finalText string, runParent func(context.Context, string) (string, error)) (string, error) {
41- if tracker == nil {45+ if tracker == nil || runParent == nil {
42 return finalText, nil46 return finalText, nil
43 }47 }
48+ waves := 0
44 for {49 for {
45 batch, err := tracker.WaitIdle(ctx)50 batch, err := tracker.WaitIdle(ctx)
46 if err != nil {51 if err != nil {
@@ -49,13 +54,14 @@ func runHeadlessContinuation(ctx context.Context, tracker completionBatchWaiter,
49 if len(batch) == 0 {54 if len(batch) == 0 {
50 return finalText, nil55 return finalText, nil
51 }56 }
52- if runParent == nil {57+ if waves >= maxHeadlessCompletionWaves {
53- return finalText, nil58+ return finalText, fmt.Errorf("orchestration exceeded the maximum of %d completion waves", maxHeadlessCompletionWaves)
54 }59 }
55 update := subagents.FormatCompletionUpdate(batch, headlessCompletionInstruction)60 update := subagents.FormatCompletionUpdate(batch, headlessCompletionInstruction)
56 finalText, err = runParent(ctx, update)61 finalText, err = runParent(ctx, update)
57 if err != nil {62 if err != nil {
58 return finalText, err63 return finalText, err
59 }64 }
65+ waves++
60 }66 }
61 }67 }
M packages/agent/orchestration_loop_test.go +22 -0
@@ -15,6 +15,13 @@ type fakeCompletionBatches struct {
1515 waits int
1616 }
1717
18+type endlessCompletionBatches struct{ waits int }
19+
20+func (f *endlessCompletionBatches) WaitIdle(context.Context) ([]subagents.Completion, error) {
21+ f.waits++
22+ return []subagents.Completion{{AgentID: "worker", Status: "completed"}}, nil
23+}
24+
1825 func (f *fakeCompletionBatches) WaitIdle(context.Context) ([]subagents.Completion, error) {
1926 index := f.waits
2027 f.waits++
@@ -70,6 +77,21 @@ func TestRunHeadlessContinuationBatchesFailuresAndSecondWave(t *testing.T) {
7077 }
7178 }
7279
80+func TestRunHeadlessContinuationStopsAtWaveLimit(t *testing.T) {
81+ tracker := &endlessCompletionBatches{}
82+ calls := 0
83+ got, err := runHeadlessContinuation(context.Background(), tracker, "initial", func(context.Context, string) (string, error) {
84+ calls++
85+ return "synthesis", nil
86+ })
87+ if err == nil || !strings.Contains(err.Error(), "maximum of 32 completion waves") {
88+ t.Fatalf("error = %v, want wave-limit error", err)
89+ }
90+ if got != "synthesis" || calls != maxHeadlessCompletionWaves || tracker.waits != maxHeadlessCompletionWaves+1 {
91+ t.Fatalf("result=%q calls=%d waits=%d", got, calls, tracker.waits)
92+ }
93+}
94+
7395 func TestRunHeadlessContinuationCancellation(t *testing.T) {
7496 ctx, cancel := context.WithCancel(context.Background())
7597 cancel()
@@ -15,6 +15,13 @@ type fakeCompletionBatches struct {
15 waits int15 waits int
16 }16 }
17 17
18+type endlessCompletionBatches struct{ waits int }
19+
20+func (f *endlessCompletionBatches) WaitIdle(context.Context) ([]subagents.Completion, error) {
21+ f.waits++
22+ return []subagents.Completion{{AgentID: "worker", Status: "completed"}}, nil
23+}
24+
18 func (f *fakeCompletionBatches) WaitIdle(context.Context) ([]subagents.Completion, error) {25 func (f *fakeCompletionBatches) WaitIdle(context.Context) ([]subagents.Completion, error) {
19 index := f.waits26 index := f.waits
20 f.waits++27 f.waits++
@@ -70,6 +77,21 @@ func TestRunHeadlessContinuationBatchesFailuresAndSecondWave(t *testing.T) {
70 }77 }
71 }78 }
72 79
80+func TestRunHeadlessContinuationStopsAtWaveLimit(t *testing.T) {
81+ tracker := &endlessCompletionBatches{}
82+ calls := 0
83+ got, err := runHeadlessContinuation(context.Background(), tracker, "initial", func(context.Context, string) (string, error) {
84+ calls++
85+ return "synthesis", nil
86+ })
87+ if err == nil || !strings.Contains(err.Error(), "maximum of 32 completion waves") {
88+ t.Fatalf("error = %v, want wave-limit error", err)
89+ }
90+ if got != "synthesis" || calls != maxHeadlessCompletionWaves || tracker.waits != maxHeadlessCompletionWaves+1 {
91+ t.Fatalf("result=%q calls=%d waits=%d", got, calls, tracker.waits)
92+ }
93+}
94+
73 func TestRunHeadlessContinuationCancellation(t *testing.T) {95 func TestRunHeadlessContinuationCancellation(t *testing.T) {
74 ctx, cancel := context.WithCancel(context.Background())96 ctx, cancel := context.WithCancel(context.Background())
75 cancel()97 cancel()
M packages/agent/orchestration_modes.go +3 -0
@@ -190,6 +190,9 @@ func newOrchestratedRuntime(ctx context.Context, args Args, r Resolved, cfg Conf
190190 tracker.TrackTurn(a, task, false)
191191 }
192192 onResumed := func(a *subagents.Agent, prompt string) {
193+ // BeforeResumed normally owns future-turn registration. Keep this
194+ // fallback for direct runtime callers that do not install the pre-hook;
195+ // SubagentResumeTool suppresses it when BeforeResumed accepted delivery.
193196 tracker.TrackTurn(a, prompt, true)
194197 }
195198 onBeforeResumed := func(a *subagents.Agent, prompt string) func() {
@@ -190,6 +190,9 @@ func newOrchestratedRuntime(ctx context.Context, args Args, r Resolved, cfg Conf
190 tracker.TrackTurn(a, task, false)190 tracker.TrackTurn(a, task, false)
191 }191 }
192 onResumed := func(a *subagents.Agent, prompt string) {192 onResumed := func(a *subagents.Agent, prompt string) {
193+ // BeforeResumed normally owns future-turn registration. Keep this
194+ // fallback for direct runtime callers that do not install the pre-hook;
195+ // SubagentResumeTool suppresses it when BeforeResumed accepted delivery.
193 tracker.TrackTurn(a, prompt, true)196 tracker.TrackTurn(a, prompt, true)
194 }197 }
195 onBeforeResumed := func(a *subagents.Agent, prompt string) func() {198 onBeforeResumed := func(a *subagents.Agent, prompt string) func() {
M packages/agent/orchestration_modes_test.go +58 -46
@@ -4,6 +4,7 @@ import (
44 "bufio"
55 "context"
66 "encoding/json"
7+ "errors"
78 "io"
89 "net/http"
910 "net/http/httptest"
@@ -18,32 +19,21 @@ func TestRunOrchestratedStreamUsesStreamRenderer(t *testing.T) {
1819 server := orchestratedModeTestServer("stream answer")
1920 defer server.Close()
2021
21- oldStdout := os.Stdout
22- reader, writer, err := os.Pipe()
23- if err != nil {
24- t.Fatal(err)
25- }
26- os.Stdout = writer
27- runErr := runOrchestratedStreamMode(context.Background(), Args{
28- Mode: ModeStream,
29- Orchestrate: true,
30- Provider: "openai",
31- Model: "gpt-5",
32- BaseURL: server.URL,
33- APIKey: "test-key",
34- Prompt: "say hello",
35- }, "test")
36- _ = writer.Close()
37- os.Stdout = oldStdout
38- output, readErr := io.ReadAll(reader)
39- _ = reader.Close()
22+ output, runErr := captureTestStdout(t, func() error {
23+ return runOrchestratedStreamMode(context.Background(), Args{
24+ Mode: ModeStream,
25+ Orchestrate: true,
26+ Provider: "openai",
27+ Model: "gpt-5",
28+ BaseURL: server.URL,
29+ APIKey: "test-key",
30+ Prompt: "say hello",
31+ }, "test")
32+ })
4033 if runErr != nil {
4134 t.Fatalf("runOrchestratedStreamMode error: %v", runErr)
4235 }
43- if readErr != nil {
44- t.Fatal(readErr)
45- }
46- if got := string(output); got != "stream answer\n" {
36+ if got := output; got != "stream answer\n" {
4737 t.Fatalf("stdout = %q, want one rendered answer newline", got)
4838 }
4939 }
@@ -54,34 +44,23 @@ func TestRunOrchestratedJSONRetainsParentEventsAsJSONL(t *testing.T) {
5444 server := orchestratedModeTestServer("json answer")
5545 defer server.Close()
5646
57- oldStdout := os.Stdout
58- reader, writer, err := os.Pipe()
59- if err != nil {
60- t.Fatal(err)
61- }
62- os.Stdout = writer
63- runErr := runOrchestratedJSONMode(context.Background(), Args{
64- Mode: ModeJSON,
65- Orchestrate: true,
66- Provider: "openai",
67- Model: "gpt-5",
68- BaseURL: server.URL,
69- APIKey: "test-key",
70- Prompt: "say hello",
71- }, "test")
72- _ = writer.Close()
73- os.Stdout = oldStdout
74- output, readErr := io.ReadAll(reader)
75- _ = reader.Close()
47+ output, runErr := captureTestStdout(t, func() error {
48+ return runOrchestratedJSONMode(context.Background(), Args{
49+ Mode: ModeJSON,
50+ Orchestrate: true,
51+ Provider: "openai",
52+ Model: "gpt-5",
53+ BaseURL: server.URL,
54+ APIKey: "test-key",
55+ Prompt: "say hello",
56+ }, "test")
57+ })
7658 if runErr != nil {
7759 t.Fatalf("runOrchestratedJSONMode error: %v", runErr)
7860 }
79- if readErr != nil {
80- t.Fatal(readErr)
81- }
8261
8362 seen := map[string]bool{}
84- scanner := bufio.NewScanner(strings.NewReader(string(output)))
63+ scanner := bufio.NewScanner(strings.NewReader(output))
8564 for scanner.Scan() {
8665 var event map[string]any
8766 if err := json.Unmarshal(scanner.Bytes(), &event); err != nil {
@@ -101,6 +80,39 @@ func TestRunOrchestratedJSONRetainsParentEventsAsJSONL(t *testing.T) {
10180 }
10281 }
10382
83+func captureTestStdout(t *testing.T, run func() error) (string, error) {
84+ t.Helper()
85+ oldStdout := os.Stdout
86+ reader, writer, err := os.Pipe()
87+ if err != nil {
88+ return "", err
89+ }
90+ os.Stdout = writer
91+ t.Cleanup(func() {
92+ os.Stdout = oldStdout
93+ _ = writer.Close()
94+ _ = reader.Close()
95+ })
96+ readDone := make(chan struct {
97+ output []byte
98+ err error
99+ }, 1)
100+ go func() {
101+ output, readErr := io.ReadAll(reader)
102+ readDone <- struct {
103+ output []byte
104+ err error
105+ }{output: output, err: readErr}
106+ }()
107+
108+ runErr := run()
109+ _ = writer.Close()
110+ os.Stdout = oldStdout
111+ result := <-readDone
112+ _ = reader.Close()
113+ return string(result.output), errors.Join(runErr, result.err)
114+}
115+
104116 func orchestratedModeTestServer(answer string) *httptest.Server {
105117 return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
106118 w.Header().Set("content-type", "text/event-stream")
@@ -4,6 +4,7 @@ import (
4 "bufio"4 "bufio"
5 "context"5 "context"
6 "encoding/json"6 "encoding/json"
7+ "errors"
7 "io"8 "io"
8 "net/http"9 "net/http"
9 "net/http/httptest"10 "net/http/httptest"
@@ -18,32 +19,21 @@ func TestRunOrchestratedStreamUsesStreamRenderer(t *testing.T) {
18 server := orchestratedModeTestServer("stream answer")19 server := orchestratedModeTestServer("stream answer")
19 defer server.Close()20 defer server.Close()
20 21
21- oldStdout := os.Stdout22+ output, runErr := captureTestStdout(t, func() error {
22- reader, writer, err := os.Pipe()23+ return runOrchestratedStreamMode(context.Background(), Args{
23- if err != nil {24+ Mode: ModeStream,
24- t.Fatal(err)25+ Orchestrate: true,
25- }26+ Provider: "openai",
26- os.Stdout = writer27+ Model: "gpt-5",
27- runErr := runOrchestratedStreamMode(context.Background(), Args{28+ BaseURL: server.URL,
28- Mode: ModeStream,29+ APIKey: "test-key",
29- Orchestrate: true,30+ Prompt: "say hello",
30- Provider: "openai",31+ }, "test")
31- Model: "gpt-5",32+ })
32- BaseURL: server.URL,
33- APIKey: "test-key",
34- Prompt: "say hello",
35- }, "test")
36- _ = writer.Close()
37- os.Stdout = oldStdout
38- output, readErr := io.ReadAll(reader)
39- _ = reader.Close()
40 if runErr != nil {33 if runErr != nil {
41 t.Fatalf("runOrchestratedStreamMode error: %v", runErr)34 t.Fatalf("runOrchestratedStreamMode error: %v", runErr)
42 }35 }
43- if readErr != nil {36+ if got := output; got != "stream answer\n" {
44- t.Fatal(readErr)
45- }
46- if got := string(output); got != "stream answer\n" {
47 t.Fatalf("stdout = %q, want one rendered answer newline", got)37 t.Fatalf("stdout = %q, want one rendered answer newline", got)
48 }38 }
49 }39 }
@@ -54,34 +44,23 @@ func TestRunOrchestratedJSONRetainsParentEventsAsJSONL(t *testing.T) {
54 server := orchestratedModeTestServer("json answer")44 server := orchestratedModeTestServer("json answer")
55 defer server.Close()45 defer server.Close()
56 46
57- oldStdout := os.Stdout47+ output, runErr := captureTestStdout(t, func() error {
58- reader, writer, err := os.Pipe()48+ return runOrchestratedJSONMode(context.Background(), Args{
59- if err != nil {49+ Mode: ModeJSON,
60- t.Fatal(err)50+ Orchestrate: true,
61- }51+ Provider: "openai",
62- os.Stdout = writer52+ Model: "gpt-5",
63- runErr := runOrchestratedJSONMode(context.Background(), Args{53+ BaseURL: server.URL,
64- Mode: ModeJSON,54+ APIKey: "test-key",
65- Orchestrate: true,55+ Prompt: "say hello",
66- Provider: "openai",56+ }, "test")
67- Model: "gpt-5",57+ })
68- BaseURL: server.URL,
69- APIKey: "test-key",
70- Prompt: "say hello",
71- }, "test")
72- _ = writer.Close()
73- os.Stdout = oldStdout
74- output, readErr := io.ReadAll(reader)
75- _ = reader.Close()
76 if runErr != nil {58 if runErr != nil {
77 t.Fatalf("runOrchestratedJSONMode error: %v", runErr)59 t.Fatalf("runOrchestratedJSONMode error: %v", runErr)
78 }60 }
79- if readErr != nil {
80- t.Fatal(readErr)
81- }
82 61
83 seen := map[string]bool{}62 seen := map[string]bool{}
84- scanner := bufio.NewScanner(strings.NewReader(string(output)))63+ scanner := bufio.NewScanner(strings.NewReader(output))
85 for scanner.Scan() {64 for scanner.Scan() {
86 var event map[string]any65 var event map[string]any
87 if err := json.Unmarshal(scanner.Bytes(), &event); err != nil {66 if err := json.Unmarshal(scanner.Bytes(), &event); err != nil {
@@ -101,6 +80,39 @@ func TestRunOrchestratedJSONRetainsParentEventsAsJSONL(t *testing.T) {
101 }80 }
102 }81 }
103 82
83+func captureTestStdout(t *testing.T, run func() error) (string, error) {
84+ t.Helper()
85+ oldStdout := os.Stdout
86+ reader, writer, err := os.Pipe()
87+ if err != nil {
88+ return "", err
89+ }
90+ os.Stdout = writer
91+ t.Cleanup(func() {
92+ os.Stdout = oldStdout
93+ _ = writer.Close()
94+ _ = reader.Close()
95+ })
96+ readDone := make(chan struct {
97+ output []byte
98+ err error
99+ }, 1)
100+ go func() {
101+ output, readErr := io.ReadAll(reader)
102+ readDone <- struct {
103+ output []byte
104+ err error
105+ }{output: output, err: readErr}
106+ }()
107+
108+ runErr := run()
109+ _ = writer.Close()
110+ os.Stdout = oldStdout
111+ result := <-readDone
112+ _ = reader.Close()
113+ return string(result.output), errors.Join(runErr, result.err)
114+}
115+
104 func orchestratedModeTestServer(answer string) *httptest.Server {116 func orchestratedModeTestServer(answer string) *httptest.Server {
105 return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {117 return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
106 w.Header().Set("content-type", "text/event-stream")118 w.Header().Set("content-type", "text/event-stream")
M packages/agent/orchestration_print.go +15 -169
@@ -3,16 +3,11 @@ package agent
33 import (
44 "bytes"
55 "context"
6- "errors"
76 "fmt"
87 "os"
9- "os/signal"
10- "path/filepath"
118 "strings"
12- "syscall"
139
1410 "github.com/bnema/zut/packages/agent/modes"
15- "github.com/bnema/zut/packages/agent/subagents"
1611 "github.com/bnema/zut/packages/core"
1712 "github.com/bnema/zut/packages/provider"
1813 )
@@ -20,169 +15,20 @@ import (
2015 // runOrchestratedPrintMode is deliberately separate from ordinary print mode:
2116 // ordinary print continues to stream its existing one-turn implementation,
2217 // while this path keeps every parent turn in memory until terminal synthesis.
23-func runOrchestratedPrintMode(parentCtx context.Context, args Args, version string) (runErr error) {
24- ctx, stopSignal := signal.NotifyContext(parentCtx, os.Interrupt, syscall.SIGTERM, syscall.SIGHUP)
25- defer stopSignal()
26-
27- if args.NoYolo {
28- fmt.Fprintln(os.Stderr, "warning: --no-yolo has no effect in print mode (no interactive prompt available); tools will run without confirmation")
29- }
30- r, err := Resolve(args, true)
31- if err != nil {
32- return err
33- }
34- initialCfg, err := LoadConfig()
35- if err != nil {
36- return fmt.Errorf("load config for orchestrated print: %w", err)
37- }
38- prompt := args.Prompt
39- if prompt == "" {
40- piped, readErr := readAllStdin()
41- if readErr != nil {
42- return fmt.Errorf("read print prompt from stdin: %w", readErr)
43- }
44- prompt = strings.TrimSpace(piped)
45- }
46- if prompt == "" {
47- return fmt.Errorf("print mode requires a prompt (arg or stdin)")
48- }
49- extMgr, stopExt := setupNonInteractiveExtensions(ctx, args, &r, version)
50- defer stopExt()
51-
52- tracker := subagents.NewCompletionTracker()
53- onSpawned := func(a *subagents.Agent, task string) {
54- tracker.TrackTurn(a, task, false)
55- }
56- onResumed := func(a *subagents.Agent, prompt string) {
57- // BeforeResumed owns the strict future-turn registration. Keep this
58- // post-delivery callback for the same lifecycle semantics as the
59- // interactive flow; SubagentResumeTool suppresses it when the pre-hook
60- // is present so it cannot register a duplicate completion.
61- tracker.TrackTurn(a, prompt, true)
62- }
63- onBeforeResumed := func(a *subagents.Agent, prompt string) func() {
64- return tracker.TrackFutureTurn(a, prompt, true)
65- }
66- onStopRequested := func(a *subagents.Agent) {
67- tracker.TrackExit(a, "stopped")
68- }
69- runtime := newSubagentRuntime(subagentRuntimeConfig{
70- Context: ctx,
71- Args: args,
72- Root: filepath.Join(ZutHome(), "subagents"),
73- RepoRoot: r.CWD,
74- Provider: r.Provider,
75- Model: r.Model,
76- Reasoning: r.Reasoning,
77- BaseURL: r.BaseURL,
78- InsecureTLS: r.InsecureTLS,
79- FastMode: r.FastMode,
80- APIKey: args.APIKey,
81- Policy: subagentPolicyFromConfig(initialCfg.Subagents),
82- WebSearchPolicy: webSearchPolicyForRegistry(r.WebSearchPolicy, r.ToolRegistry),
83- OnSpawned: onSpawned,
84- OnResumed: onResumed,
85- BeforeResumed: onBeforeResumed,
86- OnStopRequested: onStopRequested,
87- })
88- defer func() {
89- if closeErr := closeSubagentRuntimeFresh(runtime); closeErr != nil {
90- runErr = errors.Join(runErr, closeErr)
91- }
92- }()
93-
94- prepareRegistry := func(reg core.Registry) core.Registry {
95- return runtime.PrepareRegistry(reg)
96- }
97- runtime.SetProvider(r.Provider)
98- runtime.SetProviderSettings(r.BaseURL, r.InsecureTLS)
99- runtime.SetFastMode(r.FastMode)
100- runtime.PrepareResolvedRegistry(r.ToolRegistry, r.WebSearchPolicy)
101- ag := r.NewAgent()
102- initialAg := ag
103- defer func() {
104- closeAgentLSP(ag)
105- if ag != initialAg {
106- closeAgentLSP(initialAg)
107- }
108- }()
109- wireNonInteractiveAgentExtHooks(ctx, ag, extMgr)
110-
111- sess, err := openOrCreateSession(ctx, args, r, ag, version)
112- if err != nil {
113- return err
114- }
115- if sess != nil {
116- defer func() { runErr = joinSessionCloseError(runErr, sess) }()
117- var providerName, model string
118- sess, ag, providerName, model, err = applyInitialSessionResumeWithRuntime(ctx, args, r, extMgr, sess, ag, runtime)
119- if err != nil {
18+func runOrchestratedPrintMode(ctx context.Context, args Args, version string) error {
19+ return runOrchestratedMode(ctx, args, version, orchestratedModeHooks{
20+ promptLabel: "print",
21+ startupSink: func() (func(core.AgentEvent), func()) {
22+ return nil, func() {}
23+ },
24+ runPrimary: func(turnCtx context.Context, ag *core.Agent, prompt string, persist func([]provider.Message) error) (string, modes.ContextRecoveryResult, error) {
25+ var captured bytes.Buffer
26+ _, recovery, err := modes.RunPrintWithContextRecovery(turnCtx, ag, prompt, nil, &captured, persist)
27+ return strings.TrimSuffix(captured.String(), "\n"), recovery, err
28+ },
29+ finish: func(finalText string) error {
30+ _, err := fmt.Fprintln(os.Stdout, finalText)
12031 return err
121- }
122- r.Provider, r.Model = providerName, model
123- ag.OnToolResult = func(_ string, result core.ToolResult) { persistExtensionToolResult(extMgr, sess, result) }
124- runtime.SetActiveSession(sess.ID)
125- }
126- announceSession(extMgr, sess)
127-
128- start := len(ag.Messages())
129- if err := runZutfileStartupPre(ctx, args.StartupPre, r.CWD, r.Sandbox, ag, nil, os.Stderr); err != nil {
130- return err
131- }
132- if strings.TrimSpace(args.StartupPre) != "" {
133- refreshedPolicy, refreshErr := reloadResourcesAfterStartupPreWithRegistry(ctx, args, extMgr, r.Sandbox, ag, prepareRegistry)
134- if refreshErr != nil {
135- return refreshErr
136- }
137- // entry.pre can change the resolved PermissionSet. Apply that effective
138- // policy before any child launch, not only to the parent's registry.
139- runtime.SetWebSearchPolicy(refreshedPolicy)
140- }
141- var usagePersistErr error
142- if sess != nil {
143- ag.OnUsage = func(cumulative provider.Usage) {
144- if usagePersistErr == nil {
145- usagePersistErr = sess.AppendUsage(ag.LastTurnUsage(), cumulative)
146- }
147- }
148- }
149- var persistCompaction func([]provider.Message) error
150- if sess != nil {
151- persistCompaction = func(messages []provider.Message) error {
152- return sess.AppendCompactionWithUsage(messages, ag.Cost())
153- }
154- }
155-
156- var totalUsage provider.Usage
157- transcriptStart := start
158- runParent := func(turnCtx context.Context, text string) (string, error) {
159- var captured bytes.Buffer
160- usage, recovery, turnErr := modes.RunPrintWithContextRecovery(turnCtx, ag, text, nil, &captured, persistCompaction)
161- totalUsage = totalUsage.Add(usage)
162- if recovery.Compacted {
163- transcriptStart = recovery.OutputStart
164- }
165- if usagePersistErr != nil {
166- return captured.String(), usagePersistErr
167- }
168- if turnErr != nil {
169- return captured.String(), turnErr
170- }
171- return strings.TrimSuffix(captured.String(), "\n"), nil
172- }
173-
174- finalText, err := runParent(ctx, prompt)
175- if err == nil {
176- finalText, err = runHeadlessContinuation(ctx, tracker, finalText, runParent)
177- }
178- if persistErr := WriteNewTranscript(ag, sess, transcriptStart); persistErr != nil {
179- err = errors.Join(err, persistErr)
180- }
181- if err != nil {
182- return err
183- }
184- if _, err := fmt.Fprintln(os.Stdout, finalText); err != nil {
185- return err
186- }
187- return nil
32+ },
33+ })
18834 }
@@ -3,16 +3,11 @@ package agent
3 import (3 import (
4 "bytes"4 "bytes"
5 "context"5 "context"
6- "errors"
7 "fmt"6 "fmt"
8 "os"7 "os"
9- "os/signal"
10- "path/filepath"
11 "strings"8 "strings"
12- "syscall"
13 9
14 "github.com/bnema/zut/packages/agent/modes"10 "github.com/bnema/zut/packages/agent/modes"
15- "github.com/bnema/zut/packages/agent/subagents"
16 "github.com/bnema/zut/packages/core"11 "github.com/bnema/zut/packages/core"
17 "github.com/bnema/zut/packages/provider"12 "github.com/bnema/zut/packages/provider"
18 )13 )
@@ -20,169 +15,20 @@ import (
20 // runOrchestratedPrintMode is deliberately separate from ordinary print mode:15 // runOrchestratedPrintMode is deliberately separate from ordinary print mode:
21 // ordinary print continues to stream its existing one-turn implementation,16 // ordinary print continues to stream its existing one-turn implementation,
22 // while this path keeps every parent turn in memory until terminal synthesis.17 // while this path keeps every parent turn in memory until terminal synthesis.
23-func runOrchestratedPrintMode(parentCtx context.Context, args Args, version string) (runErr error) {18+func runOrchestratedPrintMode(ctx context.Context, args Args, version string) error {
24- ctx, stopSignal := signal.NotifyContext(parentCtx, os.Interrupt, syscall.SIGTERM, syscall.SIGHUP)19+ return runOrchestratedMode(ctx, args, version, orchestratedModeHooks{
25- defer stopSignal()20+ promptLabel: "print",
26-21+ startupSink: func() (func(core.AgentEvent), func()) {
27- if args.NoYolo {22+ return nil, func() {}
28- fmt.Fprintln(os.Stderr, "warning: --no-yolo has no effect in print mode (no interactive prompt available); tools will run without confirmation")23+ },
29- }24+ runPrimary: func(turnCtx context.Context, ag *core.Agent, prompt string, persist func([]provider.Message) error) (string, modes.ContextRecoveryResult, error) {
30- r, err := Resolve(args, true)25+ var captured bytes.Buffer
31- if err != nil {26+ _, recovery, err := modes.RunPrintWithContextRecovery(turnCtx, ag, prompt, nil, &captured, persist)
32- return err27+ return strings.TrimSuffix(captured.String(), "\n"), recovery, err
33- }28+ },
34- initialCfg, err := LoadConfig()29+ finish: func(finalText string) error {
35- if err != nil {30+ _, err := fmt.Fprintln(os.Stdout, finalText)
36- return fmt.Errorf("load config for orchestrated print: %w", err)
37- }
38- prompt := args.Prompt
39- if prompt == "" {
40- piped, readErr := readAllStdin()
41- if readErr != nil {
42- return fmt.Errorf("read print prompt from stdin: %w", readErr)
43- }
44- prompt = strings.TrimSpace(piped)
45- }
46- if prompt == "" {
47- return fmt.Errorf("print mode requires a prompt (arg or stdin)")
48- }
49- extMgr, stopExt := setupNonInteractiveExtensions(ctx, args, &r, version)
50- defer stopExt()
51-
52- tracker := subagents.NewCompletionTracker()
53- onSpawned := func(a *subagents.Agent, task string) {
54- tracker.TrackTurn(a, task, false)
55- }
56- onResumed := func(a *subagents.Agent, prompt string) {
57- // BeforeResumed owns the strict future-turn registration. Keep this
58- // post-delivery callback for the same lifecycle semantics as the
59- // interactive flow; SubagentResumeTool suppresses it when the pre-hook
60- // is present so it cannot register a duplicate completion.
61- tracker.TrackTurn(a, prompt, true)
62- }
63- onBeforeResumed := func(a *subagents.Agent, prompt string) func() {
64- return tracker.TrackFutureTurn(a, prompt, true)
65- }
66- onStopRequested := func(a *subagents.Agent) {
67- tracker.TrackExit(a, "stopped")
68- }
69- runtime := newSubagentRuntime(subagentRuntimeConfig{
70- Context: ctx,
71- Args: args,
72- Root: filepath.Join(ZutHome(), "subagents"),
73- RepoRoot: r.CWD,
74- Provider: r.Provider,
75- Model: r.Model,
76- Reasoning: r.Reasoning,
77- BaseURL: r.BaseURL,
78- InsecureTLS: r.InsecureTLS,
79- FastMode: r.FastMode,
80- APIKey: args.APIKey,
81- Policy: subagentPolicyFromConfig(initialCfg.Subagents),
82- WebSearchPolicy: webSearchPolicyForRegistry(r.WebSearchPolicy, r.ToolRegistry),
83- OnSpawned: onSpawned,
84- OnResumed: onResumed,
85- BeforeResumed: onBeforeResumed,
86- OnStopRequested: onStopRequested,
87- })
88- defer func() {
89- if closeErr := closeSubagentRuntimeFresh(runtime); closeErr != nil {
90- runErr = errors.Join(runErr, closeErr)
91- }
92- }()
93-
94- prepareRegistry := func(reg core.Registry) core.Registry {
95- return runtime.PrepareRegistry(reg)
96- }
97- runtime.SetProvider(r.Provider)
98- runtime.SetProviderSettings(r.BaseURL, r.InsecureTLS)
99- runtime.SetFastMode(r.FastMode)
100- runtime.PrepareResolvedRegistry(r.ToolRegistry, r.WebSearchPolicy)
101- ag := r.NewAgent()
102- initialAg := ag
103- defer func() {
104- closeAgentLSP(ag)
105- if ag != initialAg {
106- closeAgentLSP(initialAg)
107- }
108- }()
109- wireNonInteractiveAgentExtHooks(ctx, ag, extMgr)
110-
111- sess, err := openOrCreateSession(ctx, args, r, ag, version)
112- if err != nil {
113- return err
114- }
115- if sess != nil {
116- defer func() { runErr = joinSessionCloseError(runErr, sess) }()
117- var providerName, model string
118- sess, ag, providerName, model, err = applyInitialSessionResumeWithRuntime(ctx, args, r, extMgr, sess, ag, runtime)
119- if err != nil {
120 return err31 return err
121- }32+ },
122- r.Provider, r.Model = providerName, model33+ })
123- ag.OnToolResult = func(_ string, result core.ToolResult) { persistExtensionToolResult(extMgr, sess, result) }
124- runtime.SetActiveSession(sess.ID)
125- }
126- announceSession(extMgr, sess)
127-
128- start := len(ag.Messages())
129- if err := runZutfileStartupPre(ctx, args.StartupPre, r.CWD, r.Sandbox, ag, nil, os.Stderr); err != nil {
130- return err
131- }
132- if strings.TrimSpace(args.StartupPre) != "" {
133- refreshedPolicy, refreshErr := reloadResourcesAfterStartupPreWithRegistry(ctx, args, extMgr, r.Sandbox, ag, prepareRegistry)
134- if refreshErr != nil {
135- return refreshErr
136- }
137- // entry.pre can change the resolved PermissionSet. Apply that effective
138- // policy before any child launch, not only to the parent's registry.
139- runtime.SetWebSearchPolicy(refreshedPolicy)
140- }
141- var usagePersistErr error
142- if sess != nil {
143- ag.OnUsage = func(cumulative provider.Usage) {
144- if usagePersistErr == nil {
145- usagePersistErr = sess.AppendUsage(ag.LastTurnUsage(), cumulative)
146- }
147- }
148- }
149- var persistCompaction func([]provider.Message) error
150- if sess != nil {
151- persistCompaction = func(messages []provider.Message) error {
152- return sess.AppendCompactionWithUsage(messages, ag.Cost())
153- }
154- }
155-
156- var totalUsage provider.Usage
157- transcriptStart := start
158- runParent := func(turnCtx context.Context, text string) (string, error) {
159- var captured bytes.Buffer
160- usage, recovery, turnErr := modes.RunPrintWithContextRecovery(turnCtx, ag, text, nil, &captured, persistCompaction)
161- totalUsage = totalUsage.Add(usage)
162- if recovery.Compacted {
163- transcriptStart = recovery.OutputStart
164- }
165- if usagePersistErr != nil {
166- return captured.String(), usagePersistErr
167- }
168- if turnErr != nil {
169- return captured.String(), turnErr
170- }
171- return strings.TrimSuffix(captured.String(), "\n"), nil
172- }
173-
174- finalText, err := runParent(ctx, prompt)
175- if err == nil {
176- finalText, err = runHeadlessContinuation(ctx, tracker, finalText, runParent)
177- }
178- if persistErr := WriteNewTranscript(ag, sess, transcriptStart); persistErr != nil {
179- err = errors.Join(err, persistErr)
180- }
181- if err != nil {
182- return err
183- }
184- if _, err := fmt.Fprintln(os.Stdout, finalText); err != nil {
185- return err
186- }
187- return nil
188 }34 }
M packages/agent/orchestration_print_test.go +13 -31
@@ -2,9 +2,6 @@ package agent
22
33 import (
44 "context"
5- "io"
6- "net/http"
7- "net/http/httptest"
85 "os"
96 "strings"
107 "testing"
@@ -13,39 +10,24 @@ import (
1310 func TestRunOrchestratedPrintEmitsOnlyFinalAnswer(t *testing.T) {
1411 home := t.TempDir()
1512 t.Setenv("ZUT_HOME", home)
16- server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
17- w.Header().Set("content-type", "text/event-stream")
18- _, _ = io.WriteString(w, "data: {\"choices\":[{\"index\":0,\"delta\":{\"content\":\"final answer\"},\"finish_reason\":\"stop\"}]}\n\n")
19- _, _ = io.WriteString(w, "data: [DONE]\n\n")
20- }))
13+ server := orchestratedModeTestServer("final answer")
2114 defer server.Close()
2215
23- oldStdout := os.Stdout
24- reader, writer, err := os.Pipe()
25- if err != nil {
26- t.Fatal(err)
27- }
28- os.Stdout = writer
29- runErr := runOrchestratedPrintMode(context.Background(), Args{
30- Mode: ModePrint,
31- Orchestrate: true,
32- Provider: "openai",
33- Model: "gpt-5",
34- BaseURL: server.URL,
35- APIKey: "test-key",
36- Prompt: "say hello",
37- }, "test")
38- _ = writer.Close()
39- os.Stdout = oldStdout
40- output, readErr := io.ReadAll(reader)
41- _ = reader.Close()
16+ output, runErr := captureTestStdout(t, func() error {
17+ return runOrchestratedPrintMode(context.Background(), Args{
18+ Mode: ModePrint,
19+ Orchestrate: true,
20+ Provider: "openai",
21+ Model: "gpt-5",
22+ BaseURL: server.URL,
23+ APIKey: "test-key",
24+ Prompt: "say hello",
25+ }, "test")
26+ })
4227 if runErr != nil {
4328 t.Fatalf("runOrchestratedPrintMode error: %v", runErr)
4429 }
45- if readErr != nil {
46- t.Fatal(readErr)
47- }
48- if got := string(output); got != "final answer\n" {
30+ if got := output; got != "final answer\n" {
4931 t.Fatalf("stdout = %q, want final answer only", got)
5032 }
5133 }
@@ -2,9 +2,6 @@ package agent
2 2
3 import (3 import (
4 "context"4 "context"
5- "io"
6- "net/http"
7- "net/http/httptest"
8 "os"5 "os"
9 "strings"6 "strings"
10 "testing"7 "testing"
@@ -13,39 +10,24 @@ import (
13 func TestRunOrchestratedPrintEmitsOnlyFinalAnswer(t *testing.T) {10 func TestRunOrchestratedPrintEmitsOnlyFinalAnswer(t *testing.T) {
14 home := t.TempDir()11 home := t.TempDir()
15 t.Setenv("ZUT_HOME", home)12 t.Setenv("ZUT_HOME", home)
16- server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {13+ server := orchestratedModeTestServer("final answer")
17- w.Header().Set("content-type", "text/event-stream")
18- _, _ = io.WriteString(w, "data: {\"choices\":[{\"index\":0,\"delta\":{\"content\":\"final answer\"},\"finish_reason\":\"stop\"}]}\n\n")
19- _, _ = io.WriteString(w, "data: [DONE]\n\n")
20- }))
21 defer server.Close()14 defer server.Close()
22 15
23- oldStdout := os.Stdout16+ output, runErr := captureTestStdout(t, func() error {
24- reader, writer, err := os.Pipe()17+ return runOrchestratedPrintMode(context.Background(), Args{
25- if err != nil {18+ Mode: ModePrint,
26- t.Fatal(err)19+ Orchestrate: true,
27- }20+ Provider: "openai",
28- os.Stdout = writer21+ Model: "gpt-5",
29- runErr := runOrchestratedPrintMode(context.Background(), Args{22+ BaseURL: server.URL,
30- Mode: ModePrint,23+ APIKey: "test-key",
31- Orchestrate: true,24+ Prompt: "say hello",
32- Provider: "openai",25+ }, "test")
33- Model: "gpt-5",26+ })
34- BaseURL: server.URL,
35- APIKey: "test-key",
36- Prompt: "say hello",
37- }, "test")
38- _ = writer.Close()
39- os.Stdout = oldStdout
40- output, readErr := io.ReadAll(reader)
41- _ = reader.Close()
42 if runErr != nil {27 if runErr != nil {
43 t.Fatalf("runOrchestratedPrintMode error: %v", runErr)28 t.Fatalf("runOrchestratedPrintMode error: %v", runErr)
44 }29 }
45- if readErr != nil {30+ if got := output; got != "final answer\n" {
46- t.Fatal(readErr)
47- }
48- if got := string(output); got != "final answer\n" {
49 t.Fatalf("stdout = %q, want final answer only", got)31 t.Fatalf("stdout = %q, want final answer only", got)
50 }32 }
51 }33 }
M packages/agent/subagent_runtime.go +67 -16
@@ -43,8 +43,9 @@ type subagentRuntime struct {
4343 beforeResumed func(*subagents.Agent, string) func()
4444 onStopRequested func(*subagents.Agent)
4545
46- closeMu sync.Mutex
47- closed bool
46+ settingsMu sync.RWMutex
47+ closeMu sync.Mutex
48+ closed bool
4849 }
4950
5051 type subagentRuntimeConfiguration struct {
@@ -114,13 +115,25 @@ func newSubagentRuntime(cfg subagentRuntimeConfig) *subagentRuntime {
114115 onStopRequested: cfg.OnStopRequested,
115116 }
116117 if rt.activeProvider == nil {
117- rt.activeProvider = func() string { return rt.provider }
118+ rt.activeProvider = func() string {
119+ rt.settingsMu.RLock()
120+ defer rt.settingsMu.RUnlock()
121+ return rt.provider
122+ }
118123 }
119124 if rt.activeModel == nil {
120- rt.activeModel = func() string { return rt.model }
125+ rt.activeModel = func() string {
126+ rt.settingsMu.RLock()
127+ defer rt.settingsMu.RUnlock()
128+ return rt.model
129+ }
121130 }
122131 if rt.activeReasoning == nil {
123- rt.activeReasoning = func() string { return rt.reasoning }
132+ rt.activeReasoning = func() string {
133+ rt.settingsMu.RLock()
134+ defer rt.settingsMu.RUnlock()
135+ return rt.reasoning
136+ }
124137 }
125138 if rt.apiKey != "" {
126139 // A launch-time API key belongs to the provider selected at startup. It
@@ -179,7 +192,18 @@ func (rt *subagentRuntime) SetModel(model string) {
179192 if rt == nil {
180193 return
181194 }
195+ rt.settingsMu.Lock()
182196 rt.model = strings.TrimSpace(model)
197+ rt.settingsMu.Unlock()
198+}
199+
200+func (rt *subagentRuntime) SetReasoning(reasoning string) {
201+ if rt == nil {
202+ return
203+ }
204+ rt.settingsMu.Lock()
205+ rt.reasoning = strings.TrimSpace(reasoning)
206+ rt.settingsMu.Unlock()
183207 }
184208
185209 func (rt *subagentRuntime) currentReasoning() string {
@@ -193,9 +217,12 @@ func (rt *subagentRuntime) SetProvider(providerID string) {
193217 if rt == nil {
194218 return
195219 }
196- rt.provider = strings.TrimSpace(providerID)
220+ providerID = strings.TrimSpace(providerID)
221+ rt.settingsMu.Lock()
222+ rt.provider = providerID
223+ rt.settingsMu.Unlock()
197224 if rt.supervisor != nil {
198- rt.supervisor.SetProvider(rt.provider)
225+ rt.supervisor.SetProvider(providerID)
199226 }
200227 }
201228
@@ -203,10 +230,13 @@ func (rt *subagentRuntime) SetProviderSettings(baseURL string, insecureTLS bool)
203230 if rt == nil {
204231 return
205232 }
206- rt.baseURL = strings.TrimSpace(baseURL)
233+ baseURL = strings.TrimSpace(baseURL)
234+ rt.settingsMu.Lock()
235+ rt.baseURL = baseURL
207236 rt.insecureTLS = insecureTLS
237+ rt.settingsMu.Unlock()
208238 if rt.supervisor != nil {
209- rt.supervisor.SetProviderSettings(rt.baseURL, rt.insecureTLS)
239+ rt.supervisor.SetProviderSettings(baseURL, insecureTLS)
210240 }
211241 }
212242
@@ -214,7 +244,9 @@ func (rt *subagentRuntime) SetFastMode(fastMode bool) {
214244 if rt == nil {
215245 return
216246 }
247+ rt.settingsMu.Lock()
217248 rt.fastMode = fastMode
249+ rt.settingsMu.Unlock()
218250 if rt.supervisor != nil {
219251 rt.supervisor.SetFastMode(fastMode)
220252 }
@@ -224,7 +256,9 @@ func (rt *subagentRuntime) SetRepoRoot(repoRoot string) {
224256 if rt == nil {
225257 return
226258 }
259+ rt.settingsMu.Lock()
227260 rt.repoRoot = repoRoot
261+ rt.settingsMu.Unlock()
228262 if rt.supervisor != nil {
229263 rt.supervisor.SetRepoRoot(repoRoot)
230264 }
@@ -237,6 +271,8 @@ func (rt *subagentRuntime) snapshotConfiguration() subagentRuntimeConfiguration
237271 if rt == nil {
238272 return subagentRuntimeConfiguration{}
239273 }
274+ rt.settingsMu.RLock()
275+ defer rt.settingsMu.RUnlock()
240276 return subagentRuntimeConfiguration{
241277 provider: rt.provider,
242278 model: rt.model,
@@ -252,12 +288,22 @@ func (rt *subagentRuntime) restoreConfiguration(snapshot subagentRuntimeConfigur
252288 if rt == nil {
253289 return
254290 }
255- rt.SetProvider(snapshot.provider)
256- rt.SetModel(snapshot.model)
257- rt.SetProviderSettings(snapshot.baseURL, snapshot.insecureTLS)
258- rt.SetFastMode(snapshot.fastMode)
259- rt.SetRepoRoot(snapshot.repoRoot)
260- rt.SetWebSearchPolicy(snapshot.webSearchPolicy)
291+ rt.settingsMu.Lock()
292+ rt.provider = snapshot.provider
293+ rt.model = snapshot.model
294+ rt.baseURL = snapshot.baseURL
295+ rt.insecureTLS = snapshot.insecureTLS
296+ rt.fastMode = snapshot.fastMode
297+ rt.repoRoot = snapshot.repoRoot
298+ rt.webSearchPolicy = snapshot.webSearchPolicy
299+ rt.settingsMu.Unlock()
300+ if rt.supervisor != nil {
301+ rt.supervisor.SetProvider(snapshot.provider)
302+ rt.supervisor.SetProviderSettings(snapshot.baseURL, snapshot.insecureTLS)
303+ rt.supervisor.SetFastMode(snapshot.fastMode)
304+ rt.supervisor.SetRepoRoot(snapshot.repoRoot)
305+ rt.supervisor.SetWebSearchPolicy(snapshot.webSearchPolicy)
306+ }
261307 }
262308
263309 func (rt *subagentRuntime) SetActiveSession(sessionID string) {
@@ -270,7 +316,9 @@ func (rt *subagentRuntime) SetWebSearchPolicy(policy subagents.WebSearchPolicy)
270316 if rt == nil {
271317 return
272318 }
319+ rt.settingsMu.Lock()
273320 rt.webSearchPolicy = policy
321+ rt.settingsMu.Unlock()
274322 if rt.supervisor != nil {
275323 rt.supervisor.SetWebSearchPolicy(policy)
276324 }
@@ -307,7 +355,10 @@ func (rt *subagentRuntime) resolveSubagent(name string) (*subagents.Profile, err
307355 if rt == nil {
308356 return nil, nil
309357 }
310- return findSubagentProfile(rt.repoRoot, name)
358+ rt.settingsMu.RLock()
359+ repoRoot := rt.repoRoot
360+ rt.settingsMu.RUnlock()
361+ return findSubagentProfile(repoRoot, name)
311362 }
312363
313364 // InjectTools adds the canonical manager tools allowed by the host launch
@@ -43,8 +43,9 @@ type subagentRuntime struct {
43 beforeResumed func(*subagents.Agent, string) func()43 beforeResumed func(*subagents.Agent, string) func()
44 onStopRequested func(*subagents.Agent)44 onStopRequested func(*subagents.Agent)
45 45
46- closeMu sync.Mutex46+ settingsMu sync.RWMutex
47- closed bool47+ closeMu sync.Mutex
48+ closed bool
48 }49 }
49 50
50 type subagentRuntimeConfiguration struct {51 type subagentRuntimeConfiguration struct {
@@ -114,13 +115,25 @@ func newSubagentRuntime(cfg subagentRuntimeConfig) *subagentRuntime {
114 onStopRequested: cfg.OnStopRequested,115 onStopRequested: cfg.OnStopRequested,
115 }116 }
116 if rt.activeProvider == nil {117 if rt.activeProvider == nil {
117- rt.activeProvider = func() string { return rt.provider }118+ rt.activeProvider = func() string {
119+ rt.settingsMu.RLock()
120+ defer rt.settingsMu.RUnlock()
121+ return rt.provider
122+ }
118 }123 }
119 if rt.activeModel == nil {124 if rt.activeModel == nil {
120- rt.activeModel = func() string { return rt.model }125+ rt.activeModel = func() string {
126+ rt.settingsMu.RLock()
127+ defer rt.settingsMu.RUnlock()
128+ return rt.model
129+ }
121 }130 }
122 if rt.activeReasoning == nil {131 if rt.activeReasoning == nil {
123- rt.activeReasoning = func() string { return rt.reasoning }132+ rt.activeReasoning = func() string {
133+ rt.settingsMu.RLock()
134+ defer rt.settingsMu.RUnlock()
135+ return rt.reasoning
136+ }
124 }137 }
125 if rt.apiKey != "" {138 if rt.apiKey != "" {
126 // A launch-time API key belongs to the provider selected at startup. It139 // A launch-time API key belongs to the provider selected at startup. It
@@ -179,7 +192,18 @@ func (rt *subagentRuntime) SetModel(model string) {
179 if rt == nil {192 if rt == nil {
180 return193 return
181 }194 }
195+ rt.settingsMu.Lock()
182 rt.model = strings.TrimSpace(model)196 rt.model = strings.TrimSpace(model)
197+ rt.settingsMu.Unlock()
198+}
199+
200+func (rt *subagentRuntime) SetReasoning(reasoning string) {
201+ if rt == nil {
202+ return
203+ }
204+ rt.settingsMu.Lock()
205+ rt.reasoning = strings.TrimSpace(reasoning)
206+ rt.settingsMu.Unlock()
183 }207 }
184 208
185 func (rt *subagentRuntime) currentReasoning() string {209 func (rt *subagentRuntime) currentReasoning() string {
@@ -193,9 +217,12 @@ func (rt *subagentRuntime) SetProvider(providerID string) {
193 if rt == nil {217 if rt == nil {
194 return218 return
195 }219 }
196- rt.provider = strings.TrimSpace(providerID)220+ providerID = strings.TrimSpace(providerID)
221+ rt.settingsMu.Lock()
222+ rt.provider = providerID
223+ rt.settingsMu.Unlock()
197 if rt.supervisor != nil {224 if rt.supervisor != nil {
198- rt.supervisor.SetProvider(rt.provider)225+ rt.supervisor.SetProvider(providerID)
199 }226 }
200 }227 }
201 228
@@ -203,10 +230,13 @@ func (rt *subagentRuntime) SetProviderSettings(baseURL string, insecureTLS bool)
203 if rt == nil {230 if rt == nil {
204 return231 return
205 }232 }
206- rt.baseURL = strings.TrimSpace(baseURL)233+ baseURL = strings.TrimSpace(baseURL)
234+ rt.settingsMu.Lock()
235+ rt.baseURL = baseURL
207 rt.insecureTLS = insecureTLS236 rt.insecureTLS = insecureTLS
237+ rt.settingsMu.Unlock()
208 if rt.supervisor != nil {238 if rt.supervisor != nil {
209- rt.supervisor.SetProviderSettings(rt.baseURL, rt.insecureTLS)239+ rt.supervisor.SetProviderSettings(baseURL, insecureTLS)
210 }240 }
211 }241 }
212 242
@@ -214,7 +244,9 @@ func (rt *subagentRuntime) SetFastMode(fastMode bool) {
214 if rt == nil {244 if rt == nil {
215 return245 return
216 }246 }
247+ rt.settingsMu.Lock()
217 rt.fastMode = fastMode248 rt.fastMode = fastMode
249+ rt.settingsMu.Unlock()
218 if rt.supervisor != nil {250 if rt.supervisor != nil {
219 rt.supervisor.SetFastMode(fastMode)251 rt.supervisor.SetFastMode(fastMode)
220 }252 }
@@ -224,7 +256,9 @@ func (rt *subagentRuntime) SetRepoRoot(repoRoot string) {
224 if rt == nil {256 if rt == nil {
225 return257 return
226 }258 }
259+ rt.settingsMu.Lock()
227 rt.repoRoot = repoRoot260 rt.repoRoot = repoRoot
261+ rt.settingsMu.Unlock()
228 if rt.supervisor != nil {262 if rt.supervisor != nil {
229 rt.supervisor.SetRepoRoot(repoRoot)263 rt.supervisor.SetRepoRoot(repoRoot)
230 }264 }
@@ -237,6 +271,8 @@ func (rt *subagentRuntime) snapshotConfiguration() subagentRuntimeConfiguration
237 if rt == nil {271 if rt == nil {
238 return subagentRuntimeConfiguration{}272 return subagentRuntimeConfiguration{}
239 }273 }
274+ rt.settingsMu.RLock()
275+ defer rt.settingsMu.RUnlock()
240 return subagentRuntimeConfiguration{276 return subagentRuntimeConfiguration{
241 provider: rt.provider,277 provider: rt.provider,
242 model: rt.model,278 model: rt.model,
@@ -252,12 +288,22 @@ func (rt *subagentRuntime) restoreConfiguration(snapshot subagentRuntimeConfigur
252 if rt == nil {288 if rt == nil {
253 return289 return
254 }290 }
255- rt.SetProvider(snapshot.provider)291+ rt.settingsMu.Lock()
256- rt.SetModel(snapshot.model)292+ rt.provider = snapshot.provider
257- rt.SetProviderSettings(snapshot.baseURL, snapshot.insecureTLS)293+ rt.model = snapshot.model
258- rt.SetFastMode(snapshot.fastMode)294+ rt.baseURL = snapshot.baseURL
259- rt.SetRepoRoot(snapshot.repoRoot)295+ rt.insecureTLS = snapshot.insecureTLS
260- rt.SetWebSearchPolicy(snapshot.webSearchPolicy)296+ rt.fastMode = snapshot.fastMode
297+ rt.repoRoot = snapshot.repoRoot
298+ rt.webSearchPolicy = snapshot.webSearchPolicy
299+ rt.settingsMu.Unlock()
300+ if rt.supervisor != nil {
301+ rt.supervisor.SetProvider(snapshot.provider)
302+ rt.supervisor.SetProviderSettings(snapshot.baseURL, snapshot.insecureTLS)
303+ rt.supervisor.SetFastMode(snapshot.fastMode)
304+ rt.supervisor.SetRepoRoot(snapshot.repoRoot)
305+ rt.supervisor.SetWebSearchPolicy(snapshot.webSearchPolicy)
306+ }
261 }307 }
262 308
263 func (rt *subagentRuntime) SetActiveSession(sessionID string) {309 func (rt *subagentRuntime) SetActiveSession(sessionID string) {
@@ -270,7 +316,9 @@ func (rt *subagentRuntime) SetWebSearchPolicy(policy subagents.WebSearchPolicy)
270 if rt == nil {316 if rt == nil {
271 return317 return
272 }318 }
319+ rt.settingsMu.Lock()
273 rt.webSearchPolicy = policy320 rt.webSearchPolicy = policy
321+ rt.settingsMu.Unlock()
274 if rt.supervisor != nil {322 if rt.supervisor != nil {
275 rt.supervisor.SetWebSearchPolicy(policy)323 rt.supervisor.SetWebSearchPolicy(policy)
276 }324 }
@@ -307,7 +355,10 @@ func (rt *subagentRuntime) resolveSubagent(name string) (*subagents.Profile, err
307 if rt == nil {355 if rt == nil {
308 return nil, nil356 return nil, nil
309 }357 }
310- return findSubagentProfile(rt.repoRoot, name)358+ rt.settingsMu.RLock()
359+ repoRoot := rt.repoRoot
360+ rt.settingsMu.RUnlock()
361+ return findSubagentProfile(repoRoot, name)
311 }362 }
312 363
313 // InjectTools adds the canonical manager tools allowed by the host launch364 // InjectTools adds the canonical manager tools allowed by the host launch
M packages/agent/subagent_runtime_test.go +38 -5
@@ -133,6 +133,40 @@ func TestSubagentRuntimeOwnsSupervisorConfigurationAndResolution(t *testing.T) {
133133 }
134134 }
135135
136+func TestSubagentRuntimeConfigurationConcurrentAccess(t *testing.T) {
137+ cfg := newRuntimeTestConfig(t.TempDir(), t.TempDir(), func(*subagents.Agent) subagents.Runner {
138+ return &runtimeTestRunner{}
139+ })
140+ rt := newSubagentRuntime(cfg)
141+ defer func() { _ = rt.Close(context.Background()) }()
142+ repoRoot := t.TempDir()
143+
144+ var wg sync.WaitGroup
145+ wg.Add(2)
146+ go func() {
147+ defer wg.Done()
148+ for index := 0; index < 100; index++ {
149+ rt.SetProvider("provider")
150+ rt.SetModel("model")
151+ rt.SetReasoning("medium")
152+ rt.SetProviderSettings("https://provider.example/v1", index%2 == 0)
153+ rt.SetFastMode(index%2 == 0)
154+ rt.SetRepoRoot(repoRoot)
155+ rt.SetWebSearchPolicy(subagents.WebSearchDeny)
156+ }
157+ }()
158+ go func() {
159+ defer wg.Done()
160+ for index := 0; index < 100; index++ {
161+ _ = rt.currentProvider()
162+ _ = rt.currentModel()
163+ _ = rt.currentReasoning()
164+ _ = rt.snapshotConfiguration()
165+ }
166+ }()
167+ wg.Wait()
168+}
169+
136170 func TestSubagentRuntimeResolvedWebSearchPolicyRefreshesSupervisor(t *testing.T) {
137171 var captured []*subagents.Agent
138172 cfg := newRuntimeTestConfig(t.TempDir(), t.TempDir(), func(a *subagents.Agent) subagents.Runner {
@@ -349,16 +383,15 @@ func TestSubagentRuntimeInjectedSpawnProfileInheritsAndOverridesFrontmatter(t *t
349383 t.Setenv("ZUT_AGENT_PROFILES", profilesDir)
350384
351385 var captured []*subagents.Agent
352- liveReasoning := "high"
353386 cfg := newRuntimeTestConfig(t.TempDir(), cwd, func(a *subagents.Agent) subagents.Runner {
354387 captured = append(captured, a)
355388 return &runtimeTestRunner{}
356389 })
357- cfg.ActiveReasoning = func() string { return liveReasoning }
390+ cfg.Reasoning = "high"
358391 rt := newSubagentRuntime(cfg)
359- // Change the host callback after runtime construction. A child without a
360- // profile override must observe this new live value.
361- liveReasoning = "medium"
392+ // Change the host-owned setting after runtime construction. A child without
393+ // a profile override must observe this new live value.
394+ rt.SetReasoning("medium")
362395 defer func() { _ = rt.Close(context.Background()) }()
363396 spawn := rt.InjectTools(core.NewRegistry())["subagent_spawn"]
364397
@@ -133,6 +133,40 @@ func TestSubagentRuntimeOwnsSupervisorConfigurationAndResolution(t *testing.T) {
133 }133 }
134 }134 }
135 135
136+func TestSubagentRuntimeConfigurationConcurrentAccess(t *testing.T) {
137+ cfg := newRuntimeTestConfig(t.TempDir(), t.TempDir(), func(*subagents.Agent) subagents.Runner {
138+ return &runtimeTestRunner{}
139+ })
140+ rt := newSubagentRuntime(cfg)
141+ defer func() { _ = rt.Close(context.Background()) }()
142+ repoRoot := t.TempDir()
143+
144+ var wg sync.WaitGroup
145+ wg.Add(2)
146+ go func() {
147+ defer wg.Done()
148+ for index := 0; index < 100; index++ {
149+ rt.SetProvider("provider")
150+ rt.SetModel("model")
151+ rt.SetReasoning("medium")
152+ rt.SetProviderSettings("https://provider.example/v1", index%2 == 0)
153+ rt.SetFastMode(index%2 == 0)
154+ rt.SetRepoRoot(repoRoot)
155+ rt.SetWebSearchPolicy(subagents.WebSearchDeny)
156+ }
157+ }()
158+ go func() {
159+ defer wg.Done()
160+ for index := 0; index < 100; index++ {
161+ _ = rt.currentProvider()
162+ _ = rt.currentModel()
163+ _ = rt.currentReasoning()
164+ _ = rt.snapshotConfiguration()
165+ }
166+ }()
167+ wg.Wait()
168+}
169+
136 func TestSubagentRuntimeResolvedWebSearchPolicyRefreshesSupervisor(t *testing.T) {170 func TestSubagentRuntimeResolvedWebSearchPolicyRefreshesSupervisor(t *testing.T) {
137 var captured []*subagents.Agent171 var captured []*subagents.Agent
138 cfg := newRuntimeTestConfig(t.TempDir(), t.TempDir(), func(a *subagents.Agent) subagents.Runner {172 cfg := newRuntimeTestConfig(t.TempDir(), t.TempDir(), func(a *subagents.Agent) subagents.Runner {
@@ -349,16 +383,15 @@ func TestSubagentRuntimeInjectedSpawnProfileInheritsAndOverridesFrontmatter(t *t
349 t.Setenv("ZUT_AGENT_PROFILES", profilesDir)383 t.Setenv("ZUT_AGENT_PROFILES", profilesDir)
350 384
351 var captured []*subagents.Agent385 var captured []*subagents.Agent
352- liveReasoning := "high"
353 cfg := newRuntimeTestConfig(t.TempDir(), cwd, func(a *subagents.Agent) subagents.Runner {386 cfg := newRuntimeTestConfig(t.TempDir(), cwd, func(a *subagents.Agent) subagents.Runner {
354 captured = append(captured, a)387 captured = append(captured, a)
355 return &runtimeTestRunner{}388 return &runtimeTestRunner{}
356 })389 })
357- cfg.ActiveReasoning = func() string { return liveReasoning }390+ cfg.Reasoning = "high"
358 rt := newSubagentRuntime(cfg)391 rt := newSubagentRuntime(cfg)
359- // Change the host callback after runtime construction. A child without a392+ // Change the host-owned setting after runtime construction. A child without
360- // profile override must observe this new live value.393+ // a profile override must observe this new live value.
361- liveReasoning = "medium"394+ rt.SetReasoning("medium")
362 defer func() { _ = rt.Close(context.Background()) }()395 defer func() { _ = rt.Close(context.Background()) }()
363 spawn := rt.InjectTools(core.NewRegistry())["subagent_spawn"]396 spawn := rt.InjectTools(core.NewRegistry())["subagent_spawn"]
364 397
M packages/agent/subagents/completion_test.go +8 -6
@@ -22,6 +22,7 @@ func newCompletionTestAgent(id, task string, status Status) *Agent {
2222
2323 func TestCompletionTrackerTurnEndCompletesLongLivedWorker(t *testing.T) {
2424 agent := newCompletionTestAgent("worker-1", "original task", StatusRunning)
25+ t.Cleanup(func() { close(agent.done) })
2526 tracker := NewCompletionTracker()
2627 tracker.TrackTurn(agent, "ignored task", false)
2728
@@ -46,8 +47,6 @@ func TestCompletionTrackerTurnEndCompletesLongLivedWorker(t *testing.T) {
4647 if tracker.Pending() != 0 {
4748 t.Fatalf("pending = %d, want 0", tracker.Pending())
4849 }
49-
50- close(agent.done)
5150 }
5251
5352 func TestCompletionTrackerStartupAndProcessFailuresUseExitPath(t *testing.T) {
@@ -363,16 +362,19 @@ func TestFormatCompletionUpdateBoundsFieldsAndPreservesEvidence(t *testing.T) {
363362 t.Fatalf("update instructs the parent to poll: %q", update)
364363 }
365364
366- taskLine := strings.Split(update, "\n")[3]
367- if got := len([]rune(strings.TrimPrefix(taskLine, " task: "))); got != 240 {
368- t.Fatalf("formatted task length = %d, want 240", got)
369- }
365+ taskLine := ""
370366 errorLine := ""
371367 for _, line := range strings.Split(update, "\n") {
368+ if strings.HasPrefix(line, " task: ") {
369+ taskLine = strings.TrimPrefix(line, " task: ")
370+ }
372371 if strings.HasPrefix(line, " error: ") {
373372 errorLine = strings.TrimPrefix(line, " error: ")
374373 }
375374 }
375+ if got := len([]rune(taskLine)); got != 240 {
376+ t.Fatalf("formatted task length = %d, want 240", got)
377+ }
376378 if got := len([]rune(errorLine)); got != 240 {
377379 t.Fatalf("formatted error length = %d, want 240", got)
378380 }
@@ -22,6 +22,7 @@ func newCompletionTestAgent(id, task string, status Status) *Agent {
22 22
23 func TestCompletionTrackerTurnEndCompletesLongLivedWorker(t *testing.T) {23 func TestCompletionTrackerTurnEndCompletesLongLivedWorker(t *testing.T) {
24 agent := newCompletionTestAgent("worker-1", "original task", StatusRunning)24 agent := newCompletionTestAgent("worker-1", "original task", StatusRunning)
25+ t.Cleanup(func() { close(agent.done) })
25 tracker := NewCompletionTracker()26 tracker := NewCompletionTracker()
26 tracker.TrackTurn(agent, "ignored task", false)27 tracker.TrackTurn(agent, "ignored task", false)
27 28
@@ -46,8 +47,6 @@ func TestCompletionTrackerTurnEndCompletesLongLivedWorker(t *testing.T) {
46 if tracker.Pending() != 0 {47 if tracker.Pending() != 0 {
47 t.Fatalf("pending = %d, want 0", tracker.Pending())48 t.Fatalf("pending = %d, want 0", tracker.Pending())
48 }49 }
49-
50- close(agent.done)
51 }50 }
52 51
53 func TestCompletionTrackerStartupAndProcessFailuresUseExitPath(t *testing.T) {52 func TestCompletionTrackerStartupAndProcessFailuresUseExitPath(t *testing.T) {
@@ -363,16 +362,19 @@ func TestFormatCompletionUpdateBoundsFieldsAndPreservesEvidence(t *testing.T) {
363 t.Fatalf("update instructs the parent to poll: %q", update)362 t.Fatalf("update instructs the parent to poll: %q", update)
364 }363 }
365 364
366- taskLine := strings.Split(update, "\n")[3]365+ taskLine := ""
367- if got := len([]rune(strings.TrimPrefix(taskLine, " task: "))); got != 240 {
368- t.Fatalf("formatted task length = %d, want 240", got)
369- }
370 errorLine := ""366 errorLine := ""
371 for _, line := range strings.Split(update, "\n") {367 for _, line := range strings.Split(update, "\n") {
368+ if strings.HasPrefix(line, " task: ") {
369+ taskLine = strings.TrimPrefix(line, " task: ")
370+ }
372 if strings.HasPrefix(line, " error: ") {371 if strings.HasPrefix(line, " error: ") {
373 errorLine = strings.TrimPrefix(line, " error: ")372 errorLine = strings.TrimPrefix(line, " error: ")
374 }373 }
375 }374 }
375+ if got := len([]rune(taskLine)); got != 240 {
376+ t.Fatalf("formatted task length = %d, want 240", got)
377+ }
376 if got := len([]rune(errorLine)); got != 240 {378 if got := len([]rune(errorLine)); got != 240 {
377 t.Fatalf("formatted error length = %d, want 240", got)379 t.Fatalf("formatted error length = %d, want 240", got)
378 }380 }
M packages/agent/subagents/persist.go +7 -3
@@ -811,8 +811,10 @@ func (f *Supervisor) ResumeWithPrompt(ctx context.Context, id, prompt string) (*
811811 // ResumeWithPromptBefore is ResumeWithPrompt with a pre-delivery hook. The
812812 // hook runs after the target Agent is known but before a queued or direct
813813 // follow-up is made deliverable. For a restarted worker it runs after the new
814-// Agent is built and before its runner is scheduled. A non-nil cleanup is
815-// called when the operation is rejected before delivery.
814+// Agent is built and before its runner is scheduled. For queued and direct
815+// delivery, the hook runs while operationMu is held and must not block or call
816+// back into Supervisor methods. A non-nil cleanup is called when the operation
817+// is rejected before delivery.
816818 func (f *Supervisor) ResumeWithPromptBefore(ctx context.Context, id, prompt string, before func(*Agent, string) func()) (*Agent, error) {
817819 if ctx == nil {
818820 ctx = context.Background()
@@ -1287,7 +1289,9 @@ func (f *Supervisor) resumeWithHook(ctx context.Context, id string, resuming boo
12871289 // has not reached its first event yet. Register the turn before the
12881290 // scheduler can start the worker and emit a fast turn_end.
12891291 if before != nil && resumePrompt != "" {
1290- before(a, resumePrompt)
1292+ // No rejecting operation remains before scheduling, so there is no path
1293+ // that needs the hook's rollback after this point.
1294+ _ = before(a, resumePrompt)
12911295 }
12921296 f.armQueueTimeout(a)
12931297 f.schedule()
@@ -811,8 +811,10 @@ func (f *Supervisor) ResumeWithPrompt(ctx context.Context, id, prompt string) (*
811 // ResumeWithPromptBefore is ResumeWithPrompt with a pre-delivery hook. The811 // ResumeWithPromptBefore is ResumeWithPrompt with a pre-delivery hook. The
812 // hook runs after the target Agent is known but before a queued or direct812 // hook runs after the target Agent is known but before a queued or direct
813 // follow-up is made deliverable. For a restarted worker it runs after the new813 // follow-up is made deliverable. For a restarted worker it runs after the new
814-// Agent is built and before its runner is scheduled. A non-nil cleanup is814+// Agent is built and before its runner is scheduled. For queued and direct
815-// called when the operation is rejected before delivery.815+// delivery, the hook runs while operationMu is held and must not block or call
816+// back into Supervisor methods. A non-nil cleanup is called when the operation
817+// is rejected before delivery.
816 func (f *Supervisor) ResumeWithPromptBefore(ctx context.Context, id, prompt string, before func(*Agent, string) func()) (*Agent, error) {818 func (f *Supervisor) ResumeWithPromptBefore(ctx context.Context, id, prompt string, before func(*Agent, string) func()) (*Agent, error) {
817 if ctx == nil {819 if ctx == nil {
818 ctx = context.Background()820 ctx = context.Background()
@@ -1287,7 +1289,9 @@ func (f *Supervisor) resumeWithHook(ctx context.Context, id string, resuming boo
1287 // has not reached its first event yet. Register the turn before the1289 // has not reached its first event yet. Register the turn before the
1288 // scheduler can start the worker and emit a fast turn_end.1290 // scheduler can start the worker and emit a fast turn_end.
1289 if before != nil && resumePrompt != "" {1291 if before != nil && resumePrompt != "" {
1290- before(a, resumePrompt)1292+ // No rejecting operation remains before scheduling, so there is no path
1293+ // that needs the hook's rollback after this point.
1294+ _ = before(a, resumePrompt)
1291 }1295 }
1292 f.armQueueTimeout(a)1296 f.armQueueTimeout(a)
1293 f.schedule()1297 f.schedule()

Keyboard shortcuts

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

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

Branches 1

Tags 476

↑↓ moveenter openesc close