detached 063d011

Merge pull request #90 from bnema/refactor/subagent-completion-tracker

feat(agent): add headless subagent orchestration
commit
063d0110f88c76f11d4b6a5fa5f545b1c158c67f
author
bnema · · 2026-08-09 09:21 UTC
committer
GitHub · · 2026-08-09 09:21 UTC
merge of
399710f + fa4d1f9 · diff against the first parent
signature
GPG UnverifiedB5690EEEBB952194 The key is unknown
34 files · +4019 -430
M README.md +11 -1
@@ -192,6 +192,7 @@ zut "fix the failing test" # tui, pre-filled prompt
192192 zut -p "list all go files" # print final text, exit
193193 zut -p --stats stats.json "task" # print final text and write generation stats
194194 zut --json "refactor main.go" # newline-delimited json events, exit
195+zut -p --orchestrate --provider openai --model gpt-5 "implement and synthesize"
195196 zut --continue # resume the most recent session for this cwd
196197 zut --resume # pick a session to resume
197198 zut --resume <UUID> # resume a persisted session by UUID
@@ -213,7 +214,8 @@ Print-mode stats contain `provider`, `model`, `prompt_tokens`, `reasoning_tokens
213214 | `--system-prompt <text>` | Replace the default system prompt for this run (also overrides `$ZUT_HOME/SYSTEM.md`). |
214215 | `--append-system-prompt <text>` | Append text to the system prompt (repeatable). |
215216 | `--reasoning off\|minimum\|low\|medium\|high\|xhigh\|max` | Set the reasoning level on supported models (default: off). `max` is a separate opt-in tier above `xhigh`. |
216-| `--stats <path>` | With `-p`/`--print`, write generation statistics as JSON. |
217+| `--stats <path>` | With `-p`/`--print`, write generation statistics as JSON; incompatible with `--orchestrate`. |
218+| `--orchestrate` | In print, stream, or JSON mode, enable bounded headless delegation and final synthesis. It is not implicit. |
217219 | `-c`, `--continue` | Resume the latest session for this cwd. |
218220 | `-r`, `--resume [UUID]` | Pick a session to resume, or resume a persisted session by UUID. |
219221 | `--session <path>` | Resume a specific session file. |
@@ -248,6 +250,14 @@ When the sandbox is on (see `/jail`), filesystem tools and LSP workspace edits r
248250 - **JSON**: `zut --json "prompt"` emits one JSON object per agent event to stdout, newline-delimited. The schema is documented in [docs/rpc.md](docs/rpc.md).
249251 - **RPC**: `zut rpc` runs as a long-lived child process; commands in on stdin, events and responses out on stdout, both as NDJSON. Designed for embedding zut in third-party apps written in any language. See [docs/rpc.md](docs/rpc.md) for the wire schema and `examples/rpc/{python,node,shell,go}` for working clients.
250252
253+### Headless orchestration
254+
255+`--orchestrate` explicitly enables bounded delegation for `-p`/`--print`, `--stream`, or `--json`; ordinary headless runs never delegate implicitly. The parent needs `subagent_spawn`: it is available with the default tool set, or must be named in `--tools subagent_spawn` (and is disabled by `--no-tools`). A packaged agent's `PermissionSet` does not expose `subagent_spawn`, so packaged-agent and profile-worker modes cannot use orchestration. Interactive and RPC modes do not support this flag, and `--stats` cannot be combined with it.
256+
257+Workers inherit the parent CLI provider, model, and reasoning selection. Profile metadata from [docs/subagents.md](docs/subagents.md) is provided to the parent through `[subagents_list]` before selection; only the selected profile body and child-specific provider/model/reasoning configuration are loaded or applied afterward. 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+
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+
251261 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.
252262
253263 ## zutfile agents
M docs/subagents.md +14 -0
@@ -52,6 +52,20 @@ Supported metadata:
5252
5353 Other frontmatter from another agent host is ignored when zut does not have an equivalent setting. Recursive child spawning is disabled in v1; a worker cannot invoke `subagent_spawn` to create descendants.
5454
55+## Headless orchestration
56+
57+Headless orchestration is explicit: pass `--orchestrate` with `-p`/`--print`, `--stream`, or `--json`. There is no implicit activation. The parent must be allowed to use `subagent_spawn`; the default tool set allows it, while an explicit allowlist must include it (for example, `--tools subagent_spawn`), and `--no-tools` disables it. A packaged agent's `PermissionSet` has no `subagent_spawn` capability, so orchestration is rejected for packaged agents and profile-worker mode. `--orchestrate` is incompatible with `--stats` and unsupported in interactive and RPC modes. Workers are non-recursive: a worker cannot spawn another worker.
58+
59+The parent CLI's `--provider`, `--model`, and `--reasoning` values are inherited by unnamed workers. A named profile can explicitly override provider, model, and reasoning in its frontmatter; the profile's body and other metadata are applied only after the parent selects it. For example:
60+
61+```bash
62+zut -p --orchestrate --provider openai --model gpt-5 --reasoning high "delegate the implementation and synthesize the result"
63+```
64+
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+
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+
5569 ## Selecting a profile
5670
5771 When **auto-subagents** is enabled in `/settings`, the interactive primary agent runs in strict orchestrator mode. It must delegate all implementation, debugging/testing, and code-review work to an appropriately named profile, or to a clearly described general worker when no profile fits. The primary agent must not write or edit code, make direct implementation tool calls, inspect or review code, or apply worker patches. It may decompose requests, select and spawn workers, check status, coordinate follow-ups, and synthesize their results.
M packages/agent/args.go +9 -4
@@ -34,10 +34,11 @@ const (
3434
3535 // Args holds parsed command-line options.
3636 type Args struct {
37- Mode Mode
38- Provider string
39- Model string
40- APIKey string
37+ Mode Mode
38+ Orchestrate bool
39+ Provider string
40+ Model string
41+ APIKey string
4142
4243 // inheritedCredential is populated only by subagent-worker mode from
4344 // the supervisor's stdin. It is never accepted as a CLI argument.
@@ -200,6 +201,8 @@ func ParseArgs(in []string) (Args, error) {
200201 a.Mode = ModeStream
201202 case "--json":
202203 a.Mode = ModeJSON
204+ case "--orchestrate":
205+ a.Orchestrate = true
203206 case "--rpc":
204207 a.Mode = ModeRPC
205208 case "-c", "--continue":
@@ -518,6 +521,7 @@ func PrintHelp(version string) {
518521 row{"zut", "interactive tui"},
519522 row{"zut \"prompt\"", "interactive, pre-filled prompt"},
520523 row{"zut -p \"prompt\"", "print final text, exit"},
524+ row{"zut -p --orchestrate \"prompt\"", "delegate independent work, print final synthesis, exit"},
521525 row{"zut --stream \"prompt\"", "stream assistant text live, exit"},
522526 row{"zut --json \"prompt\"", "newline-delimited json events, exit"},
523527 row{"zut rpc", "json-rpc loop on stdin/stdout (see docs/rpc.md)"},
@@ -553,6 +557,7 @@ func PrintHelp(version string) {
553557 row{"--temperature N", "sampling temperature, 0 to 2 (omit for provider default)"},
554558 )
555559 section("prompt and session flags",
560+ row{"--orchestrate", "headless delegation and final synthesis (print, stream, or json modes)"},
556561 row{"--stats PATH", "write print-mode generation stats as json"},
557562 row{"--system-prompt TEXT", "replace the default system prompt"},
558563 row{"--append-system-prompt TEXT", "append to the system prompt (repeatable)"},
M packages/agent/args_test.go +61 -0
@@ -1,13 +1,74 @@
11 package agent
22
33 import (
4+ "os"
5+ "strings"
46 "testing"
57 "time"
68
79 "github.com/bnema/zut/packages/agent/subagents"
10+ "github.com/bnema/zut/packages/agent/tools"
811 "github.com/google/uuid"
912 )
1013
14+func TestParseArgsOrchestrate(t *testing.T) {
15+ for _, mode := range []string{"--print", "--stream", "--json"} {
16+ args, err := ParseArgs([]string{mode, "--orchestrate", "task"})
17+ if err != nil {
18+ t.Fatalf("ParseArgs(%q): %v", mode, err)
19+ }
20+ if !args.Orchestrate {
21+ t.Fatalf("ParseArgs(%q): Orchestrate = false", mode)
22+ }
23+ }
24+}
25+
26+func TestValidateOrchestrationArgs(t *testing.T) {
27+ valid := Args{Mode: ModePrint, Orchestrate: true}
28+ if err := validateOrchestrationArgs(valid); err != nil {
29+ t.Fatalf("valid orchestration args: %v", err)
30+ }
31+ for _, tc := range []struct {
32+ name string
33+ args Args
34+ want string
35+ }{
36+ {name: "interactive", args: Args{Mode: ModeInteractive, Orchestrate: true}, want: "print, stream, or JSON"},
37+ {name: "rpc", args: Args{Mode: ModeRPC, Orchestrate: true}, want: "RPC"},
38+ {name: "worker", args: Args{Mode: ModeSubagentWorker, Orchestrate: true}, want: "subagent-worker"},
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"},
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"},
44+ {name: "missing spawn", args: Args{Mode: ModePrint, Orchestrate: true, ToolsSet: true, Tools: []string{"read"}}, want: "subagent_spawn"},
45+ } {
46+ t.Run(tc.name, func(t *testing.T) {
47+ err := validateOrchestrationArgs(tc.args)
48+ if err == nil || !strings.Contains(err.Error(), tc.want) {
49+ t.Fatalf("error = %v, want substring %q", err, tc.want)
50+ }
51+ })
52+ }
53+}
54+
55+func TestInvalidOrchestrationRejectsBeforeRuntimeCatalogSideEffects(t *testing.T) {
56+ home := t.TempDir()
57+ t.Setenv("ZUT_HOME", home)
58+
59+ err := runWithArgsRaw([]string{"--orchestrate", "task"}, "test")
60+ if err == nil || !strings.Contains(err.Error(), "requires print, stream, or JSON") {
61+ t.Fatalf("run error = %v, want mode validation error", err)
62+ }
63+ entries, readErr := os.ReadDir(home)
64+ if readErr != nil {
65+ t.Fatal(readErr)
66+ }
67+ if len(entries) != 0 {
68+ t.Fatalf("invalid orchestration created runtime state: %v", entries)
69+ }
70+}
71+
1172 func TestParseArgsSubagentAndReasoning(t *testing.T) {
1273 args, err := ParseArgs([]string{"--subagent-worker", "/tmp/in.sock", "--subagent", "reviewer", "--reasoning", "high", "task"})
1374 if err != nil {
M packages/agent/build.go +14 -3
@@ -644,7 +644,9 @@ func Resolve(args Args, requireCred bool) (Resolved, error) {
644644 if !CredentialAvailable(other) {
645645 continue
646646 }
647- c, m, a, err := ResolveCredentialFull(other, args.APIKey)
647+ // An explicit launch key belongs to the initially selected provider;
648+ // automatic fallback must never reuse it for another provider.
649+ c, m, a, err := ResolveCredentialFull(other, "")
648650 provName = other
649651 cred, method, accountID, credErr = c, m, a, err
650652 break
@@ -858,14 +860,23 @@ func Resolve(args Args, requireCred bool) (Resolved, error) {
858860 }
859861 interactiveMode := args.Mode == "" || args.Mode == ModeInteractive
860862 primaryInteractive := selectedProfile == nil && interactiveMode
861- if primaryInteractive && autoSubagentsToolAllowed(args) {
863+ primaryOrchestrator := selectedProfile == nil && args.Orchestrate
864+ if (primaryInteractive || primaryOrchestrator) && autoSubagentsToolAllowed(args) {
862865 homeDir, _ := os.UserHomeDir()
863866 profiles, _ := subagents.Discover(args.CWD, homeDir)
864867 if subagentsAddendum := subagents.SystemPromptAddendum(profiles); subagentsAddendum != "" {
865868 append_ = append(append_, subagentsAddendum)
866869 }
867870 }
868- if primaryInteractive && cfg.AutoSubagentsEnabled != nil && *cfg.AutoSubagentsEnabled {
871+ if primaryOrchestrator {
872+ // Headless orchestration owns its strict prompt contract rather than
873+ // inheriting the interactive setting.
874+ append_ = append(append_, AutoSubagentsSystemAddendumFor(
875+ autoSubagentsToolAllowed(args),
876+ autoSubagentsStopToolAllowed(args),
877+ autoSubagentsResumeToolAllowed(args),
878+ ))
879+ } else if primaryInteractive && cfg.AutoSubagentsEnabled != nil && *cfg.AutoSubagentsEnabled {
869880 append_ = append(append_, AutoSubagentsSystemAddendumFor(
870881 autoSubagentsToolAllowed(args),
871882 autoSubagentsStopToolAllowed(args),
M packages/agent/build_test.go +41 -0
@@ -339,6 +339,46 @@ func TestFindSubagentProfileReportsDiscoveryFailure(t *testing.T) {
339339 }
340340 }
341341
342+func TestResolveOrchestratedParentPromptScopesManifestAndContract(t *testing.T) {
343+ root := t.TempDir()
344+ home := filepath.Join(root, "home")
345+ project := filepath.Join(root, "repo")
346+ if err := os.MkdirAll(filepath.Join(home, ".agents", "agents"), 0o755); err != nil {
347+ t.Fatal(err)
348+ }
349+ if err := os.MkdirAll(project, 0o755); err != nil {
350+ t.Fatal(err)
351+ }
352+ t.Setenv("HOME", home)
353+ t.Setenv("ZUT_HOME", filepath.Join(root, "zut-home"))
354+ t.Setenv("ZUT_AGENT_PROFILES", filepath.Join(home, ".agents", "agents"))
355+ if err := os.WriteFile(filepath.Join(home, ".agents", "agents", "reviewer.md"), []byte("---\nname: reviewer\ndescription: Read-only reviewer\n---\nReview only.\n"), 0o600); err != nil {
356+ t.Fatal(err)
357+ }
358+
359+ parent, err := Resolve(Args{CWD: project, Mode: ModePrint, Orchestrate: true}, false)
360+ if err != nil {
361+ t.Fatal(err)
362+ }
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)
365+ }
366+ if !strings.Contains(parent.SystemPrompt, "primary-agent orchestrator") {
367+ t.Fatalf("parent prompt omitted strict orchestrator contract:\n%s", parent.SystemPrompt)
368+ }
369+
370+ child, err := Resolve(Args{CWD: project, Mode: ModePrint, Orchestrate: true, Subagent: "reviewer"}, false)
371+ if err != nil {
372+ t.Fatal(err)
373+ }
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)
376+ }
377+ if !strings.Contains(child.SystemPrompt, "Review only.") {
378+ t.Fatalf("selected child profile prompt missing:\n%s", child.SystemPrompt)
379+ }
380+}
381+
342382 func TestResolveIncludesNamedSubagentsListWhenAutoSubagentsIsEnabled(t *testing.T) {
343383 root := t.TempDir()
344384 home := filepath.Join(root, "home")
@@ -426,6 +466,7 @@ func TestResolveKeepsStrictContractButOmitsProfilesWhenSpawnIsUnavailable(t *tes
426466 {name: "read allowlist", args: Args{CWD: project, Tools: []string{"read"}, ToolsSet: true}},
427467 {name: "status allowlist", args: Args{CWD: project, Tools: []string{"subagent_status"}, ToolsSet: true}},
428468 {name: "permission set", args: Args{CWD: project, PermissionSet: &tools.PermissionSet{}}},
469+ {name: "headless orchestration", args: Args{CWD: project, Mode: ModePrint, Orchestrate: true, NoTools: true}},
429470 }
430471 for _, tc := range cases {
431472 t.Run(tc.name, func(t *testing.T) {
M packages/agent/cli.go +178 -235
@@ -532,10 +532,7 @@ func prepareSessionResumeWithOptions(path string, current *core.Agent, currentPr
532532 return candidate, nil
533533 }
534534
535-// buildNonInteractiveSessionAgent constructs a provider/model-specific agent
536-// for startup resume while preserving the cwd sandbox and extension tools that
537-// were attached to the initial resolution.
538-func buildNonInteractiveSessionAgent(ctx context.Context, args Args, base Resolved, extMgr *extensions.Manager, providerName, model string) (*core.Agent, string, string, error) {
535+func buildNonInteractiveSessionAgentWithRegistry(ctx context.Context, args Args, base Resolved, extMgr *extensions.Manager, providerName, model string, prepareRegistry func(core.Registry) core.Registry) (*core.Agent, string, string, error) {
539536 next := args
540537 next.Provider = providerName
541538 next.Model = model
@@ -547,6 +544,9 @@ func buildNonInteractiveSessionAgent(ctx context.Context, args Args, base Resolv
547544 if extMgr != nil {
548545 resolved.MergeExtensionTools(&extToolAdapter{mgr: extMgr})
549546 }
547+ if prepareRegistry != nil {
548+ prepareRegistry(resolved.ToolRegistry)
549+ }
550550 ag := resolved.NewAgent()
551551 wireNonInteractiveAgentExtHooks(ctx, ag, extMgr)
552552 return ag, resolved.Provider, resolved.Model, nil
@@ -598,9 +598,44 @@ func applySessionResume(sess *core.Session, ag *core.Agent, currentProvider, cur
598598 // contract used by interactive session switching to non-interactive startup
599599 // modes. The returned session owns the replacement append handle.
600600 func applyInitialSessionResume(ctx context.Context, args Args, base Resolved, extMgr *extensions.Manager, sess *core.Session, ag *core.Agent) (*core.Session, *core.Agent, string, string, error) {
601+ return applyInitialSessionResumeWithRegistry(ctx, args, base, extMgr, sess, ag, nil)
602+}
603+
604+func applyInitialSessionResumeWithRegistry(ctx context.Context, args Args, base Resolved, extMgr *extensions.Manager, sess *core.Session, ag *core.Agent, prepareRegistry func(core.Registry) core.Registry) (*core.Session, *core.Agent, string, string, error) {
605+ candidate, err := applySessionResume(sess, ag, base.Provider, base.Model,
606+ strings.TrimSpace(args.Provider) != "", strings.TrimSpace(args.Model) != "", func(providerName, model string) (*core.Agent, string, string, error) {
607+ return buildNonInteractiveSessionAgentWithRegistry(ctx, args, base, extMgr, providerName, model, prepareRegistry)
608+ })
609+ if err != nil {
610+ return nil, ag, "", "", err
611+ }
612+ return candidate.session, candidate.agent, candidate.provider, candidate.model, nil
613+}
614+
615+func applyInitialSessionResumeWithRuntime(ctx context.Context, args Args, base Resolved, extMgr *extensions.Manager, sess *core.Session, ag *core.Agent, runtime *subagentRuntime) (*core.Session, *core.Agent, string, string, error) {
601616 candidate, err := applySessionResume(sess, ag, base.Provider, base.Model,
602617 strings.TrimSpace(args.Provider) != "", strings.TrimSpace(args.Model) != "", func(providerName, model string) (*core.Agent, string, string, error) {
603- return buildNonInteractiveSessionAgent(ctx, args, base, extMgr, providerName, model)
618+ next := args
619+ next.Provider = providerName
620+ next.Model = model
621+ resolved, resolveErr := Resolve(next, true)
622+ if resolveErr != nil {
623+ return nil, "", "", resolveErr
624+ }
625+ resolved.UseSandbox(base.Sandbox)
626+ if extMgr != nil {
627+ resolved.MergeExtensionTools(&extToolAdapter{mgr: extMgr})
628+ }
629+ if runtime != nil {
630+ runtime.SetProvider(resolved.Provider)
631+ runtime.SetModel(resolved.Model)
632+ runtime.SetProviderSettings(resolved.BaseURL, resolved.InsecureTLS)
633+ runtime.SetFastMode(resolved.FastMode)
634+ runtime.PrepareResolvedRegistry(resolved.ToolRegistry, resolved.WebSearchPolicy)
635+ }
636+ rebuilt := resolved.NewAgent()
637+ wireNonInteractiveAgentExtHooks(ctx, rebuilt, extMgr)
638+ return rebuilt, resolved.Provider, resolved.Model, nil
604639 })
605640 if err != nil {
606641 return nil, ag, "", "", err
@@ -749,6 +784,11 @@ func runWithArgsRaw(rawArgs []string, version string) error {
749784 fmt.Println("zut", version)
750785 return nil
751786 }
787+ // Reject invalid orchestration combinations before catalog repair or model
788+ // refresh can mutate local state or launch background work.
789+ if err := validateOrchestrationArgs(args); err != nil {
790+ return err
791+ }
752792 // Model catalog: load any cached discovery data before we inspect
753793 // the model list (list-models, print/json, interactive).
754794 prepareRuntimeCatalog()
@@ -762,6 +802,9 @@ func runWithArgsRaw(rawArgs []string, version string) error {
762802 }
763803
764804 func runWithArgs(args Args, version string) error {
805+ if err := validateOrchestrationArgs(args); err != nil {
806+ return err
807+ }
765808 ctx := context.Background()
766809 switch args.Mode {
767810 case ModePrint:
@@ -891,6 +934,9 @@ func writePrintStats(path, providerID, model string, usage provider.Usage, elaps
891934 }
892935
893936 func runPrintMode(ctx context.Context, args Args, version string) (runErr error) {
937+ if args.Orchestrate {
938+ return runOrchestratedPrintMode(ctx, args, version)
939+ }
894940 if args.NoYolo {
895941 fmt.Fprintln(os.Stderr, "warning: --no-yolo has no effect in print mode (no interactive prompt available); tools will run without confirmation")
896942 }
@@ -969,6 +1015,9 @@ func runPrintMode(ctx context.Context, args Args, version string) (runErr error)
9691015 }
9701016
9711017 func runStreamMode(ctx context.Context, args Args, version string) (runErr error) {
1018+ if args.Orchestrate {
1019+ return runOrchestratedStreamMode(ctx, args, version)
1020+ }
9721021 if args.NoYolo {
9731022 fmt.Fprintln(os.Stderr, "warning: --no-yolo has no effect in stream mode (no interactive prompt available); tools will run without confirmation")
9741023 }
@@ -1086,6 +1135,9 @@ func newStreamTextSink(out io.Writer) (func(core.AgentEvent), func()) {
10861135 }
10871136
10881137 func runJSONMode(ctx context.Context, args Args, version string) (runErr error) {
1138+ if args.Orchestrate {
1139+ return runOrchestratedJSONMode(ctx, args, version)
1140+ }
10891141 if args.NoYolo {
10901142 fmt.Fprintln(os.Stderr, "warning: --no-yolo has no effect in json mode (no interactive prompt available); tools will run without confirmation")
10911143 }
@@ -1300,47 +1352,23 @@ func webSearchExplicitlyEnabledForSession(args Args) bool {
13001352 // refreshes the agent tool registry + system prompt so skills/extensions
13011353 // installed by entry.pre are visible to the following turn.
13021354 func reloadResourcesAfterStartupPre(ctx context.Context, args Args, extMgr *extensions.Manager, sharedSandbox *tools.Sandbox, ag *core.Agent) error {
1355+ _, err := reloadResourcesAfterStartupPreWithRegistry(ctx, args, extMgr, sharedSandbox, ag, nil)
1356+ return err
1357+}
1358+
1359+func reloadResourcesAfterStartupPreWithRegistry(ctx context.Context, args Args, extMgr *extensions.Manager, sharedSandbox *tools.Sandbox, ag *core.Agent, mutateRegistry func(core.Registry) core.Registry) (subagents.WebSearchPolicy, error) {
13031360 if strings.TrimSpace(args.StartupPre) == "" || ag == nil {
1304- return nil
1361+ return subagents.WebSearchDeny, nil
13051362 }
13061363 adapter := &extToolAdapter{mgr: extMgr}
13071364 if extMgr != nil {
13081365 _ = extMgr.Reload(ctx, 2*time.Second)
13091366 }
1310- _, err := refreshAgentToolsAndPrompt(args, sharedSandbox, adapter, ag, nil, nil)
1311- return err
1367+ return refreshAgentToolsAndPrompt(args, sharedSandbox, adapter, ag, mutateRegistry, nil)
13121368 }
13131369
13141370 // ---- interactive mode: opens the TUI even without credentials ----
13151371
1316-func subagentPolicyFromConfig(cfg SubagentsConfig) subagents.SubagentPolicy {
1317- parseDuration := func(value string) time.Duration {
1318- if strings.TrimSpace(value) == "" {
1319- return 0
1320- }
1321- parsed, err := time.ParseDuration(strings.TrimSpace(value))
1322- if err != nil || parsed <= 0 {
1323- return 0
1324- }
1325- return parsed
1326- }
1327- return subagents.SubagentPolicy{
1328- MaxConcurrent: cfg.MaxConcurrent,
1329- MaxConcurrentPerParent: cfg.MaxConcurrentPerParent,
1330- QueueTimeout: parseDuration(cfg.QueueTimeout),
1331- DefaultTimeout: parseDuration(cfg.DefaultTimeout),
1332- MaxTurns: cfg.MaxTurns,
1333- MaxOutputBytes: cfg.MaxOutputBytes,
1334- MaxOutputLines: cfg.MaxOutputLines,
1335- AllowedTools: append([]string(nil), cfg.AllowedTools...),
1336- AllowedRoots: append([]string(nil), cfg.AllowedRoots...),
1337- HeartbeatInterval: parseDuration(cfg.HeartbeatInterval),
1338- IdleTimeout: parseDuration(cfg.IdleTimeout),
1339- ReconnectTimeout: parseDuration(cfg.ReconnectTimeout),
1340- CancelGracePeriod: parseDuration(cfg.CancelGracePeriod),
1341- }
1342-}
1343-
13441372 func runInteractive(ctx context.Context, args Args, version string) (runErr error) {
13451373 initialCfg, _ := LoadConfig()
13461374 // Resolve WITHOUT requiring credentials.
@@ -1405,78 +1433,12 @@ func runInteractive(ctx context.Context, args Args, version string) (runErr erro
14051433 r.MergeExtensionTools(extToolAdapter)
14061434 webSearchGuard := &webSearchSessionGuard{}
14071435
1408- // Build the subagent supervisor BEFORE the agent so the auto-subagents
1409- // tool can reference it during tool-registry construction. State
1410- // lives under ZutHome/subagents so per-agent meta/events survive
1411- // restarts; the user can hunt orphaned agents down with
1412- // `git worktree list` if anything misbehaves.
1413- //
1414- // subagentsMgr is also captured by loadSession / changeCWD closures
1415- // further down the function, which is why we keep the variable
1416- // in this outer scope rather than scoping it tighter.
1436+ // Keep the shared subagent runtime in the outer scope: session reloads,
1437+ // dashboard updates, agent rebuilds, and shutdown all use the same owner.
1438+ var runtime *subagentRuntime
14171439 var subagentsMgr *subagents.Supervisor
1418- subagentsMgr = subagents.New(subagents.Config{
1419- Context: ctx,
1420- Root: filepath.Join(ZutHome(), "subagents"),
1421- RepoRoot: r.CWD,
1422- Provider: r.Provider,
1423- FastMode: r.FastMode,
1424- WebSearchPolicy: webSearchPolicyForRegistry(r.WebSearchPolicy, r.ToolRegistry),
1425- BaseURL: r.BaseURL,
1426- InsecureTLS: r.InsecureTLS,
1427- Policy: subagentPolicyFromConfig(initialCfg.Subagents),
1428- // Keep an explicit launch key in this callback only. Supervisor persists
1429- // connection settings, but never stores credentials in Agent metadata
1430- // or puts them in the worker's argv/environment.
1431- ResolveCredential: func(ctx context.Context, providerID string) (subagents.Credential, error) {
1432- childProvider := canonicalProvider(providerID)
1433- persistMu.Lock()
1434- hostProvider := canonicalProvider(activeProvider)
1435- persistMu.Unlock()
1436- if childProvider == "" {
1437- childProvider = hostProvider
1438- }
1439- explicit := ""
1440- // A launch-time API key is scoped to the provider selected at
1441- // startup. Never send it to an explicitly different child
1442- // provider; resolve that provider's own credential instead.
1443- if args.APIKey != "" && childProvider == hostProvider {
1444- explicit = args.APIKey
1445- }
1446- if childProvider == "ollama" && explicit == "" {
1447- return subagents.Credential{Value: "ollama", Method: "apikey"}, nil
1448- }
1449- credential, method, accountID, err := ResolveCredentialFullContext(ctx, childProvider, explicit)
1450- if err != nil {
1451- // Providers backed only by a local endpoint do not need a key.
1452- // Leave stdin untouched and let the child resolve that endpoint.
1453- if !CredentialAvailable(childProvider) {
1454- return subagents.Credential{}, nil
1455- }
1456- return subagents.Credential{}, err
1457- }
1458- return subagents.Credential{Value: credential, Method: method, AccountID: accountID}, nil
1459- },
1460- })
1461- if args.PermissionSet != nil {
1462- // Packaged zutfile agents have an explicit capability ceiling. A
1463- // child process would otherwise rebuild a fresh unrestricted sandbox,
1464- // so do not expose either slash or tool-based delegation from this
1465- // restricted host until worker capability propagation exists.
1466- subagentsMgr = nil
1467- } else {
1468- // Replaying every historical event log can take seconds for users with
1469- // many completed subagents. The manager is safe for concurrent snapshots,
1470- // so populate the detached-agent dashboard in the background instead of
1471- // blocking the first interactive paint.
1472- go func() { _, _ = subagentsMgr.Reload() }()
1473- }
1474-
1475- // onSpawnedSupervisor is the OnSpawned callback the subagent_spawn tool
1476- // fires after every successful spawn. It hands the agent off to
1477- // the running Interactive so the watcher can flush a summary back
1478- // into chat when all sub-agents finish. Reads `iv` lazily because
1479- // the Interactive is constructed after the agent.
1440+ // These callbacks deliberately resolve iv lazily because the Interactive
1441+ // instance is constructed after the agent and its registry.
14801442 onSpawnedSupervisor := func(a *subagents.Agent, task string) {
14811443 if iv != nil {
14821444 iv.TrackSubagentWorker(a, task)
@@ -1487,86 +1449,77 @@ func runInteractive(ctx context.Context, args Args, version string) (runErr erro
14871449 iv.TrackResumedSubagentWorker(a, prompt)
14881450 }
14891451 }
1452+ onBeforeResumedSupervisor := func(a *subagents.Agent, prompt string) func() {
1453+ if iv != nil {
1454+ return iv.PrepareResumedSubagentWorker(a, prompt)
1455+ }
1456+ return nil
1457+ }
14901458 onStopRequestedSupervisor := func(a *subagents.Agent) {
14911459 if iv != nil {
14921460 iv.TrackStoppedSubagentWorker(a)
14931461 }
14941462 }
1495- resolveSubagent := func(name string) (*subagents.Profile, error) {
1496- return findSubagentProfile(r.CWD, name)
1497- }
1498-
1499- // Register the canonical subagent tools whenever launch-time policy permits
1500- // them. The auto-subagents setting controls strict orchestration guidance,
1501- // not explicit user-requested delegation.
1502- injectSubagentTools := func(reg core.Registry) core.Registry {
1503- if reg == nil || subagentsMgr == nil || !autoSubagentsAnyToolAllowed(args) {
1504- return reg
1505- }
1506- if autoSubagentsToolAllowed(args) {
1507- canonical := &tools.SubagentSpawnTool{
1508- Supervisor: subagentsMgr,
1509- Enabled: func() bool { return true },
1510- DefaultModel: func() string {
1511- persistMu.Lock()
1512- defer persistMu.Unlock()
1513- return activeModel
1514- },
1515- DefaultProvider: func() string {
1516- persistMu.Lock()
1517- defer persistMu.Unlock()
1518- return activeProvider
1519- },
1520- DefaultReasoning: func() string { return r.Reasoning },
1521- ResolveSubagent: resolveSubagent,
1522- OnSpawned: onSpawnedSupervisor,
1523- }
1524- reg[canonical.Name()] = canonical
1525- }
1526- if autoSubagentsStatusToolAllowed(args) {
1527- statusTool := &tools.SubagentStatusTool{
1528- Supervisor: subagentsMgr,
1529- Enabled: func() bool { return true },
1530- }
1531- reg[statusTool.Name()] = statusTool
1532- }
1533- if autoSubagentsStopToolAllowed(args) {
1534- stopTool := &tools.SubagentStopTool{
1535- Supervisor: subagentsMgr,
1536- Enabled: func() bool { return true },
1537- OnStopRequested: onStopRequestedSupervisor,
1538- }
1539- reg[stopTool.Name()] = stopTool
1540- }
1541- if autoSubagentsResumeToolAllowed(args) {
1542- resumeTool := &tools.SubagentResumeTool{
1543- Supervisor: subagentsMgr,
1544- Enabled: func() bool { return true },
1545- OnResumed: onResumedSupervisor,
1546- }
1547- reg[resumeTool.Name()] = resumeTool
1548- }
1549- return reg
1463+ activeProviderForSubagents := func() string {
1464+ persistMu.Lock()
1465+ defer persistMu.Unlock()
1466+ return activeProvider
15501467 }
1551- prepareInteractiveRegistry := func(reg core.Registry) core.Registry {
1552- reg = injectSubagentTools(reg)
1553- return webSearchGuard.wrapRegistry(reg)
1468+ activeModelForSubagents := func() string {
1469+ persistMu.Lock()
1470+ defer persistMu.Unlock()
1471+ return activeModel
15541472 }
1555- prepareResolvedInteractiveRegistry := func(reg core.Registry, policy subagents.WebSearchPolicy) core.Registry {
1556- // The resolved policy is a ceiling. In particular, an extension named
1557- // web_search must not reintroduce the capability when --no-tools,
1558- // --tools, a packaged PermissionSet, or persisted settings deny it.
1559- if policy != subagents.WebSearchAllow {
1560- delete(reg, "web_search")
1561- }
1562- return prepareInteractiveRegistry(reg)
1473+
1474+ runtime = newSubagentRuntime(subagentRuntimeConfig{
1475+ Context: ctx,
1476+ Args: args,
1477+ Root: filepath.Join(ZutHome(), "subagents"),
1478+ RepoRoot: r.CWD,
1479+ Provider: r.Provider,
1480+ Model: r.Model,
1481+ Reasoning: r.Reasoning,
1482+ BaseURL: r.BaseURL,
1483+ InsecureTLS: r.InsecureTLS,
1484+ FastMode: r.FastMode,
1485+ APIKey: args.APIKey,
1486+ Policy: subagentPolicyFromConfig(initialCfg.Subagents),
1487+ WebSearchPolicy: webSearchPolicyForRegistry(r.WebSearchPolicy, r.ToolRegistry),
1488+ WebSearchGuard: webSearchGuard,
1489+ ActiveProvider: activeProviderForSubagents,
1490+ ActiveModel: activeModelForSubagents,
1491+ OnSpawned: onSpawnedSupervisor,
1492+ OnResumed: onResumedSupervisor,
1493+ BeforeResumed: onBeforeResumedSupervisor,
1494+ OnStopRequested: onStopRequestedSupervisor,
1495+ })
1496+ subagentsMgr = runtime.Supervisor()
1497+ reloadDone := make(chan struct{})
1498+ var reloadErrs []error
1499+ if subagentsMgr != nil {
1500+ // Replaying historical event logs can take seconds. Populate the
1501+ // detached-agent dashboard without blocking the first interactive paint.
1502+ go func() {
1503+ _, reloadErrs = subagentsMgr.Reload()
1504+ close(reloadDone)
1505+ }()
1506+ } else {
1507+ close(reloadDone)
15631508 }
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+ }()
1515+
1516+ prepareInteractiveRegistry := runtime.PrepareRegistry
1517+ prepareResolvedInteractiveRegistry := runtime.PrepareResolvedRegistry
15641518 prepareResolvedInteractiveRegistry(r.ToolRegistry, r.WebSearchPolicy)
15651519 initialWebSearchPolicy := webSearchPolicyForRegistry(r.WebSearchPolicy, r.ToolRegistry)
15661520 webSearchGuard.setAvailable(initialWebSearchPolicy == subagents.WebSearchAllow)
1567- if subagentsMgr != nil {
1568- subagentsMgr.SetWebSearchPolicy(initialWebSearchPolicy)
1569- }
1521+ runtime.SetWebSearchPolicy(initialWebSearchPolicy)
1522+ resolveSubagent := runtime.resolveSubagent
15701523
15711524 // Confirmation gate: when --no-yolo is on, the agent must ask
15721525 // the user before every tool call. In interactive mode the TUI
@@ -1650,10 +1603,9 @@ func runInteractive(ctx context.Context, args Args, version string) (runErr erro
16501603 return nil, "", "", err
16511604 }
16521605 resolved.UseSandbox(sharedSandbox)
1653- if subagentsMgr != nil {
1654- subagentsMgr.SetProvider(resolved.Provider)
1655- subagentsMgr.SetProviderSettings(resolved.BaseURL, resolved.InsecureTLS)
1656- }
1606+ runtime.SetProvider(resolved.Provider)
1607+ runtime.SetProviderSettings(resolved.BaseURL, resolved.InsecureTLS)
1608+ runtime.SetFastMode(resolved.FastMode)
16571609 resolved.MergeExtensionTools(extToolAdapter)
16581610 prepareResolvedInteractiveRegistry(resolved.ToolRegistry, resolved.WebSearchPolicy)
16591611 return wireAgentExt(resolved.NewAgent()), resolved.Provider, resolved.Model, nil
@@ -1673,10 +1625,9 @@ func runInteractive(ctx context.Context, args Args, version string) (runErr erro
16731625 return nil, "", "", err
16741626 }
16751627 resolved.UseSandbox(sharedSandbox)
1676- if subagentsMgr != nil {
1677- subagentsMgr.SetProvider(resolved.Provider)
1678- subagentsMgr.SetProviderSettings(resolved.BaseURL, resolved.InsecureTLS)
1679- }
1628+ runtime.SetProvider(resolved.Provider)
1629+ runtime.SetProviderSettings(resolved.BaseURL, resolved.InsecureTLS)
1630+ runtime.SetFastMode(resolved.FastMode)
16801631 resolved.MergeExtensionTools(extToolAdapter)
16811632 prepareResolvedInteractiveRegistry(resolved.ToolRegistry, resolved.WebSearchPolicy)
16821633 return wireAgentExt(resolved.NewAgent()), resolved.Provider, resolved.Model, nil
@@ -1704,10 +1655,9 @@ func runInteractive(ctx context.Context, args Args, version string) (runErr erro
17041655 return nil, "", "", err
17051656 }
17061657 resolved.UseSandbox(sharedSandbox)
1707- if subagentsMgr != nil {
1708- subagentsMgr.SetProvider(resolved.Provider)
1709- subagentsMgr.SetProviderSettings(resolved.BaseURL, resolved.InsecureTLS)
1710- }
1658+ runtime.SetProvider(resolved.Provider)
1659+ runtime.SetProviderSettings(resolved.BaseURL, resolved.InsecureTLS)
1660+ runtime.SetFastMode(resolved.FastMode)
17111661 resolved.MergeExtensionTools(extToolAdapter)
17121662 prepareResolvedInteractiveRegistry(resolved.ToolRegistry, resolved.WebSearchPolicy)
17131663 return wireAgentExt(resolved.NewAgent()), resolved.Provider, resolved.Model, nil
@@ -1927,19 +1877,24 @@ func runInteractive(ctx context.Context, args Args, version string) (runErr erro
19271877 persistMu.Unlock()
19281878
19291879 // A candidate build must not publish its provider/model as the live
1930- // selection before the candidate commits.
1931- candidate, err := prepareSessionResume(path, currentAg, currentProvider, currentModel, baseBuildAgentFor)
1932- if err != nil {
1933- // prepareSessionResume closes its append handle on failure;
1934- // no live agent or session has been changed.
1935- return err
1936- }
1880+ // selection before the candidate commits. The builder also stages
1881+ // provider settings on the shared runtime, so restore those settings on
1882+ // every failed path, including errors returned during preparation.
1883+ runtimeConfig := runtime.snapshotConfiguration()
19371884 committed := false
1885+ var candidate sessionResumeCandidate
19381886 defer func() {
19391887 if !committed {
1888+ runtime.restoreConfiguration(runtimeConfig)
19401889 loadErr = joinSessionCloseError(loadErr, candidate.session)
19411890 }
19421891 }()
1892+ candidate, err := prepareSessionResume(path, currentAg, currentProvider, currentModel, baseBuildAgentFor)
1893+ if err != nil {
1894+ // prepareSessionResume closes its append handle on failure;
1895+ // no live agent or session has been changed.
1896+ return err
1897+ }
19431898
19441899 // Wire the private candidate before it can become visible. This is
19451900 // also required for legacy same-provider resumes, where the current
@@ -1995,7 +1950,7 @@ func runInteractive(ctx context.Context, args Args, version string) (runErr erro
19951950 if iv != nil {
19961951 iv.SetSubagentSessionScope(candidate.session.ID)
19971952 } else {
1998- subagentsMgr.SetActiveSession(candidate.session.ID)
1953+ runtime.SetActiveSession(candidate.session.ID)
19991954 }
20001955 }
20011956 committed = true
@@ -2073,10 +2028,7 @@ func runInteractive(ctx context.Context, args Args, version string) (runErr erro
20732028 oldActiveProvider := activeProvider
20742029 oldActiveModel := activeModel
20752030 persistMu.Unlock()
2076- oldSupervisorRoot := ""
2077- if subagentsMgr != nil {
2078- oldSupervisorRoot = subagentsMgr.RepoRoot()
2079- }
2031+ runtimeConfig := runtime.snapshotConfiguration()
20802032 oldSandboxRoot := ""
20812033 oldSandboxLocked := false
20822034 if sharedSandbox != nil {
@@ -2091,9 +2043,7 @@ func runInteractive(ctx context.Context, args Args, version string) (runErr erro
20912043 activeProvider = oldActiveProvider
20922044 activeModel = oldActiveModel
20932045 persistMu.Unlock()
2094- if subagentsMgr != nil {
2095- subagentsMgr.SetRepoRoot(oldSupervisorRoot)
2096- }
2046+ runtime.restoreConfiguration(runtimeConfig)
20972047 if sharedSandbox != nil {
20982048 sharedSandbox.Root = oldSandboxRoot
20992049 if oldSandboxLocked {
@@ -2106,9 +2056,7 @@ func runInteractive(ctx context.Context, args Args, version string) (runErr erro
21062056
21072057 args.CWD = absPath
21082058 r.CWD = absPath
2109- if subagentsMgr != nil {
2110- subagentsMgr.SetRepoRoot(absPath)
2111- }
2059+ runtime.SetRepoRoot(absPath)
21122060 if args.PermissionSet != nil {
21132061 expanded := args.PermissionSet.Expand(absPath, args.AgentDataDir)
21142062 args.PermissionSet = &expanded
@@ -2189,11 +2137,11 @@ func runInteractive(ctx context.Context, args Args, version string) (runErr erro
21892137 }
21902138
21912139 // Re-scope the subagent dashboard to the new session.
2192- if subagentsMgr != nil && newSess != nil {
2140+ if newSess != nil {
21932141 if iv != nil {
21942142 iv.SetSubagentSessionScope(newSess.ID)
21952143 } else {
2196- subagentsMgr.SetActiveSession(newSess.ID)
2144+ runtime.SetActiveSession(newSess.ID)
21972145 }
21982146 }
21992147 if newSess != nil {
@@ -2272,25 +2220,10 @@ func runInteractive(ctx context.Context, args Args, version string) (runErr erro
22722220 }
22732221 }
22742222
2275- // subagentsMgr was constructed and reloaded earlier (before the agent
2276- // build, so the auto-subagents tool could capture it). Here we just
2277- // scope the dashboard to the active host session so /subagents only
2278- // shows agents this session spawned (and any pre-upgrade unscoped
2279- // agents — see SnapshotAll docs). Updated again whenever the
2280- // user swaps sessions via loadSession below.
2281- if sess != nil && subagentsMgr != nil {
2282- subagentsMgr.SetActiveSession(sess.ID)
2283- }
2284- // Best-effort shutdown on normal interactive exit: stop only workers
2285- // owned by the active host session. Other session scopes can share this
2286- // supervisor and must remain alive.
2287- if subagentsMgr != nil {
2288- defer func() {
2289- rootSessionID := subagentsMgr.ActiveSession()
2290- if rootSessionID != "" {
2291- _ = subagentsMgr.StopByRootSession(rootSessionID)
2292- }
2293- }()
2223+ // Scope the dashboard to the active host session. The runtime owns
2224+ // shutdown separately and closes the supervisor exactly once.
2225+ if sess != nil {
2226+ runtime.SetActiveSession(sess.ID)
22942227 }
22952228
22962229 var startupSkills []*skills.Skill
@@ -2315,7 +2248,7 @@ func runInteractive(ctx context.Context, args Args, version string) (runErr erro
23152248 if available {
23162249 policy = subagents.WebSearchAllow
23172250 }
2318- subagentsMgr.SetWebSearchPolicy(policy)
2251+ runtime.SetWebSearchPolicy(policy)
23192252 }
23202253
23212254 iv = modes.NewInteractive(modes.InteractiveConfig{
@@ -2363,6 +2296,7 @@ func runInteractive(ctx context.Context, args Args, version string) (runErr erro
23632296 AuthMethod: r.AuthMethod,
23642297 BaseURL: r.BaseURL,
23652298 Reasoning: r.Reasoning,
2299+ OnReasoningChanged: runtime.SetReasoning,
23662300 SystemPrompt: r.SystemPrompt,
23672301 Tools: r.ToolRegistry,
23682302 MaxSteps: r.MaxSteps,
@@ -2573,6 +2507,14 @@ func runInteractive(ctx context.Context, args Args, version string) (runErr erro
25732507 },
25742508 })
25752509 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+ }()
25762518
25772519 // Bind the interactive TUI as the Confirmer. We deferred this
25782520 // until now because the gate is constructed before the TUI
@@ -2624,12 +2566,9 @@ func runInteractive(ctx context.Context, args Args, version string) (runErr erro
26242566 closeAgentLSP(finalAg)
26252567 closeResolvedLSP(r)
26262568 // Stop supervised workers before exiting. os.Exit skips deferred
2627- // cleanup, so relying only on the normal runInteractive defer would
2628- // leave child daemons (and their descendants) alive after a TERM or
2629- // HUP delivered to the host.
2630- if subagentsMgr != nil {
2631- subagentsMgr.StopAll()
2632- }
2569+ // cleanup, so wait for reload and close the shared runtime explicitly.
2570+ <-reloadDone
2571+ _ = closeSubagentRuntimeFresh(runtime)
26332572 // Exit cleanly. Re-raising the signal would skip os.Exit's
26342573 // at-exit hooks; explicit exit is fine because we've already
26352574 // flushed the only at-risk state (the session file) and stopped
@@ -2825,7 +2764,11 @@ func readAllStdin() (string, error) {
28252764 if (fi.Mode() & os.ModeCharDevice) != 0 {
28262765 return "", nil
28272766 }
2828- b, err := io.ReadAll(os.Stdin)
2767+ return readAllStdinFrom(os.Stdin)
2768+}
2769+
2770+func readAllStdinFrom(r io.Reader) (string, error) {
2771+ b, err := io.ReadAll(r)
28292772 return string(b), err
28302773 }
28312774
M packages/agent/cli_session_test.go +52 -0
@@ -18,6 +18,16 @@ import (
1818 "github.com/google/uuid"
1919 )
2020
21+type failingStdinReader struct{}
22+
23+func (failingStdinReader) Read([]byte) (int, error) { return 0, errors.New("stdin failed") }
24+
25+func TestReadAllStdinFromPropagatesReadErrors(t *testing.T) {
26+ if _, err := readAllStdinFrom(failingStdinReader{}); err == nil || !strings.Contains(err.Error(), "stdin failed") {
27+ t.Fatalf("readAllStdinFrom error = %v, want stdin failure", err)
28+ }
29+}
30+
2131 func TestOpenOrCreateSessionResumesByUUIDAcrossCWDAndAgentStores(t *testing.T) {
2232 root := t.TempDir()
2333 cwd := t.TempDir()
@@ -412,6 +422,48 @@ func TestApplyInitialSessionResumeKeepsFreshEmptySessionOwned(t *testing.T) {
412422 }
413423 }
414424
425+func TestApplyInitialSessionResumeWithRuntimePreservesInheritedModelDefaults(t *testing.T) {
426+ t.Setenv("ZUT_HOME", t.TempDir())
427+ if err := SaveConfig(Config{Provider: "ollama", Model: "current-local-model"}); err != nil {
428+ t.Fatal(err)
429+ }
430+ path := syntheticSession(t, "ollama", "stored-local-model", provider.Usage{})
431+ args := Args{Mode: ModePrint, Orchestrate: true, CWD: t.TempDir()}
432+ base, err := Resolve(args, true)
433+ if err != nil {
434+ t.Fatal(err)
435+ }
436+ current := base.NewAgent()
437+ runtime := newSubagentRuntime(subagentRuntimeConfig{
438+ Context: context.Background(),
439+ Args: args,
440+ Root: t.TempDir(),
441+ RepoRoot: args.CWD,
442+ Provider: base.Provider,
443+ Model: base.Model,
444+ })
445+ defer func() { _ = runtime.Close(context.Background()) }()
446+
447+ sess, _, err := core.OpenSession(path)
448+ if err != nil {
449+ t.Fatal(err)
450+ }
451+ gotSess, gotAgent, providerName, model, err := applyInitialSessionResumeWithRuntime(context.Background(), args, base, nil, sess, current, runtime)
452+ if err != nil {
453+ t.Fatal(err)
454+ }
455+ defer gotSess.Close()
456+ if gotAgent == current || providerName != "ollama" || model != "stored-local-model" {
457+ t.Fatalf("resume candidate = rebuilt=%v provider/model=%q/%q", gotAgent == current, providerName, model)
458+ }
459+ if got := runtime.currentModel(); got != "stored-local-model" {
460+ t.Fatalf("runtime inherited model = %q, want stored-local-model", got)
461+ }
462+ if _, ok := gotAgent.ToolsSnapshot()["subagent_spawn"]; !ok {
463+ t.Fatal("resumed parent lost canonical subagent_spawn manager tool")
464+ }
465+}
466+
415467 func TestPrepareSessionResumeRebuildsForStoredProviderAndModel(t *testing.T) {
416468 old := core.NewAgent(nil, "old-model", "", nil)
417469 old.SetMessages([]provider.Message{{
M packages/agent/modes/auto_subagents_test.go +106 -9
@@ -222,7 +222,7 @@ func TestTrackResumedSubagentWorkerDeliversFollowUp(t *testing.T) {
222222 if a.OnTurnEnd == nil {
223223 t.Fatal("resumed worker has no completion watcher")
224224 }
225- a.OnTurnEnd(2, "")
225+ a.OnTurnEnd(1, "")
226226
227227 update := waitForQueuedPrompt(t, iv)
228228 if !strings.Contains(update, "task: "+followUp) {
@@ -233,6 +233,103 @@ func TestTrackResumedSubagentWorkerDeliversFollowUp(t *testing.T) {
233233 }
234234 }
235235
236+func TestTrackResumedSubagentWorkerQueuesActiveFollowUpOnSameWorker(t *testing.T) {
237+ started := make(chan struct{})
238+ mgr := subagents.New(subagents.Config{
239+ Root: t.TempDir(),
240+ RepoRoot: t.TempDir(),
241+ NewRunner: func(*subagents.Agent) subagents.Runner {
242+ return subagents.RunnerFunc(func(ctx context.Context, _ subagents.Sink) error {
243+ select {
244+ case started <- struct{}{}:
245+ default:
246+ }
247+ <-ctx.Done()
248+ return ctx.Err()
249+ })
250+ },
251+ })
252+ first, err := mgr.Spawn(context.Background(), "original task")
253+ if err != nil {
254+ t.Fatal(err)
255+ }
256+ <-started
257+ t.Cleanup(func() {
258+ _ = mgr.Stop(first.ID)
259+ first.Wait()
260+ })
261+
262+ iv := newQueuedAutoSubagentsInteractive()
263+ iv.TrackSubagentWorker(first, first.Task)
264+ const followUp = "queued follow-up"
265+ cancel := iv.PrepareResumedSubagentWorker(first, followUp)
266+ if cancel == nil {
267+ t.Fatal("resume registration returned nil cleanup")
268+ }
269+ if first.OnTurnEnd == nil {
270+ t.Fatal("worker has no completion watcher")
271+ }
272+ first.OnTurnEnd(1, "")
273+ first.OnTurnEnd(2, "")
274+
275+ update := waitForQueuedPrompt(t, iv)
276+ if !strings.Contains(update, "[auto-subagents update] 2 sub-agent(s) finished:") {
277+ t.Fatalf("active follow-up was not batched with the original turn: %q", update)
278+ }
279+ if !strings.Contains(update, "task: "+followUp) {
280+ t.Fatalf("active follow-up task missing: %q", update)
281+ }
282+}
283+
284+func TestCompletionDeliveryHoldBatchesFastAndLateRegistrations(t *testing.T) {
285+ mgr := subagents.New(subagents.Config{
286+ Root: t.TempDir(),
287+ RepoRoot: t.TempDir(),
288+ NewRunner: func(*subagents.Agent) subagents.Runner {
289+ return subagents.RunnerFunc(func(ctx context.Context, _ subagents.Sink) error {
290+ <-ctx.Done()
291+ return ctx.Err()
292+ })
293+ },
294+ })
295+ first, err := mgr.Spawn(context.Background(), "first task")
296+ if err != nil {
297+ t.Fatal(err)
298+ }
299+ second, err := mgr.Spawn(context.Background(), "second task")
300+ if err != nil {
301+ t.Fatal(err)
302+ }
303+ t.Cleanup(func() {
304+ _ = mgr.Stop(first.ID)
305+ _ = mgr.Stop(second.ID)
306+ first.Wait()
307+ second.Wait()
308+ })
309+
310+ iv := newQueuedAutoSubagentsInteractive()
311+ release := iv.beginCompletionDeliveryHold()
312+ iv.TrackSubagentWorker(first, first.Task)
313+ first.OnTurnEnd(1, "")
314+ iv.TrackSubagentWorker(second, second.Task)
315+ second.OnTurnEnd(1, "")
316+ release()
317+
318+ update := waitForQueuedPrompt(t, iv)
319+ if !strings.Contains(update, "[auto-subagents update] 2 sub-agent(s) finished:") {
320+ t.Fatalf("fast and late registrations were not batched: %q", update)
321+ }
322+ if !strings.Contains(update, "task: first task") || !strings.Contains(update, "task: second task") {
323+ t.Fatalf("batched update omitted one task: %q", update)
324+ }
325+ iv.mu.Lock()
326+ queued := len(iv.queued)
327+ iv.mu.Unlock()
328+ if queued != 1 {
329+ t.Fatalf("queued updates = %d; want exactly one batched update", queued)
330+ }
331+}
332+
236333 func TestCompleteSupervisorWatchReportsTurnOutcomeOnce(t *testing.T) {
237334 started := make(chan struct{})
238335 mgr := subagents.New(subagents.Config{
@@ -257,10 +354,11 @@ func TestCompleteSupervisorWatchReportsTurnOutcomeOnce(t *testing.T) {
257354 })
258355
259356 iv := newQueuedAutoSubagentsInteractive()
260- entry := &subagentWatchEntry{agent: a, task: a.Task}
261- iv.subagentWatch = []*subagentWatchEntry{entry}
262- iv.completeSupervisorWatchEntry(entry, "completed", "")
263- iv.completeSupervisorWatchEntry(entry, "failed", "late error")
357+ iv.TrackSubagentWorker(a, a.Task)
358+ if a.OnTurnEnd == nil {
359+ t.Fatal("worker has no completion watcher")
360+ }
361+ a.OnTurnEnd(1, "")
264362
265363 update := waitForQueuedPrompt(t, iv)
266364 if !strings.Contains(update, "status: completed") {
@@ -308,10 +406,9 @@ func TestAutoSubagentsUpdateQueuedAtTurnEndStartsFollowUp(t *testing.T) {
308406 return
309407 }
310408 once.Do(func() {
311- interactive.flushSupervisorSummary([]*subagentWatchEntry{{
312- agent: &subagents.Agent{},
313- task: "smoke test",
314- }})
409+ instruction := "Briefly summarise the collective outcome for the user. Reference the agents by id. If any failed, suggest a follow-up; otherwise confirm completion. Do not spawn new sub-agents unless the user asks."
410+ update := subagents.FormatCompletionUpdate([]subagents.Completion{{Task: "smoke test"}}, instruction)
411+ interactive.SubmitOrQueue(update, nil)
315412 })
316413 }
317414
M packages/agent/modes/interactive.go +131 -145
@@ -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.
@@ -774,14 +777,14 @@ type Interactive struct {
774777 extPanel *extPanelDialog
775778 llamaConfigured bool
776779
777- // subagentWatch tracks auto-subagents sub-agents the main agent spawned
778- // via subagent_spawn. Each entry holds the agent + the task text;
779- // a per-entry goroutine waits on the agent's terminal state. When
780- // every tracked entry has finished, the watcher flushes a single
781- // summary turn into the main chat (queued if the main agent is
782- // busy, run immediately if idle).
783- subagentWatchMu sync.Mutex
784- subagentWatch []*subagentWatchEntry
780+ // completionTracker observes auto-subagent turn and process completion.
781+ // Delivery is kept separate so every active set is injected as one
782+ // summary turn, preserving the existing queue behavior.
783+ completionTracker *subagents.CompletionTracker
784+ completionDeliveryMu sync.Mutex
785+ completionDeliveryRunning bool
786+ completionDeliveryRequest bool
787+ completionDeliveryHolds int
785788
786789 // subagentsWaitWatcherDone is an optional test hook invoked when a
787790 // /subagents wait watcher exits.
@@ -1026,7 +1029,9 @@ func (i *Interactive) markCtrlCExit() {
10261029
10271030 // Run blocks until the user quits.
10281031 func (i *Interactive) Run(ctx context.Context) error {
1032+ i.mu.Lock()
10291033 i.runCtx = ctx
1034+ i.mu.Unlock()
10301035 term := i.cfg.Terminal
10311036 restore, err := term.EnterRaw()
10321037 if err != nil {
@@ -5629,6 +5634,9 @@ func (i *Interactive) applyReasoningSetting(level string) {
56295634 return
56305635 }
56315636 }
5637+ if i.cfg.OnReasoningChanged != nil {
5638+ i.cfg.OnReasoningChanged(level)
5639+ }
56325640 i.mu.Lock()
56335641 if i.agent != nil {
56345642 i.agent.Reasoning = level
@@ -7672,7 +7680,9 @@ func (i *Interactive) startTurnRequest(parent context.Context, prompt string, im
76727680 i.handleEventForPresentation(ev)
76737681 }
76747682
7683+ releaseCompletionHold := i.beginCompletionDeliveryHold()
76757684 go func() {
7685+ defer releaseCompletionHold()
76767686 var err error
76777687 if continueExisting {
76787688 err = i.agent.Continue(ctx, sink)
@@ -8694,177 +8704,153 @@ func (a telegramSenderAdapter) Active() bool {
86948704 return a.bridge != nil && a.bridge.Active()
86958705 }
86968706
8697-// subagentWatchEntry is one tracked auto-subagents sub-agent. It records the
8698-// delegated turn's outcome separately from the long-lived daemon status.
8699-type subagentWatchEntry struct {
8700- agent *subagents.Agent
8701- task string
8702- followUp bool
8703- done bool
8704- outcome string
8705- err string
8706-}
8707-
87088707 // TrackSubagentWorker is the exported entry point used by the cli to
8709-// hand a freshly-spawned auto-subagents agent off to the watcher.
8708+// hand a freshly-spawned auto-subagents agent off to the shared tracker.
87108709 func (i *Interactive) TrackSubagentWorker(a *subagents.Agent, task string) {
8711- i.trackSubagentWorker(a, task, false)
8710+ i.trackSubagentWorker(a, task, false, false)
87128711 }
87138712
87148713 // TrackResumedSubagentWorker watches a resumed follow-up independently of the
87158714 // worker's original task. A long-lived daemon can complete several manager
87168715 // turns, each of which must produce its own automatic delivery.
87178716 func (i *Interactive) TrackResumedSubagentWorker(a *subagents.Agent, prompt string) {
8718- i.trackSubagentWorker(a, prompt, true)
8717+ i.trackSubagentWorker(a, prompt, true, false)
8718+}
8719+
8720+// PrepareResumedSubagentWorker registers a resumed turn before its prompt is
8721+// sent. The returned cleanup removes the registration if the resume operation
8722+// fails before delivery. It is used by the resume tool's pre-send hook; the
8723+// existing TrackResumedSubagentWorker signature remains the post-success API.
8724+func (i *Interactive) PrepareResumedSubagentWorker(a *subagents.Agent, prompt string) func() {
8725+ return i.trackSubagentWorker(a, prompt, true, true)
87198726 }
87208727
87218728 // TrackStoppedSubagentWorker watches a requested worker shutdown. If an active
8722-// task watcher already owns the worker, that watcher reports the terminal
8723-// outcome; otherwise this creates a single exit-only watcher, including for a
8724-// worker restored by Reload.
8729+// task watcher already owns the worker, the shared tracker leaves that watcher
8730+// responsible for the terminal outcome.
87258731 func (i *Interactive) TrackStoppedSubagentWorker(a *subagents.Agent) {
87268732 if i == nil || a == nil {
87278733 return
87288734 }
8729- i.subagentWatchMu.Lock()
8730- for _, existing := range i.subagentWatch {
8731- if existing.agent.ID == a.ID && !existing.done {
8732- i.subagentWatchMu.Unlock()
8733- return
8734- }
8735- }
8736- entry := &subagentWatchEntry{agent: a, task: a.Task}
8737- i.subagentWatch = append(i.subagentWatch, entry)
8738- i.subagentWatchMu.Unlock()
8735+ tracker := i.ensureCompletionTracker()
8736+ tracker.TrackExit(a, a.Task)
87398737 i.invalidate()
8740- i.watchSubagentExit(entry)
8738+ i.requestCompletionDelivery()
87418739 }
87428740
8743-// trackSubagentWorker records an auto-subagents turn and subscribes to both
8744-// ways it can finish. Successful long-lived agents report a prompt-level
8745-// turn_end and keep listening; startup failures and unexpected daemon exits
8746-// only unblock Agent.Wait. Whichever signal arrives first completes the entry
8747-// exactly once.
8748-func (i *Interactive) trackSubagentWorker(a *subagents.Agent, task string, followUp bool) {
8741+func (i *Interactive) trackSubagentWorker(a *subagents.Agent, task string, followUp, future bool) func() {
87498742 if i == nil || a == nil {
8750- return
8743+ return nil
8744+ }
8745+ tracker := i.ensureCompletionTracker()
8746+ var cancel func()
8747+ if future {
8748+ cancel = tracker.TrackFutureTurn(a, task, followUp)
8749+ } else {
8750+ cancel = tracker.TrackTurn(a, task, followUp)
87518751 }
8752- entry := &subagentWatchEntry{agent: a, task: task, followUp: followUp}
8753- i.subagentWatchMu.Lock()
8754- i.subagentWatch = append(i.subagentWatch, entry)
8755- i.subagentWatchMu.Unlock()
87568752 i.invalidate()
8757-
8758- a.SetOnTurnEnd(func(step int, errMsg string) {
8759- outcome := "completed"
8760- if errMsg != "" {
8761- outcome = "failed"
8762- }
8763- i.completeSupervisorWatchEntry(entry, outcome, errMsg)
8764- })
8765- i.watchSubagentExit(entry)
8753+ i.requestCompletionDelivery()
8754+ return cancel
87668755 }
87678756
8768-func (i *Interactive) watchSubagentExit(entry *subagentWatchEntry) {
8769- go func() {
8770- entry.agent.Wait()
8771- snap := entry.agent.Snapshot()
8772- outcome := string(snap.Status)
8773- if snap.Status == subagents.StatusDone {
8774- outcome = "completed"
8775- }
8776- i.completeSupervisorWatchEntry(entry, outcome, snap.Err)
8777- }()
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.
8760+ i.completionDeliveryMu.Lock()
8761+ defer i.completionDeliveryMu.Unlock()
8762+ if i.completionTracker == nil {
8763+ i.completionTracker = subagents.NewCompletionTracker()
8764+ }
8765+ return i.completionTracker
87788766 }
87798767
8780-func (i *Interactive) completeSupervisorWatchEntry(entry *subagentWatchEntry, outcome, errMsg string) {
8781- i.subagentWatchMu.Lock()
8782- if entry.done {
8783- i.subagentWatchMu.Unlock()
8768+// requestCompletionDelivery starts at most one waiter for the current active
8769+// set. A request arriving while that waiter is formatting or submitting is
8770+// picked up before the waiter exits, so a later worker cannot be lost in the
8771+// handoff between batches.
8772+func (i *Interactive) requestCompletionDelivery() {
8773+ if i == nil {
87848774 return
87858775 }
8786- entry.done = true
8787- entry.outcome = outcome
8788- entry.err = errMsg
8789- allDone := true
8790- for _, e := range i.subagentWatch {
8791- if !e.done {
8792- allDone = false
8793- break
8794- }
8795- }
8796- var batch []*subagentWatchEntry
8797- if allDone {
8798- batch = i.subagentWatch
8799- i.subagentWatch = nil
8776+ i.ensureCompletionTracker()
8777+ i.completionDeliveryMu.Lock()
8778+ i.completionDeliveryRequest = true
8779+ if i.completionDeliveryRunning || i.completionDeliveryHolds != 0 {
8780+ i.completionDeliveryMu.Unlock()
8781+ return
88008782 }
8801- i.subagentWatchMu.Unlock()
8802- if len(batch) != 0 {
8803- i.flushSupervisorSummary(batch)
8783+ i.completionDeliveryRunning = true
8784+ i.completionDeliveryMu.Unlock()
8785+
8786+ go i.deliverCompletionUpdates()
8787+}
8788+
8789+// beginCompletionDeliveryHold keeps all completions observed during one
8790+// parent model turn together. Tool calls are executed sequentially inside the
8791+// core agent, so the release point is the owning registration boundary for
8792+// same-parent-turn batching rather than a timing-based debounce.
8793+func (i *Interactive) beginCompletionDeliveryHold() func() {
8794+ if i == nil {
8795+ return func() {}
88048796 }
8797+ i.completionDeliveryMu.Lock()
8798+ i.completionDeliveryHolds++
8799+ i.completionDeliveryMu.Unlock()
8800+ return i.releaseCompletionDeliveryHold
88058801 }
88068802
8807-// flushSupervisorSummary composes a synthetic user turn describing every
8808-// sub-agent's outcome and injects it via SubmitOrQueue so the main
8809-// agent picks it up at the next safe boundary. The summary is
8810-// phrased as a system update ("Auto-subagents finished: ...") so the
8811-// model treats it as observed state, not as a fresh user request.
8812-func (i *Interactive) flushSupervisorSummary(batch []*subagentWatchEntry) {
8813- if len(batch) == 0 {
8803+func (i *Interactive) releaseCompletionDeliveryHold() {
8804+ if i == nil {
88148805 return
88158806 }
8816- var sb strings.Builder
8817- fmt.Fprintf(&sb, "[auto-subagents update] %d sub-agent(s) finished:\n\n", len(batch))
8818- for idx, e := range batch {
8819- snap := e.agent.Snapshot()
8820- status := e.outcome
8821- if status == "" {
8822- status = string(snap.Status)
8823- }
8824- resultErr := ""
8825- if snap.Result != nil && snap.Result.ShutdownOrigin != "" {
8826- if snap.Result.Status == subagents.ResultCanceled {
8827- status = "cancelled"
8828- }
8829- if snap.Result.Error != nil {
8830- resultErr = snap.Result.Error.Message
8831- }
8832- }
8833- task := e.task
8834- if task == "" || !e.followUp {
8835- task = snap.Task
8836- }
8837- if task == "" {
8838- task = e.task
8839- }
8840- fmt.Fprintf(&sb, "%d. agent %s - status: %s\n", idx+1, snap.ID, status)
8841- fmt.Fprintf(&sb, " task: %s\n", truncateForSummary(task, 240))
8842- if resultErr != "" {
8843- fmt.Fprintf(&sb, " cancellation: %s\n", truncateForSummary(resultErr, 240))
8844- } else if snap.Err != "" {
8845- fmt.Fprintf(&sb, " error: %s\n", truncateForSummary(snap.Err, 240))
8846- } else if e.err != "" {
8847- fmt.Fprintf(&sb, " turn error: %s\n", truncateForSummary(e.err, 240))
8848- }
8849- if snap.LastAssistant != "" {
8850- sb.WriteString(" final response:\n")
8851- sb.WriteString(snap.LastAssistant)
8852- sb.WriteString("\n")
8853- } else if tail := strings.TrimSpace(snap.Tail); tail != "" {
8854- fmt.Fprintf(&sb, " tail: %s\n", truncateForSummary(tail, 600))
8855- }
8856- sb.WriteString("\n")
8807+ start := false
8808+ i.completionDeliveryMu.Lock()
8809+ if i.completionDeliveryHolds > 0 {
8810+ i.completionDeliveryHolds--
8811+ }
8812+ if i.completionDeliveryHolds == 0 && i.completionDeliveryRequest && !i.completionDeliveryRunning {
8813+ i.completionDeliveryRunning = true
8814+ start = true
8815+ }
8816+ i.completionDeliveryMu.Unlock()
8817+ if start {
8818+ go i.deliverCompletionUpdates()
88578819 }
8858- sb.WriteString("Briefly summarise the collective outcome for the user. Reference the agents by id. If any failed, suggest a follow-up; otherwise confirm completion. Do not spawn new sub-agents unless the user asks.")
8859- i.SubmitOrQueue(sb.String(), nil)
88608820 }
88618821
8862-func truncateForSummary(s string, n int) string {
8863- s = strings.TrimSpace(s)
8864- if len(s) <= n {
8865- return s
8822+func (i *Interactive) completionWaitContext() context.Context {
8823+ i.mu.Lock()
8824+ ctx := i.runCtx
8825+ i.mu.Unlock()
8826+ if ctx == nil {
8827+ return context.Background()
8828+ }
8829+ return ctx
8830+}
8831+
8832+func (i *Interactive) deliverCompletionUpdates() {
8833+ for {
8834+ i.completionDeliveryMu.Lock()
8835+ i.completionDeliveryRequest = false
8836+ tracker := i.completionTracker
8837+ i.completionDeliveryMu.Unlock()
8838+
8839+ batch, err := tracker.WaitIdle(i.completionWaitContext())
8840+ if err == nil && len(batch) != 0 {
8841+ instruction := "Briefly summarise the collective outcome for the user. Reference the agents by id. If any failed, suggest a follow-up; otherwise confirm completion. Do not spawn new sub-agents unless the user asks."
8842+ i.SubmitOrQueue(subagents.FormatCompletionUpdate(batch, instruction), nil)
8843+ }
8844+
8845+ i.completionDeliveryMu.Lock()
8846+ if i.completionDeliveryRequest {
8847+ i.completionDeliveryMu.Unlock()
8848+ continue
8849+ }
8850+ i.completionDeliveryRunning = false
8851+ i.completionDeliveryMu.Unlock()
8852+ return
88668853 }
8867- return s[:n-3] + "..."
88688854 }
88698855
88708856 // autoSubagentsAddenda returns the prompt blocks owned by subagent prompting,
@@ -9067,9 +9053,9 @@ func (i *Interactive) applyAutoSubagentsTool() {
90679053 }
90689054 if i.autoSubagentsResumeToolAllowed() {
90699055 resumeTool := &tools.SubagentResumeTool{
9070- Supervisor: i.cfg.Supervisor,
9071- Enabled: func() bool { return true },
9072- OnResumed: i.TrackResumedSubagentWorker,
9056+ Supervisor: i.cfg.Supervisor,
9057+ Enabled: func() bool { return true },
9058+ BeforeResumed: i.PrepareResumedSubagentWorker,
90739059 }
90749060 next[resumeTool.Name()] = resumeTool
90759061 }
M packages/agent/modes/json.go +14 -9
@@ -3,8 +3,9 @@ package modes
33 import (
44 "context"
55 "encoding/json"
6- "fmt"
6+ "errors"
77 "io"
8+ "sync"
89
910 "github.com/bnema/zut/packages/core"
1011 "github.com/bnema/zut/packages/provider"
@@ -24,8 +25,14 @@ 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
29+ var writeErr error
2730 write := func(v any) {
28- _ = enc.Encode(v)
31+ writeMu.Lock()
32+ defer writeMu.Unlock()
33+ if writeErr == nil {
34+ writeErr = enc.Encode(v)
35+ }
2936 }
3037
3138 var runErr error
@@ -42,9 +49,12 @@ func RunJSONWithContextRecovery(ctx context.Context, ag *core.Agent, prompt stri
4249 }
4350
4451 if runErr != nil {
45- fmt.Fprintln(out, `{"type":"error","message":`+jsonString(runErr.Error())+`}`)
52+ write(map[string]any{"type": "error", "message": runErr.Error()})
4653 }
47- return recovery, runErr
54+ writeMu.Lock()
55+ outputErr := writeErr
56+ writeMu.Unlock()
57+ return recovery, errors.Join(runErr, outputErr)
4858 }
4959
5060 // EventToJSON converts an AgentEvent to a JSON-friendly map. The on-wire
@@ -155,8 +165,3 @@ func nullableReasoningTokens(usage provider.Usage) any {
155165 }
156166 return usage.ReasoningTokens
157167 }
158-
159-func jsonString(s string) string {
160- b, _ := json.Marshal(s)
161- return string(b)
162-}
M packages/agent/modes/json_activity_test.go +62 -0
@@ -1,12 +1,74 @@
11 package modes
22
33 import (
4+ "bytes"
5+ "context"
6+ "encoding/json"
7+ "errors"
8+ "strings"
49 "testing"
510 "time"
611
712 "github.com/bnema/zut/packages/core"
13+ "github.com/bnema/zut/packages/provider"
814 )
915
16+func TestRunJSONCompletionUserMessageIsValidJSONL(t *testing.T) {
17+ message := core.EvUserMessage{Message: provider.Message{
18+ Role: provider.RoleUser,
19+ Content: []provider.Content{provider.TextBlock{Text: "[auto-subagents update] worker finished"}},
20+ }}
21+ var line bytes.Buffer
22+ if err := json.NewEncoder(&line).Encode(EventToJSON(message)); err != nil {
23+ t.Fatal(err)
24+ }
25+ var decoded map[string]any
26+ if err := json.Unmarshal(bytes.TrimSpace(line.Bytes()), &decoded); err != nil {
27+ t.Fatalf("completion event is not JSON: %v", err)
28+ }
29+ if decoded["type"] != "user_message" {
30+ t.Fatalf("event type = %#v, want user_message", decoded["type"])
31+ }
32+}
33+
34+type failingJSONWriter struct{ err error }
35+
36+func (w failingJSONWriter) Write([]byte) (int, error) { return 0, w.err }
37+
38+func TestRunJSONPropagatesOutputErrors(t *testing.T) {
39+ providerErr := errors.New("provider unavailable")
40+ writerErr := errors.New("stdout unavailable")
41+ ag := core.NewAgent(&streamTestClient{err: providerErr}, "test-model", "", nil)
42+ _, err := RunJSONWithContextRecovery(context.Background(), ag, "hello", nil, failingJSONWriter{err: writerErr}, nil)
43+ if !errors.Is(err, providerErr) || !errors.Is(err, writerErr) {
44+ t.Fatalf("RunJSONWithContextRecovery error = %v, want provider and output errors", err)
45+ }
46+}
47+
48+func TestRunJSONFailureEmitsOneErrorObject(t *testing.T) {
49+ client := &streamTestClient{err: errors.New("provider unavailable")}
50+ ag := core.NewAgent(client, "test-model", "", nil)
51+ var out bytes.Buffer
52+ _, err := RunJSONWithContextRecovery(context.Background(), ag, "hello", nil, &out, nil)
53+ if err == nil {
54+ t.Fatal("RunJSONWithContextRecovery unexpectedly succeeded")
55+ }
56+ lines := strings.Split(strings.TrimSpace(out.String()), "\n")
57+ errorObjects := 0
58+ for _, line := range lines {
59+ var decoded map[string]any
60+ if err := json.Unmarshal([]byte(line), &decoded); err != nil {
61+ t.Fatalf("invalid JSONL line %q: %v", line, err)
62+ }
63+ if decoded["type"] == "error" {
64+ errorObjects++
65+ }
66+ }
67+ if errorObjects != 1 {
68+ t.Fatalf("error objects = %d, want one; output=%q", errorObjects, out.String())
69+ }
70+}
71+
1072 func TestEventToJSONActivityLifecycle(t *testing.T) {
1173 request := EventToJSON(core.EvRequestStarted{
1274 Provider: "anthropic",
M packages/agent/modes/stream_test.go +4 -0
@@ -11,11 +11,15 @@ import (
1111
1212 type streamTestClient struct {
1313 events []provider.Event
14+ err error
1415 }
1516
1617 func (c *streamTestClient) Name() string { return "stream-test" }
1718
1819 func (c *streamTestClient) Stream(ctx context.Context, req provider.Request) (<-chan provider.Event, error) {
20+ if c.err != nil {
21+ return nil, c.err
22+ }
1923 out := make(chan provider.Event, len(c.events))
2024 go func() {
2125 defer close(out)
M packages/agent/modes/subagents_slash.go +4 -1
@@ -249,6 +249,10 @@ func (i *Interactive) runSubagents(ctx context.Context, args []string) {
249249 return
250250 }
251251 waitWatcherDone := i.subagentsWaitWatcherDone
252+ // Publish the initial state before starting the watcher. A worker may
253+ // already be done, and its completion must be the final status rather
254+ // than being overwritten by a late "waiting" update.
255+ i.subagentsStatus("waiting for "+a.ID, "")
252256 go func() {
253257 defer func() {
254258 if waitWatcherDone != nil {
@@ -265,7 +269,6 @@ func (i *Interactive) runSubagents(ctx context.Context, args []string) {
265269 i.subagentsStatus("completed "+a.ID, "")
266270 }
267271 }()
268- i.subagentsStatus("waiting for "+a.ID, "")
269272 case "resume-session", "resume", "reattach", "reopen":
270273 if rest == "" {
271274 count := i.subagentsDialog.OpenForResume(
M packages/agent/modes/subagents_slash_test.go +3 -0
@@ -290,6 +290,9 @@ func TestRunSupervisorWaitReportsCompletionBeforeCancellation(t *testing.T) {
290290 if err != nil {
291291 t.Fatal(err)
292292 }
293+ // Exercise the ordering-sensitive path deterministically: /wait can be
294+ // invoked after the worker has already completed.
295+ a.Wait()
293296 watcherDone := make(chan struct{})
294297 iv.subagentsWaitWatcherDone = func() { close(watcherDone) }
295298 iv.runSubagents(context.Background(), []string{"wait", a.ID})
A packages/agent/orchestration.go +40 -0
@@ -0,0 +1,40 @@
1+package agent
2+
3+import (
4+ "fmt"
5+ "strings"
6+)
7+
8+// validateOrchestrationArgs is the single launch-time gate for headless
9+// orchestration. Callers should run it before any mode-specific setup and
10+// again after attaching packaged-agent metadata when using `zut run`.
11+func validateOrchestrationArgs(args Args) error {
12+ if !args.Orchestrate {
13+ return nil
14+ }
15+ switch args.Mode {
16+ case ModePrint, ModeStream, ModeJSON:
17+ // These are the non-interactive output modes. Print is the first
18+ // execution path; stream and JSON retain the same eligibility contract
19+ // for callers that select those output protocols.
20+ case ModeRPC:
21+ return fmt.Errorf("--orchestrate is not supported with RPC mode")
22+ case ModeSubagentWorker:
23+ return fmt.Errorf("--orchestrate is not supported in subagent-worker mode")
24+ default:
25+ return fmt.Errorf("--orchestrate requires print, stream, or JSON mode")
26+ }
27+ if strings.TrimSpace(args.Subagent) != "" {
28+ return fmt.Errorf("--orchestrate is not supported with --subagent profiles")
29+ }
30+ if args.PermissionSet != nil || strings.TrimSpace(args.AgentName) != "" || strings.TrimSpace(args.AgentDataDir) != "" {
31+ return fmt.Errorf("--orchestrate is not supported for packaged agents")
32+ }
33+ if args.StatsPath != "" {
34+ return fmt.Errorf("--orchestrate cannot be combined with --stats")
35+ }
36+ if !autoSubagentsToolAllowed(args) {
37+ return fmt.Errorf("--orchestrate requires the subagent_spawn tool")
38+ }
39+ return nil
40+}
A packages/agent/orchestration_cli_e2e_test.go +430 -0
@@ -0,0 +1,430 @@
1+package agent
2+
3+import (
4+ "bytes"
5+ "encoding/json"
6+ "fmt"
7+ "io"
8+ "net/http"
9+ "net/http/httptest"
10+ "os"
11+ "os/exec"
12+ "path/filepath"
13+ "runtime"
14+ "strings"
15+ "sync"
16+ "syscall"
17+ "testing"
18+ "time"
19+)
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+
37+func TestOrchestratedCLITwoDelegationWavesAcrossHeadlessModes(t *testing.T) {
38+ if runtime.GOOS == "windows" {
39+ t.Skip("signal and subprocess semantics differ on Windows")
40+ }
41+ if testing.Short() {
42+ t.Skip("skip CLI orchestration end-to-end test in -short mode")
43+ }
44+ binary := buildOrchestrationCLI(t)
45+ for _, mode := range []string{"print", "stream", "json"} {
46+ t.Run(mode, func(t *testing.T) {
47+ server := newTwoWaveProvider(t)
48+ defer server.Close()
49+ home := t.TempDir()
50+ args := []string{"--orchestrate", "--provider", "openai", "--model", "gpt-5", "--base-url", server.URL(), "--api-key", "test-key"}
51+ switch mode {
52+ case "print":
53+ args = append(args, "--print")
54+ case "stream":
55+ args = append(args, "--stream")
56+ case "json":
57+ args = append(args, "--json")
58+ }
59+ args = append(args, "delegate two waves")
60+ cmd := exec.Command(binary, args...)
61+ cmd.Env = orchestrationCLIEnv(binary, home)
62+ var stdout, stderr bytes.Buffer
63+ cmd.Stdout = &stdout
64+ cmd.Stderr = &stderr
65+ err := cmd.Run()
66+ output := append(append([]byte(nil), stdout.Bytes()...), stderr.Bytes()...)
67+ if err != nil {
68+ t.Fatalf("zut %s: %v\n%s", mode, err, output)
69+ }
70+ if !bytes.Contains(output, []byte("final synthesis")) {
71+ t.Fatalf("%s output omitted final synthesis: %s", mode, output)
72+ }
73+ if server.ParentCalls() != 5 || server.WorkerCalls() < 2 {
74+ t.Fatalf("%s calls = parent %d, worker %d; want two delegation waves and two workers", mode, server.ParentCalls(), server.WorkerCalls())
75+ }
76+ if mode == "json" {
77+ events := assertJSONL(t, stdout.Bytes())
78+ assertJSONOrchestrationWaves(t, events)
79+ }
80+ if mode == "stream" && !bytes.Contains(stdout.Bytes(), []byte("wave one synthesis")) {
81+ t.Fatalf("stream output omitted first parent turn: %s", output)
82+ }
83+ if mode == "stream" && bytes.Contains(stdout.Bytes(), []byte("[auto-subagents update]")) {
84+ t.Fatalf("stream stdout leaked internal completion prompt: %s", stdout.Bytes())
85+ }
86+ if mode == "print" && bytes.Contains(stdout.Bytes(), []byte("wave one synthesis")) {
87+ t.Fatalf("print output leaked intermediate parent turn: %s", output)
88+ }
89+ })
90+ }
91+}
92+
93+func TestOrchestratedCLICancellation(t *testing.T) {
94+ if runtime.GOOS == "windows" {
95+ t.Skip("signal and subprocess semantics differ on Windows")
96+ }
97+ if testing.Short() {
98+ t.Skip("skip CLI orchestration end-to-end test in -short mode")
99+ }
100+ binary := buildOrchestrationCLI(t)
101+ server, workerReady, releaseWorker := newCancellationProvider()
102+ defer server.Close()
103+ defer close(releaseWorker)
104+
105+ cmd := exec.Command(binary, "--orchestrate", "--json", "--provider", "openai", "--model", "gpt-5", "--base-url", server.URL, "--api-key", "test-key", "cancel")
106+ cmd.Env = orchestrationCLIEnv(binary, t.TempDir())
107+ var stdout, stderr bytes.Buffer
108+ cmd.Stdout = &stdout
109+ cmd.Stderr = &stderr
110+ if err := cmd.Start(); err != nil {
111+ t.Fatal(err)
112+ }
113+ waitForWorker := time.NewTimer(10 * time.Second)
114+ select {
115+ case <-workerReady:
116+ if !waitForWorker.Stop() {
117+ <-waitForWorker.C
118+ }
119+ case <-waitForWorker.C:
120+ _ = cmd.Process.Kill()
121+ _ = cmd.Wait()
122+ t.Fatal("worker did not reach the blocking provider request")
123+ }
124+ if err := cmd.Process.Signal(syscall.SIGTERM); err != nil {
125+ t.Fatalf("send cancellation: %v", err)
126+ }
127+ wait := make(chan error, 1)
128+ go func() { wait <- cmd.Wait() }()
129+ select {
130+ case err := <-wait:
131+ if err == nil {
132+ t.Fatal("canceled orchestration unexpectedly succeeded")
133+ }
134+ case <-time.After(10 * time.Second):
135+ _ = cmd.Process.Kill()
136+ t.Fatal("canceled orchestration did not exit")
137+ }
138+
139+ events := assertJSONL(t, stdout.Bytes())
140+ errorCount := 0
141+ for _, event := range events {
142+ if event["type"] == "error" {
143+ errorCount++
144+ }
145+ }
146+ if errorCount != 1 {
147+ t.Fatalf("cancellation error objects = %d, want one; stdout=%s stderr=%s", errorCount, stdout.Bytes(), stderr.Bytes())
148+ }
149+}
150+
151+func TestOrchestratedCLIParentFailure(t *testing.T) {
152+ if runtime.GOOS == "windows" {
153+ t.Skip("signal and subprocess semantics differ on Windows")
154+ }
155+ if testing.Short() {
156+ t.Skip("skip CLI orchestration end-to-end test in -short mode")
157+ }
158+ binary := buildOrchestrationCLI(t)
159+
160+ t.Run("failure-jsonl", func(t *testing.T) {
161+ server := newFailingProvider()
162+ defer server.Close()
163+ cmd := exec.Command(binary, "--orchestrate", "--json", "--provider", "openai", "--model", "gpt-5", "--base-url", server.URL, "--api-key", "test-key", "fail")
164+ cmd.Env = orchestrationCLIEnv(binary, t.TempDir())
165+ var stdout, stderr bytes.Buffer
166+ cmd.Stdout = &stdout
167+ cmd.Stderr = &stderr
168+ err := cmd.Run()
169+ if err == nil {
170+ t.Fatal("failing orchestration unexpectedly succeeded")
171+ }
172+ events := assertJSONL(t, stdout.Bytes())
173+ errorCount := 0
174+ for _, event := range events {
175+ if event["type"] == "error" {
176+ errorCount++
177+ }
178+ }
179+ if errorCount != 1 {
180+ output := append(append([]byte(nil), stdout.Bytes()...), stderr.Bytes()...)
181+ t.Fatalf("error objects = %d, want one: %s", errorCount, output)
182+ }
183+ })
184+}
185+
186+type twoWaveProvider struct {
187+ t *testing.T
188+ server *httptest.Server
189+ mu sync.Mutex
190+ parentCalls int
191+ workerCalls int
192+}
193+
194+func newTwoWaveProvider(t *testing.T) *twoWaveProvider {
195+ p := &twoWaveProvider{t: t}
196+ p.server = httptest.NewServer(http.HandlerFunc(p.serveHTTP))
197+ return p
198+}
199+
200+func (p *twoWaveProvider) URL() string { return p.server.URL }
201+func (p *twoWaveProvider) Close() { p.server.Close() }
202+
203+func (p *twoWaveProvider) ParentCalls() int {
204+ p.mu.Lock()
205+ defer p.mu.Unlock()
206+ return p.parentCalls
207+}
208+
209+func (p *twoWaveProvider) WorkerCalls() int {
210+ p.mu.Lock()
211+ defer p.mu.Unlock()
212+ return p.workerCalls
213+}
214+
215+func (p *twoWaveProvider) serveHTTP(w http.ResponseWriter, r *http.Request) {
216+ body, err := io.ReadAll(r.Body)
217+ if err != nil {
218+ p.t.Errorf("read provider request: %v", err)
219+ return
220+ }
221+ p.mu.Lock()
222+ isParent := requestIsParent(body)
223+ if isParent {
224+ p.parentCalls++
225+ call := p.parentCalls
226+ p.mu.Unlock()
227+ switch call {
228+ case 1:
229+ writeToolCall(w, "wave-one", "wave one")
230+ case 2:
231+ writeText(w, "wave one synthesis")
232+ case 3:
233+ writeToolCall(w, "wave-two", "wave two")
234+ default:
235+ writeText(w, "final synthesis")
236+ }
237+ return
238+ }
239+ p.workerCalls++
240+ worker := p.workerCalls
241+ p.mu.Unlock()
242+ writeText(w, fmt.Sprintf("worker result %d", worker))
243+}
244+
245+func newCancellationProvider() (*httptest.Server, <-chan struct{}, chan<- struct{}) {
246+ workerReady := make(chan struct{})
247+ releaseWorker := make(chan struct{})
248+ var once sync.Once
249+ var mu sync.Mutex
250+ requests := 0
251+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
252+ mu.Lock()
253+ requests++
254+ parent := requests == 1
255+ mu.Unlock()
256+ if parent {
257+ writeToolCall(w, "cancel-worker", "long running worker")
258+ return
259+ }
260+ once.Do(func() { close(workerReady) })
261+ select {
262+ case <-r.Context().Done():
263+ case <-releaseWorker:
264+ }
265+ }))
266+ return server, workerReady, releaseWorker
267+}
268+
269+func newFailingProvider() *httptest.Server {
270+ var mu sync.Mutex
271+ parentCalls := 0
272+ return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
273+ body, _ := io.ReadAll(r.Body)
274+ if requestIsParent(body) {
275+ mu.Lock()
276+ parentCalls++
277+ call := parentCalls
278+ mu.Unlock()
279+ if call == 1 {
280+ writeToolCall(w, "failure-worker", "failure wave")
281+ return
282+ }
283+ w.WriteHeader(http.StatusBadGateway)
284+ _, _ = io.WriteString(w, `{"error":{"message":"synthetic provider failure","type":"server_error"}}`)
285+ return
286+ }
287+ writeText(w, "worker completed before parent failure")
288+ }))
289+}
290+
291+func requestIsParent(body []byte) bool {
292+ var request struct {
293+ Messages []struct {
294+ Role string `json:"role"`
295+ Content json.RawMessage `json:"content"`
296+ } `json:"messages"`
297+ }
298+ if json.Unmarshal(body, &request) != nil {
299+ return false
300+ }
301+ for _, message := range request.Messages {
302+ if message.Role != "user" {
303+ continue
304+ }
305+ var text string
306+ if json.Unmarshal(message.Content, &text) == nil {
307+ if text == "delegate two waves" || text == "fail" || text == "cancel" {
308+ return true
309+ }
310+ }
311+ if bytes.Contains(message.Content, []byte(`delegate two waves`)) || bytes.Contains(message.Content, []byte(`"text":"fail"`)) || bytes.Contains(message.Content, []byte(`"text":"cancel"`)) {
312+ return true
313+ }
314+ }
315+ return false
316+}
317+
318+func writeToolCall(w http.ResponseWriter, id, task string) {
319+ w.Header().Set("content-type", "text/event-stream")
320+ arguments, _ := json.Marshal(map[string]string{"task": task})
321+ chunk := map[string]any{
322+ "choices": []any{map[string]any{"index": 0, "delta": map[string]any{"tool_calls": []any{map[string]any{"index": 0, "id": id, "type": "function", "function": map[string]string{"name": "subagent_spawn", "arguments": string(arguments)}}}}, "finish_reason": "tool_calls"}},
323+ "usage": map[string]int{"prompt_tokens": 8, "completion_tokens": 2},
324+ }
325+ data, _ := json.Marshal(chunk)
326+ _, _ = fmt.Fprintf(w, "data: %s\n\ndata: [DONE]\n\n", data)
327+}
328+
329+func writeText(w http.ResponseWriter, text string) {
330+ w.Header().Set("content-type", "text/event-stream")
331+ chunk := map[string]any{
332+ "choices": []any{map[string]any{"index": 0, "delta": map[string]string{"content": text}, "finish_reason": "stop"}},
333+ "usage": map[string]int{"prompt_tokens": 8, "completion_tokens": 2},
334+ }
335+ data, _ := json.Marshal(chunk)
336+ _, _ = fmt.Fprintf(w, "data: %s\n\ndata: [DONE]\n\n", data)
337+}
338+
339+func assertJSONL(t *testing.T, output []byte) []map[string]any {
340+ t.Helper()
341+ var events []map[string]any
342+ for _, line := range strings.Split(strings.TrimSpace(string(output)), "\n") {
343+ if strings.TrimSpace(line) == "" || strings.HasPrefix(line, "zut:") {
344+ continue
345+ }
346+ var event map[string]any
347+ if err := json.Unmarshal([]byte(line), &event); err != nil {
348+ t.Fatalf("invalid JSONL line %q: %v", line, err)
349+ }
350+ events = append(events, event)
351+ }
352+ return events
353+}
354+
355+func assertJSONOrchestrationWaves(t *testing.T, events []map[string]any) {
356+ t.Helper()
357+ var userMessages []string
358+ seenTypes := make(map[string]bool)
359+ for _, event := range events {
360+ if typ, ok := event["type"].(string); ok {
361+ seenTypes[typ] = true
362+ }
363+ if event["type"] != "user_message" {
364+ continue
365+ }
366+ content, ok := event["content"].([]any)
367+ if !ok {
368+ t.Fatalf("user_message content = %T, want JSON array", event["content"])
369+ }
370+ var text strings.Builder
371+ for _, block := range content {
372+ object, ok := block.(map[string]any)
373+ if !ok || object["type"] != "text" {
374+ continue
375+ }
376+ if value, ok := object["text"].(string); ok {
377+ text.WriteString(value)
378+ }
379+ }
380+ userMessages = append(userMessages, text.String())
381+ }
382+ if len(userMessages) < 3 {
383+ t.Fatalf("user messages = %d, want initial prompt and two parent completion turns", len(userMessages))
384+ }
385+ completionMessages := 0
386+ for _, message := range userMessages {
387+ if strings.HasPrefix(message, "[auto-subagents update]") {
388+ completionMessages++
389+ }
390+ }
391+ if completionMessages != 2 {
392+ t.Fatalf("completion user messages = %d, want two: %#v", completionMessages, userMessages)
393+ }
394+ for _, typ := range []string{"assistant_message", "tool_call", "tool_execution_started", "tool_result", "usage", "turn_end"} {
395+ if !seenTypes[typ] {
396+ t.Fatalf("orchestrated JSONL omitted %s event; seen types: %v", typ, seenTypes)
397+ }
398+ }
399+ for _, event := range events {
400+ if event["type"] == "error" {
401+ t.Fatalf("successful two-wave orchestration emitted an error event: %#v", event)
402+ }
403+ }
404+}
405+
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.
409+ return append(os.Environ(), "ZUT_HOME="+home, "PATH="+filepath.Dir(binary))
410+}
411+
412+func buildOrchestrationCLI(t *testing.T) string {
413+ t.Helper()
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)
428+ }
429+ return orchestrationBuildBinary
430+}
A packages/agent/orchestration_loop.go +67 -0
@@ -0,0 +1,67 @@
1+package agent
2+
3+import (
4+ "context"
5+ "fmt"
6+
7+ "github.com/bnema/zut/packages/agent/subagents"
8+)
9+
10+type completionBatchWaiter interface {
11+ WaitIdle(context.Context) ([]subagents.Completion, error)
12+}
13+
14+// headlessCompletionWaitError marks an error raised while waiting for worker
15+// completions. A parent turn's renderer already owns errors raised by its
16+// prompt, so callers should only render this wrapper outside that turn.
17+type headlessCompletionWaitError struct {
18+ err error
19+}
20+
21+func (e *headlessCompletionWaitError) Error() string {
22+ if e == nil || e.err == nil {
23+ return "completion wait failed"
24+ }
25+ return e.err.Error()
26+}
27+
28+func (e *headlessCompletionWaitError) Unwrap() error {
29+ if e == nil {
30+ return nil
31+ }
32+ return e.err
33+}
34+
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+)
39+
40+// runHeadlessContinuation waits for one completion batch and gives the parent
41+// one follow-up turn. A follow-up may register another batch while it runs,
42+// which is how dependency waves continue without polling. The initial parent
43+// turn is supplied by finalText; no worker path therefore returns immediately.
44+func runHeadlessContinuation(ctx context.Context, tracker completionBatchWaiter, finalText string, runParent func(context.Context, string) (string, error)) (string, error) {
45+ if tracker == nil || runParent == nil {
46+ return finalText, nil
47+ }
48+ waves := 0
49+ for {
50+ batch, err := tracker.WaitIdle(ctx)
51+ if err != nil {
52+ return finalText, &headlessCompletionWaitError{err: err}
53+ }
54+ if len(batch) == 0 {
55+ return finalText, nil
56+ }
57+ if waves >= maxHeadlessCompletionWaves {
58+ return finalText, fmt.Errorf("orchestration exceeded the maximum of %d completion waves", maxHeadlessCompletionWaves)
59+ }
60+ update := subagents.FormatCompletionUpdate(batch, headlessCompletionInstruction)
61+ finalText, err = runParent(ctx, update)
62+ if err != nil {
63+ return finalText, err
64+ }
65+ waves++
66+ }
67+}
A packages/agent/orchestration_loop_test.go +121 -0
@@ -0,0 +1,121 @@
1+package agent
2+
3+import (
4+ "context"
5+ "errors"
6+ "strings"
7+ "testing"
8+
9+ "github.com/bnema/zut/packages/agent/subagents"
10+)
11+
12+type fakeCompletionBatches struct {
13+ batches [][]subagents.Completion
14+ errors []error
15+ waits int
16+}
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+
25+func (f *fakeCompletionBatches) WaitIdle(context.Context) ([]subagents.Completion, error) {
26+ index := f.waits
27+ f.waits++
28+ if index < len(f.errors) && f.errors[index] != nil {
29+ return nil, f.errors[index]
30+ }
31+ if index >= len(f.batches) {
32+ return nil, nil
33+ }
34+ return f.batches[index], nil
35+}
36+
37+func TestRunHeadlessContinuationNoWorkerPath(t *testing.T) {
38+ tracker := &fakeCompletionBatches{batches: [][]subagents.Completion{{}}}
39+ called := false
40+ got, err := runHeadlessContinuation(context.Background(), tracker, "initial synthesis", func(context.Context, string) (string, error) {
41+ called = true
42+ return "unexpected", nil
43+ })
44+ if err != nil {
45+ t.Fatal(err)
46+ }
47+ if called || got != "initial synthesis" {
48+ t.Fatalf("no-worker result = %q, called=%v", got, called)
49+ }
50+}
51+
52+func TestRunHeadlessContinuationBatchesFailuresAndSecondWave(t *testing.T) {
53+ tracker := &fakeCompletionBatches{batches: [][]subagents.Completion{
54+ {{AgentID: "worker-a", Status: "completed", Task: "inspect"}, {AgentID: "worker-b", Status: "failed", Task: "test", Error: "provider failed"}},
55+ {{AgentID: "worker-c", Status: "completed", Task: "dependent"}},
56+ {},
57+ }}
58+ var prompts []string
59+ got, err := runHeadlessContinuation(context.Background(), tracker, "initial", func(_ context.Context, prompt string) (string, error) {
60+ prompts = append(prompts, prompt)
61+ return "synthesis " + string(rune('0'+len(prompts))), nil
62+ })
63+ if err != nil {
64+ t.Fatal(err)
65+ }
66+ if got != "synthesis 2" || len(prompts) != 2 {
67+ t.Fatalf("result=%q prompts=%d, want second-wave synthesis", got, len(prompts))
68+ }
69+ if !strings.Contains(prompts[0], "worker-b") || !strings.Contains(prompts[0], "provider failed") {
70+ t.Fatalf("first completion batch omitted failed-worker evidence: %q", prompts[0])
71+ }
72+ if !strings.Contains(prompts[1], "worker-c") || strings.Contains(prompts[1], "worker-a") {
73+ t.Fatalf("second completion batch was not isolated: %q", prompts[1])
74+ }
75+ if tracker.waits != 3 {
76+ t.Fatalf("wait count=%d, want completion batches followed by empty terminal batch", tracker.waits)
77+ }
78+}
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+
95+func TestRunHeadlessContinuationCancellation(t *testing.T) {
96+ ctx, cancel := context.WithCancel(context.Background())
97+ cancel()
98+ want := errors.New("canceled")
99+ tracker := &fakeCompletionBatches{errors: []error{want}}
100+ got, err := runHeadlessContinuation(ctx, tracker, "initial", func(context.Context, string) (string, error) {
101+ t.Fatal("parent should not run after cancellation")
102+ return "", nil
103+ })
104+ var waitErr *headlessCompletionWaitError
105+ if !errors.Is(err, want) || !errors.As(err, &waitErr) || got != "initial" {
106+ t.Fatalf("result=(%q,%v), wait error=%v, want initial and cancellation wait error", got, err, waitErr)
107+ }
108+}
109+
110+func TestRunHeadlessContinuationParentErrorDoesNotRetry(t *testing.T) {
111+ tracker := &fakeCompletionBatches{batches: [][]subagents.Completion{{{AgentID: "worker", Status: "completed"}}}}
112+ want := errors.New("parent failed")
113+ calls := 0
114+ got, err := runHeadlessContinuation(context.Background(), tracker, "initial", func(context.Context, string) (string, error) {
115+ calls++
116+ return "partial", want
117+ })
118+ if !errors.Is(err, want) || got != "partial" || calls != 1 {
119+ t.Fatalf("result=(%q,%v), calls=%d, want one non-retried parent failure", got, err, calls)
120+ }
121+}
A packages/agent/orchestration_modes.go +266 -0
@@ -0,0 +1,266 @@
1+package agent
2+
3+import (
4+ "context"
5+ "encoding/json"
6+ "errors"
7+ "fmt"
8+ "os"
9+ "os/signal"
10+ "path/filepath"
11+ "strings"
12+ "syscall"
13+
14+ "github.com/bnema/zut/packages/agent/modes"
15+ "github.com/bnema/zut/packages/agent/subagents"
16+ "github.com/bnema/zut/packages/core"
17+ "github.com/bnema/zut/packages/provider"
18+)
19+
20+type orchestratedModeHooks struct {
21+ promptLabel string
22+
23+ // startupSink is used for entry.pre agent turns. The completion updates
24+ // themselves go through runPrimary and therefore use the same renderer as
25+ // the initial parent turn.
26+ startupSink func() (func(core.AgentEvent), func())
27+ runPrimary func(context.Context, *core.Agent, string, func([]provider.Message) error) (string, modes.ContextRecoveryResult, error)
28+ emitError func(error)
29+ finish func(string) error
30+}
31+
32+// runOrchestratedMode owns the mode-neutral headless lifecycle. Modes provide
33+// only their existing renderer and wire format; the tracker, worker runtime,
34+// startup hooks, transcript persistence, and completion-wave loop are shared.
35+func runOrchestratedMode(parentCtx context.Context, args Args, version string, hooks orchestratedModeHooks) (runErr error) {
36+ ctx, stopSignal := signal.NotifyContext(parentCtx, os.Interrupt, syscall.SIGTERM, syscall.SIGHUP)
37+ defer stopSignal()
38+
39+ if args.NoYolo {
40+ fmt.Fprintf(os.Stderr, "warning: --no-yolo has no effect in %s mode (no interactive prompt available); tools will run without confirmation\n", hooks.promptLabel)
41+ }
42+ r, err := Resolve(args, true)
43+ if err != nil {
44+ return err
45+ }
46+ initialCfg, err := LoadConfig()
47+ if err != nil {
48+ return fmt.Errorf("load config for orchestrated %s: %w", hooks.promptLabel, err)
49+ }
50+ prompt := args.Prompt
51+ if prompt == "" {
52+ piped, readErr := readAllStdin()
53+ if readErr != nil {
54+ return fmt.Errorf("read %s prompt from stdin: %w", hooks.promptLabel, readErr)
55+ }
56+ prompt = strings.TrimSpace(piped)
57+ }
58+ if prompt == "" {
59+ return fmt.Errorf("%s mode requires a prompt (arg or stdin)", hooks.promptLabel)
60+ }
61+ extMgr, stopExt := setupNonInteractiveExtensions(ctx, args, &r, version)
62+ defer stopExt()
63+
64+ tracker := subagents.NewCompletionTracker()
65+ runtime := newOrchestratedRuntime(ctx, args, r, initialCfg, tracker)
66+ defer func() {
67+ if closeErr := closeSubagentRuntimeFresh(runtime); closeErr != nil {
68+ runErr = errors.Join(runErr, closeErr)
69+ }
70+ }()
71+
72+ prepareRegistry := func(reg core.Registry) core.Registry {
73+ return runtime.PrepareRegistry(reg)
74+ }
75+ runtime.SetProvider(r.Provider)
76+ runtime.SetProviderSettings(r.BaseURL, r.InsecureTLS)
77+ runtime.SetFastMode(r.FastMode)
78+ runtime.PrepareResolvedRegistry(r.ToolRegistry, r.WebSearchPolicy)
79+ ag := r.NewAgent()
80+ initialAg := ag
81+ defer func() {
82+ closeAgentLSP(ag)
83+ if ag != initialAg {
84+ closeAgentLSP(initialAg)
85+ }
86+ }()
87+ wireNonInteractiveAgentExtHooks(ctx, ag, extMgr)
88+
89+ sess, err := openOrCreateSession(ctx, args, r, ag, version)
90+ if err != nil {
91+ return err
92+ }
93+ if sess != nil {
94+ defer func() { runErr = joinSessionCloseError(runErr, sess) }()
95+ var providerName, model string
96+ sess, ag, providerName, model, err = applyInitialSessionResumeWithRuntime(ctx, args, r, extMgr, sess, ag, runtime)
97+ if err != nil {
98+ return err
99+ }
100+ r.Provider, r.Model = providerName, model
101+ ag.OnToolResult = func(_ string, result core.ToolResult) { persistExtensionToolResult(extMgr, sess, result) }
102+ runtime.SetActiveSession(sess.ID)
103+ }
104+ announceSession(extMgr, sess)
105+
106+ start := len(ag.Messages())
107+ preSink, finishPre := hooks.startupSink()
108+ preErr := runZutfileStartupPre(ctx, args.StartupPre, r.CWD, r.Sandbox, ag, preSink, os.Stderr)
109+ finishPre()
110+ if preErr != nil {
111+ if hooks.emitError != nil {
112+ hooks.emitError(preErr)
113+ }
114+ return preErr
115+ }
116+ if strings.TrimSpace(args.StartupPre) != "" {
117+ refreshedPolicy, refreshErr := reloadResourcesAfterStartupPreWithRegistry(ctx, args, extMgr, r.Sandbox, ag, prepareRegistry)
118+ if refreshErr != nil {
119+ if hooks.emitError != nil {
120+ hooks.emitError(refreshErr)
121+ }
122+ return refreshErr
123+ }
124+ // entry.pre may have changed the effective permission set. Keep the
125+ // worker supervisor in step with the refreshed parent registry.
126+ runtime.SetWebSearchPolicy(refreshedPolicy)
127+ }
128+
129+ var usagePersistErr error
130+ if sess != nil {
131+ ag.OnUsage = func(cumulative provider.Usage) {
132+ if usagePersistErr == nil {
133+ usagePersistErr = sess.AppendUsage(ag.LastTurnUsage(), cumulative)
134+ }
135+ }
136+ }
137+ var persistCompaction func([]provider.Message) error
138+ if sess != nil {
139+ persistCompaction = func(messages []provider.Message) error {
140+ return sess.AppendCompactionWithUsage(messages, ag.Cost())
141+ }
142+ }
143+
144+ transcriptStart := start
145+ runParent := func(turnCtx context.Context, text string) (string, error) {
146+ output, recovery, turnErr := hooks.runPrimary(turnCtx, ag, text, persistCompaction)
147+ if recovery.Compacted {
148+ transcriptStart = recovery.OutputStart
149+ }
150+ if usagePersistErr != nil {
151+ // Usage persistence happens after the mode renderer has completed.
152+ // JSON therefore needs an explicit terminal error for this failure;
153+ // unlike provider errors, no renderer event has reported it yet.
154+ if turnErr == nil && hooks.emitError != nil {
155+ hooks.emitError(usagePersistErr)
156+ }
157+ return output, errors.Join(turnErr, usagePersistErr)
158+ }
159+ return output, turnErr
160+ }
161+
162+ finalText, err := runParent(ctx, prompt)
163+ if err == nil {
164+ finalText, err = runHeadlessContinuation(ctx, tracker, finalText, runParent)
165+ if err != nil && hooks.emitError != nil {
166+ // A completion wait has no renderer call in which to report its
167+ // failure, so JSON must add its single terminal error object here.
168+ // Errors from the follow-up parent turn were already rendered by
169+ // that mode and must not be emitted a second time.
170+ var waitErr *headlessCompletionWaitError
171+ if errors.As(err, &waitErr) {
172+ hooks.emitError(waitErr)
173+ }
174+ }
175+ }
176+ if persistErr := WriteNewTranscript(ag, sess, transcriptStart); persistErr != nil {
177+ if err == nil && hooks.emitError != nil {
178+ hooks.emitError(persistErr)
179+ }
180+ err = errors.Join(err, persistErr)
181+ }
182+ if err != nil {
183+ return err
184+ }
185+ return hooks.finish(finalText)
186+}
187+
188+func newOrchestratedRuntime(ctx context.Context, args Args, r Resolved, cfg Config, tracker *subagents.CompletionTracker) *subagentRuntime {
189+ onSpawned := func(a *subagents.Agent, task string) {
190+ tracker.TrackTurn(a, task, false)
191+ }
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.
196+ tracker.TrackTurn(a, prompt, true)
197+ }
198+ onBeforeResumed := func(a *subagents.Agent, prompt string) func() {
199+ return tracker.TrackFutureTurn(a, prompt, true)
200+ }
201+ onStopRequested := func(a *subagents.Agent) {
202+ tracker.TrackExit(a, "stopped")
203+ }
204+ return newSubagentRuntime(subagentRuntimeConfig{
205+ Context: ctx,
206+ Args: args,
207+ Root: filepath.Join(ZutHome(), "subagents"),
208+ RepoRoot: r.CWD,
209+ Provider: r.Provider,
210+ Model: r.Model,
211+ Reasoning: r.Reasoning,
212+ BaseURL: r.BaseURL,
213+ InsecureTLS: r.InsecureTLS,
214+ FastMode: r.FastMode,
215+ APIKey: args.APIKey,
216+ Policy: subagentPolicyFromConfig(cfg.Subagents),
217+ WebSearchPolicy: webSearchPolicyForRegistry(r.WebSearchPolicy, r.ToolRegistry),
218+ OnSpawned: onSpawned,
219+ OnResumed: onResumed,
220+ BeforeResumed: onBeforeResumed,
221+ OnStopRequested: onStopRequested,
222+ })
223+}
224+
225+func runOrchestratedStreamMode(ctx context.Context, args Args, version string) error {
226+ return runOrchestratedMode(ctx, args, version, orchestratedModeHooks{
227+ promptLabel: "stream",
228+ startupSink: func() (func(core.AgentEvent), func()) {
229+ return newStreamTextSink(os.Stdout)
230+ },
231+ runPrimary: func(turnCtx context.Context, ag *core.Agent, prompt string, persist func([]provider.Message) error) (string, modes.ContextRecoveryResult, error) {
232+ recovery, err := modes.RunStreamWithContextRecovery(turnCtx, ag, prompt, nil, os.Stdout, os.Stderr, persist)
233+ return "", recovery, err
234+ },
235+ finish: func(string) error { return nil },
236+ })
237+}
238+
239+func runOrchestratedJSONMode(ctx context.Context, args Args, version string) error {
240+ enc := json.NewEncoder(os.Stdout)
241+ var outputErr error
242+ encode := func(value any) {
243+ if outputErr == nil {
244+ outputErr = enc.Encode(value)
245+ }
246+ }
247+ runErr := runOrchestratedMode(ctx, args, version, orchestratedModeHooks{
248+ promptLabel: "json",
249+ startupSink: func() (func(core.AgentEvent), func()) {
250+ return func(ev core.AgentEvent) {
251+ encode(modes.EventToJSON(ev))
252+ }, func() {}
253+ },
254+ runPrimary: func(turnCtx context.Context, ag *core.Agent, prompt string, persist func([]provider.Message) error) (string, modes.ContextRecoveryResult, error) {
255+ recovery, err := modes.RunJSONWithContextRecovery(turnCtx, ag, prompt, nil, os.Stdout, persist)
256+ return "", recovery, err
257+ },
258+ emitError: func(err error) {
259+ if err != nil {
260+ encode(map[string]any{"type": "error", "message": err.Error()})
261+ }
262+ },
263+ finish: func(string) error { return nil },
264+ })
265+ return errors.Join(runErr, outputErr)
266+}
A packages/agent/orchestration_modes_test.go +122 -0
@@ -0,0 +1,122 @@
1+package agent
2+
3+import (
4+ "bufio"
5+ "context"
6+ "encoding/json"
7+ "errors"
8+ "io"
9+ "net/http"
10+ "net/http/httptest"
11+ "os"
12+ "strings"
13+ "testing"
14+)
15+
16+func TestRunOrchestratedStreamUsesStreamRenderer(t *testing.T) {
17+ home := t.TempDir()
18+ t.Setenv("ZUT_HOME", home)
19+ server := orchestratedModeTestServer("stream answer")
20+ defer server.Close()
21+
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+ })
33+ if runErr != nil {
34+ t.Fatalf("runOrchestratedStreamMode error: %v", runErr)
35+ }
36+ if got := output; got != "stream answer\n" {
37+ t.Fatalf("stdout = %q, want one rendered answer newline", got)
38+ }
39+}
40+
41+func TestRunOrchestratedJSONRetainsParentEventsAsJSONL(t *testing.T) {
42+ home := t.TempDir()
43+ t.Setenv("ZUT_HOME", home)
44+ server := orchestratedModeTestServer("json answer")
45+ defer server.Close()
46+
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+ })
58+ if runErr != nil {
59+ t.Fatalf("runOrchestratedJSONMode error: %v", runErr)
60+ }
61+
62+ seen := map[string]bool{}
63+ scanner := bufio.NewScanner(strings.NewReader(output))
64+ for scanner.Scan() {
65+ var event map[string]any
66+ if err := json.Unmarshal(scanner.Bytes(), &event); err != nil {
67+ t.Fatalf("invalid JSONL line %q: %v", scanner.Text(), err)
68+ }
69+ if typ, ok := event["type"].(string); ok {
70+ seen[typ] = true
71+ }
72+ }
73+ if err := scanner.Err(); err != nil {
74+ t.Fatal(err)
75+ }
76+ for _, typ := range []string{"user_message", "assistant_message", "turn_end"} {
77+ if !seen[typ] {
78+ t.Fatalf("JSONL omitted %s event: %s", typ, output)
79+ }
80+ }
81+}
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+
116+func orchestratedModeTestServer(answer string) *httptest.Server {
117+ return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
118+ w.Header().Set("content-type", "text/event-stream")
119+ _, _ = io.WriteString(w, "data: {\"choices\":[{\"index\":0,\"delta\":{\"content\":\""+answer+"\"},\"finish_reason\":\"stop\"}]}\n\n")
120+ _, _ = io.WriteString(w, "data: [DONE]\n\n")
121+ }))
122+}
A packages/agent/orchestration_print.go +34 -0
@@ -0,0 +1,34 @@
1+package agent
2+
3+import (
4+ "bytes"
5+ "context"
6+ "fmt"
7+ "os"
8+ "strings"
9+
10+ "github.com/bnema/zut/packages/agent/modes"
11+ "github.com/bnema/zut/packages/core"
12+ "github.com/bnema/zut/packages/provider"
13+)
14+
15+// runOrchestratedPrintMode is deliberately separate from ordinary print mode:
16+// ordinary print continues to stream its existing one-turn implementation,
17+// while this path keeps every parent turn in memory until terminal synthesis.
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)
31+ return err
32+ },
33+ })
34+}
A packages/agent/orchestration_print_test.go +52 -0
@@ -0,0 +1,52 @@
1+package agent
2+
3+import (
4+ "context"
5+ "os"
6+ "strings"
7+ "testing"
8+)
9+
10+func TestRunOrchestratedPrintEmitsOnlyFinalAnswer(t *testing.T) {
11+ home := t.TempDir()
12+ t.Setenv("ZUT_HOME", home)
13+ server := orchestratedModeTestServer("final answer")
14+ defer server.Close()
15+
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+ })
27+ if runErr != nil {
28+ t.Fatalf("runOrchestratedPrintMode error: %v", runErr)
29+ }
30+ if got := output; got != "final answer\n" {
31+ t.Fatalf("stdout = %q, want final answer only", got)
32+ }
33+}
34+
35+func TestRunOrchestratedPrintPropagatesConfigLoadError(t *testing.T) {
36+ home := t.TempDir()
37+ t.Setenv("ZUT_HOME", home)
38+ if err := os.WriteFile(ConfigPath(), []byte("{"), 0o600); err != nil {
39+ t.Fatal(err)
40+ }
41+
42+ err := runOrchestratedPrintMode(context.Background(), Args{
43+ Mode: ModePrint,
44+ Orchestrate: true,
45+ Provider: "ollama",
46+ Model: "llama3",
47+ Prompt: "test",
48+ }, "test")
49+ if err == nil || !strings.Contains(err.Error(), "load config for orchestrated print") {
50+ t.Fatalf("runOrchestratedPrintMode error = %v, want config load error", err)
51+ }
52+}
A packages/agent/orchestration_shutdown.go +17 -0
@@ -0,0 +1,17 @@
1+package agent
2+
3+import (
4+ "context"
5+ "time"
6+)
7+
8+const headlessShutdownTimeout = 5 * time.Second
9+
10+func closeSubagentRuntimeFresh(rt *subagentRuntime) error {
11+ if rt == nil {
12+ return nil
13+ }
14+ shutdownCtx, cancel := context.WithTimeout(context.Background(), headlessShutdownTimeout)
15+ defer cancel()
16+ return rt.Close(shutdownCtx)
17+}
A packages/agent/subagent_runtime.go +480 -0
@@ -0,0 +1,480 @@
1+package agent
2+
3+import (
4+ "context"
5+ "path/filepath"
6+ "strings"
7+ "sync"
8+ "time"
9+
10+ "github.com/bnema/zut/packages/agent/subagents"
11+ "github.com/bnema/zut/packages/agent/tools"
12+ "github.com/bnema/zut/packages/core"
13+)
14+
15+// subagentRuntime owns the manager-facing runtime assembled around a host
16+// session. Keeping this state together is important: every injected manager
17+// tool must use the same supervisor, model/provider selection, profile root,
18+// policy, callbacks, and shutdown lifecycle.
19+type subagentRuntime struct {
20+ supervisor *subagents.Supervisor
21+ args Args
22+ root string
23+
24+ provider string
25+ model string
26+ reasoning string
27+ baseURL string
28+ insecureTLS bool
29+ fastMode bool
30+ repoRoot string
31+
32+ activeProvider func() string
33+ activeModel func() string
34+ activeReasoning func() string
35+
36+ apiKey string
37+ apiKeyProvider string
38+ webSearchPolicy subagents.WebSearchPolicy
39+ webSearchGuard *webSearchSessionGuard
40+
41+ onSpawned func(*subagents.Agent, string)
42+ onResumed func(*subagents.Agent, string)
43+ beforeResumed func(*subagents.Agent, string) func()
44+ onStopRequested func(*subagents.Agent)
45+
46+ settingsMu sync.RWMutex
47+ closeMu sync.Mutex
48+ closed bool
49+}
50+
51+type subagentRuntimeConfiguration struct {
52+ provider string
53+ model string
54+ baseURL string
55+ insecureTLS bool
56+ fastMode bool
57+ repoRoot string
58+ webSearchPolicy subagents.WebSearchPolicy
59+}
60+
61+// subagentRuntimeConfig supplies the host-owned settings and seams needed to
62+// assemble a subagentRuntime. The active selection callbacks are optional;
63+// when omitted, the initial Provider/Model/Reasoning values are used.
64+type subagentRuntimeConfig struct {
65+ Context context.Context
66+ Args Args
67+ Root string
68+ RepoRoot string
69+
70+ Provider string
71+ Model string
72+ Reasoning string
73+ BaseURL string
74+ InsecureTLS bool
75+ FastMode bool
76+
77+ APIKey string
78+ Policy subagents.SubagentPolicy
79+ WebSearchPolicy subagents.WebSearchPolicy
80+ WebSearchGuard *webSearchSessionGuard
81+ ActiveProvider func() string
82+ ActiveModel func() string
83+ ActiveReasoning func() string
84+ NewRunner func(*subagents.Agent) subagents.Runner
85+
86+ OnSpawned func(*subagents.Agent, string)
87+ OnResumed func(*subagents.Agent, string)
88+ BeforeResumed func(*subagents.Agent, string) func()
89+ OnStopRequested func(*subagents.Agent)
90+}
91+
92+func newSubagentRuntime(cfg subagentRuntimeConfig) *subagentRuntime {
93+ if cfg.Context == nil {
94+ cfg.Context = context.Background()
95+ }
96+ rt := &subagentRuntime{
97+ args: cfg.Args,
98+ root: cfg.Root,
99+ provider: strings.TrimSpace(cfg.Provider),
100+ model: strings.TrimSpace(cfg.Model),
101+ reasoning: strings.TrimSpace(cfg.Reasoning),
102+ baseURL: strings.TrimSpace(cfg.BaseURL),
103+ insecureTLS: cfg.InsecureTLS,
104+ fastMode: cfg.FastMode,
105+ repoRoot: cfg.RepoRoot,
106+ activeProvider: cfg.ActiveProvider,
107+ activeModel: cfg.ActiveModel,
108+ activeReasoning: cfg.ActiveReasoning,
109+ apiKey: cfg.APIKey,
110+ webSearchPolicy: cfg.WebSearchPolicy,
111+ webSearchGuard: cfg.WebSearchGuard,
112+ onSpawned: cfg.OnSpawned,
113+ onResumed: cfg.OnResumed,
114+ beforeResumed: cfg.BeforeResumed,
115+ onStopRequested: cfg.OnStopRequested,
116+ }
117+ if rt.activeProvider == nil {
118+ rt.activeProvider = func() string {
119+ rt.settingsMu.RLock()
120+ defer rt.settingsMu.RUnlock()
121+ return rt.provider
122+ }
123+ }
124+ if rt.activeModel == nil {
125+ rt.activeModel = func() string {
126+ rt.settingsMu.RLock()
127+ defer rt.settingsMu.RUnlock()
128+ return rt.model
129+ }
130+ }
131+ if rt.activeReasoning == nil {
132+ rt.activeReasoning = func() string {
133+ rt.settingsMu.RLock()
134+ defer rt.settingsMu.RUnlock()
135+ return rt.reasoning
136+ }
137+ }
138+ if rt.apiKey != "" {
139+ // A launch-time API key belongs to the provider selected at startup. It
140+ // must not follow the live provider callback through a later transition.
141+ rt.apiKeyProvider = canonicalProvider(rt.provider)
142+ }
143+
144+ // Packaged agents have an explicit capability ceiling. Until worker
145+ // capability propagation exists, they cannot expose delegation tools or a
146+ // fresh unrestricted supervisor.
147+ if cfg.Args.PermissionSet == nil {
148+ root := rt.root
149+ if strings.TrimSpace(root) == "" {
150+ root = filepath.Join(ZutHome(), "subagents")
151+ rt.root = root
152+ }
153+ rt.supervisor = subagents.New(subagents.Config{
154+ Context: cfg.Context,
155+ Root: root,
156+ RepoRoot: cfg.RepoRoot,
157+ Provider: rt.currentProvider(),
158+ FastMode: cfg.FastMode,
159+ WebSearchPolicy: cfg.WebSearchPolicy,
160+ BaseURL: cfg.BaseURL,
161+ InsecureTLS: cfg.InsecureTLS,
162+ NewRunner: cfg.NewRunner,
163+ Policy: cfg.Policy,
164+ ResolveCredential: rt.resolveCredential,
165+ })
166+ }
167+ return rt
168+}
169+
170+func (rt *subagentRuntime) Supervisor() *subagents.Supervisor {
171+ if rt == nil {
172+ return nil
173+ }
174+ return rt.supervisor
175+}
176+
177+func (rt *subagentRuntime) currentProvider() string {
178+ if rt == nil || rt.activeProvider == nil {
179+ return ""
180+ }
181+ return strings.TrimSpace(rt.activeProvider())
182+}
183+
184+func (rt *subagentRuntime) currentModel() string {
185+ if rt == nil || rt.activeModel == nil {
186+ return ""
187+ }
188+ return strings.TrimSpace(rt.activeModel())
189+}
190+
191+func (rt *subagentRuntime) SetModel(model string) {
192+ if rt == nil {
193+ return
194+ }
195+ rt.settingsMu.Lock()
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()
207+}
208+
209+func (rt *subagentRuntime) currentReasoning() string {
210+ if rt == nil || rt.activeReasoning == nil {
211+ return ""
212+ }
213+ return strings.TrimSpace(rt.activeReasoning())
214+}
215+
216+func (rt *subagentRuntime) SetProvider(providerID string) {
217+ if rt == nil {
218+ return
219+ }
220+ providerID = strings.TrimSpace(providerID)
221+ rt.settingsMu.Lock()
222+ rt.provider = providerID
223+ rt.settingsMu.Unlock()
224+ if rt.supervisor != nil {
225+ rt.supervisor.SetProvider(providerID)
226+ }
227+}
228+
229+func (rt *subagentRuntime) SetProviderSettings(baseURL string, insecureTLS bool) {
230+ if rt == nil {
231+ return
232+ }
233+ baseURL = strings.TrimSpace(baseURL)
234+ rt.settingsMu.Lock()
235+ rt.baseURL = baseURL
236+ rt.insecureTLS = insecureTLS
237+ rt.settingsMu.Unlock()
238+ if rt.supervisor != nil {
239+ rt.supervisor.SetProviderSettings(baseURL, insecureTLS)
240+ }
241+}
242+
243+func (rt *subagentRuntime) SetFastMode(fastMode bool) {
244+ if rt == nil {
245+ return
246+ }
247+ rt.settingsMu.Lock()
248+ rt.fastMode = fastMode
249+ rt.settingsMu.Unlock()
250+ if rt.supervisor != nil {
251+ rt.supervisor.SetFastMode(fastMode)
252+ }
253+}
254+
255+func (rt *subagentRuntime) SetRepoRoot(repoRoot string) {
256+ if rt == nil {
257+ return
258+ }
259+ rt.settingsMu.Lock()
260+ rt.repoRoot = repoRoot
261+ rt.settingsMu.Unlock()
262+ if rt.supervisor != nil {
263+ rt.supervisor.SetRepoRoot(repoRoot)
264+ }
265+}
266+
267+// snapshotConfiguration captures the runtime settings changed while an agent
268+// candidate is being built. Transition callers restore it when candidate
269+// preparation fails before the new agent is committed.
270+func (rt *subagentRuntime) snapshotConfiguration() subagentRuntimeConfiguration {
271+ if rt == nil {
272+ return subagentRuntimeConfiguration{}
273+ }
274+ rt.settingsMu.RLock()
275+ defer rt.settingsMu.RUnlock()
276+ return subagentRuntimeConfiguration{
277+ provider: rt.provider,
278+ model: rt.model,
279+ baseURL: rt.baseURL,
280+ insecureTLS: rt.insecureTLS,
281+ fastMode: rt.fastMode,
282+ repoRoot: rt.repoRoot,
283+ webSearchPolicy: rt.webSearchPolicy,
284+ }
285+}
286+
287+func (rt *subagentRuntime) restoreConfiguration(snapshot subagentRuntimeConfiguration) {
288+ if rt == nil {
289+ return
290+ }
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+ }
307+}
308+
309+func (rt *subagentRuntime) SetActiveSession(sessionID string) {
310+ if rt != nil && rt.supervisor != nil {
311+ rt.supervisor.SetActiveSession(sessionID)
312+ }
313+}
314+
315+func (rt *subagentRuntime) SetWebSearchPolicy(policy subagents.WebSearchPolicy) {
316+ if rt == nil {
317+ return
318+ }
319+ rt.settingsMu.Lock()
320+ rt.webSearchPolicy = policy
321+ rt.settingsMu.Unlock()
322+ if rt.supervisor != nil {
323+ rt.supervisor.SetWebSearchPolicy(policy)
324+ }
325+}
326+
327+func (rt *subagentRuntime) resolveCredential(ctx context.Context, providerID string) (subagents.Credential, error) {
328+ childProvider := canonicalProvider(providerID)
329+ if childProvider == "" {
330+ childProvider = canonicalProvider(rt.currentProvider())
331+ }
332+ explicit := ""
333+ // A launch-time API key is scoped to the provider selected at startup.
334+ // Never send it to an explicitly different child provider; resolve that
335+ // provider's own credential instead.
336+ if rt.apiKey != "" && childProvider == rt.apiKeyProvider {
337+ explicit = rt.apiKey
338+ }
339+ if childProvider == "ollama" && explicit == "" {
340+ return subagents.Credential{Value: "ollama", Method: "apikey"}, nil
341+ }
342+ credential, method, accountID, err := ResolveCredentialFullContext(ctx, childProvider, explicit)
343+ if err != nil {
344+ // Providers backed only by a local endpoint do not need a key. Leave
345+ // stdin untouched and let the child resolve that endpoint.
346+ if !CredentialAvailable(childProvider) {
347+ return subagents.Credential{}, nil
348+ }
349+ return subagents.Credential{}, err
350+ }
351+ return subagents.Credential{Value: credential, Method: method, AccountID: accountID}, nil
352+}
353+
354+func (rt *subagentRuntime) resolveSubagent(name string) (*subagents.Profile, error) {
355+ if rt == nil {
356+ return nil, nil
357+ }
358+ rt.settingsMu.RLock()
359+ repoRoot := rt.repoRoot
360+ rt.settingsMu.RUnlock()
361+ return findSubagentProfile(repoRoot, name)
362+}
363+
364+// InjectTools adds the canonical manager tools allowed by the host launch
365+// policy. It deliberately mutates the supplied registry, matching the normal
366+// interactive registry assembly path.
367+func (rt *subagentRuntime) InjectTools(reg core.Registry) core.Registry {
368+ if reg == nil || rt == nil || rt.supervisor == nil || !autoSubagentsAnyToolAllowed(rt.args) {
369+ return reg
370+ }
371+ if autoSubagentsToolAllowed(rt.args) {
372+ spawn := &tools.SubagentSpawnTool{
373+ Supervisor: rt.supervisor,
374+ Enabled: func() bool { return true },
375+ DefaultModel: func() string {
376+ return rt.currentModel()
377+ },
378+ DefaultProvider: func() string {
379+ return rt.currentProvider()
380+ },
381+ DefaultReasoning: func() string {
382+ return rt.currentReasoning()
383+ },
384+ ResolveSubagent: rt.resolveSubagent,
385+ OnSpawned: rt.onSpawned,
386+ }
387+ reg[spawn.Name()] = spawn
388+ }
389+ if autoSubagentsStatusToolAllowed(rt.args) {
390+ status := &tools.SubagentStatusTool{
391+ Supervisor: rt.supervisor,
392+ Enabled: func() bool { return true },
393+ }
394+ reg[status.Name()] = status
395+ }
396+ if autoSubagentsStopToolAllowed(rt.args) {
397+ stop := &tools.SubagentStopTool{
398+ Supervisor: rt.supervisor,
399+ Enabled: func() bool { return true },
400+ OnStopRequested: rt.onStopRequested,
401+ }
402+ reg[stop.Name()] = stop
403+ }
404+ if autoSubagentsResumeToolAllowed(rt.args) {
405+ resume := &tools.SubagentResumeTool{
406+ Supervisor: rt.supervisor,
407+ Enabled: func() bool { return true },
408+ BeforeResumed: rt.beforeResumed,
409+ OnResumed: rt.onResumed,
410+ }
411+ reg[resume.Name()] = resume
412+ }
413+ return reg
414+}
415+
416+func (rt *subagentRuntime) PrepareRegistry(reg core.Registry) core.Registry {
417+ reg = rt.InjectTools(reg)
418+ if rt != nil && rt.webSearchGuard != nil {
419+ return rt.webSearchGuard.wrapRegistry(reg)
420+ }
421+ return reg
422+}
423+
424+func (rt *subagentRuntime) PrepareResolvedRegistry(reg core.Registry, policy subagents.WebSearchPolicy) core.Registry {
425+ // Keep the supervisor's capability policy in lockstep with the resolved
426+ // parent registry. This is especially important after entry.pre refreshes
427+ // permissions: workers launched after the refresh must not retain the old
428+ // parent allow decision.
429+ if rt != nil {
430+ rt.SetWebSearchPolicy(webSearchPolicyForRegistry(policy, reg))
431+ }
432+ if policy != subagents.WebSearchAllow {
433+ delete(reg, "web_search")
434+ }
435+ return rt.PrepareRegistry(reg)
436+}
437+
438+func (rt *subagentRuntime) Close(ctx context.Context) error {
439+ if rt == nil || rt.supervisor == nil {
440+ return nil
441+ }
442+ rt.closeMu.Lock()
443+ defer rt.closeMu.Unlock()
444+ if rt.closed {
445+ return nil
446+ }
447+ if err := rt.supervisor.StopAllContext(ctx); err != nil {
448+ return err
449+ }
450+ rt.closed = true
451+ return nil
452+}
453+
454+func subagentPolicyFromConfig(cfg SubagentsConfig) subagents.SubagentPolicy {
455+ parseDuration := func(value string) time.Duration {
456+ if strings.TrimSpace(value) == "" {
457+ return 0
458+ }
459+ parsed, err := time.ParseDuration(strings.TrimSpace(value))
460+ if err != nil || parsed <= 0 {
461+ return 0
462+ }
463+ return parsed
464+ }
465+ return subagents.SubagentPolicy{
466+ MaxConcurrent: cfg.MaxConcurrent,
467+ MaxConcurrentPerParent: cfg.MaxConcurrentPerParent,
468+ QueueTimeout: parseDuration(cfg.QueueTimeout),
469+ DefaultTimeout: parseDuration(cfg.DefaultTimeout),
470+ MaxTurns: cfg.MaxTurns,
471+ MaxOutputBytes: cfg.MaxOutputBytes,
472+ MaxOutputLines: cfg.MaxOutputLines,
473+ AllowedTools: append([]string(nil), cfg.AllowedTools...),
474+ AllowedRoots: append([]string(nil), cfg.AllowedRoots...),
475+ HeartbeatInterval: parseDuration(cfg.HeartbeatInterval),
476+ IdleTimeout: parseDuration(cfg.IdleTimeout),
477+ ReconnectTimeout: parseDuration(cfg.ReconnectTimeout),
478+ CancelGracePeriod: parseDuration(cfg.CancelGracePeriod),
479+ }
480+}
A packages/agent/subagent_runtime_test.go +418 -0
@@ -0,0 +1,418 @@
1+package agent
2+
3+import (
4+ "context"
5+ "encoding/json"
6+ "errors"
7+ "os"
8+ "path/filepath"
9+ "sync"
10+ "sync/atomic"
11+ "testing"
12+ "time"
13+
14+ "github.com/bnema/zut/packages/agent/subagents"
15+ "github.com/bnema/zut/packages/agent/tools"
16+ "github.com/bnema/zut/packages/core"
17+)
18+
19+type runtimeTestRunner struct {
20+ started chan struct{}
21+ startOne sync.Once
22+ canceled atomic.Int32
23+ block bool
24+}
25+
26+type stubbornRuntimeTestRunner struct {
27+ started chan struct{}
28+ release chan struct{}
29+}
30+
31+func (r *runtimeTestRunner) Run(ctx context.Context, _ subagents.Sink) error {
32+ if r.started != nil {
33+ r.startOne.Do(func() { close(r.started) })
34+ }
35+ if !r.block {
36+ return nil
37+ }
38+ <-ctx.Done()
39+ r.canceled.Add(1)
40+ return ctx.Err()
41+}
42+
43+func (r *stubbornRuntimeTestRunner) Run(context.Context, subagents.Sink) error {
44+ close(r.started)
45+ <-r.release
46+ return nil
47+}
48+
49+func newRuntimeTestConfig(root, repoRoot string, runner func(*subagents.Agent) subagents.Runner) subagentRuntimeConfig {
50+ return subagentRuntimeConfig{
51+ Context: context.Background(),
52+ Root: root,
53+ RepoRoot: repoRoot,
54+ Provider: "parent-provider",
55+ Model: "parent-model",
56+ Reasoning: "high",
57+ BaseURL: "https://parent.example/v1",
58+ InsecureTLS: true,
59+ FastMode: true,
60+ Policy: subagents.SubagentPolicy{
61+ MaxConcurrent: 2,
62+ MaxTurns: 4,
63+ DefaultTimeout: time.Minute,
64+ CancelGracePeriod: time.Second,
65+ },
66+ NewRunner: runner,
67+ }
68+}
69+
70+func TestSubagentRuntimeLaunchCredentialStaysWithLaunchProvider(t *testing.T) {
71+ activeProvider := "parent-provider"
72+ cfg := newRuntimeTestConfig(t.TempDir(), t.TempDir(), func(*subagents.Agent) subagents.Runner {
73+ return &runtimeTestRunner{}
74+ })
75+ cfg.APIKey = "launch-key"
76+ cfg.ActiveProvider = func() string { return activeProvider }
77+ rt := newSubagentRuntime(cfg)
78+ defer func() { _ = rt.Close(context.Background()) }()
79+
80+ credential, err := rt.resolveCredential(context.Background(), "parent-provider")
81+ if err != nil {
82+ t.Fatalf("launch provider credential: %v", err)
83+ }
84+ if credential.Value != "launch-key" {
85+ t.Fatalf("launch provider credential = %q, want launch key", credential.Value)
86+ }
87+
88+ activeProvider = "later-provider"
89+ for _, providerID := range []string{"", "later-provider"} {
90+ credential, err = rt.resolveCredential(context.Background(), providerID)
91+ if err != nil {
92+ t.Fatalf("later provider credential (%q): %v", providerID, err)
93+ }
94+ if credential.Value == "launch-key" {
95+ t.Fatalf("launch API key leaked to provider selected after construction (%q)", providerID)
96+ }
97+ }
98+}
99+
100+func TestSubagentRuntimeOwnsSupervisorConfigurationAndResolution(t *testing.T) {
101+ root := t.TempDir()
102+ repoRoot := t.TempDir()
103+ var captured *subagents.Agent
104+ runner := func(a *subagents.Agent) subagents.Runner {
105+ captured = a
106+ return &runtimeTestRunner{}
107+ }
108+ rt := newSubagentRuntime(newRuntimeTestConfig(root, repoRoot, runner))
109+ if rt == nil || rt.Supervisor() == nil {
110+ t.Fatal("runtime did not create a supervisor")
111+ }
112+ defer func() { _ = rt.Close(context.Background()) }()
113+
114+ registry := rt.InjectTools(core.NewRegistry())
115+ tool, ok := registry["subagent_spawn"]
116+ if !ok {
117+ t.Fatal("runtime did not inject canonical spawn tool")
118+ }
119+ if _, err := tool.Execute(context.Background(), json.RawMessage(`{"task":"configure child"}`), nil); err != nil {
120+ t.Fatalf("spawn tool: %v", err)
121+ }
122+ if captured == nil {
123+ t.Fatal("runner did not receive an agent")
124+ }
125+ if captured.Provider != "parent-provider" || captured.Model != "parent-model" || captured.Reasoning != "high" {
126+ t.Fatalf("child model settings = provider=%q model=%q reasoning=%q", captured.Provider, captured.Model, captured.Reasoning)
127+ }
128+ if captured.BaseURL != "https://parent.example/v1" || !captured.InsecureTLS || !captured.FastMode {
129+ t.Fatalf("child connection/settings = base_url=%q insecure_tls=%v fast_mode=%v", captured.BaseURL, captured.InsecureTLS, captured.FastMode)
130+ }
131+ if captured.Dir != repoRoot {
132+ t.Fatalf("child repo root = %q, want %q", captured.Dir, repoRoot)
133+ }
134+}
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+
170+func TestSubagentRuntimeResolvedWebSearchPolicyRefreshesSupervisor(t *testing.T) {
171+ var captured []*subagents.Agent
172+ cfg := newRuntimeTestConfig(t.TempDir(), t.TempDir(), func(a *subagents.Agent) subagents.Runner {
173+ captured = append(captured, a)
174+ return &runtimeTestRunner{}
175+ })
176+ cfg.WebSearchPolicy = subagents.WebSearchAllow
177+ rt := newSubagentRuntime(cfg)
178+ defer func() { _ = rt.Close(context.Background()) }()
179+
180+ allowedRegistry := core.Registry{"web_search": &recordingWebSearchTool{}}
181+ rt.PrepareResolvedRegistry(allowedRegistry, subagents.WebSearchAllow)
182+ if rt.Supervisor().WebSearchPolicy() != subagents.WebSearchAllow {
183+ t.Fatal("resolved allow policy was not propagated to supervisor")
184+ }
185+ allowed, err := rt.Supervisor().SpawnReq(context.Background(), subagents.SpawnRequest{
186+ Task: "allowed child",
187+ Tools: []string{"web_search"},
188+ })
189+ if err != nil {
190+ t.Fatalf("spawn allowed child: %v", err)
191+ }
192+ if allowed.WebSearchPolicy != subagents.WebSearchAllow {
193+ t.Fatalf("allowed child policy = %v, want allow", allowed.WebSearchPolicy)
194+ }
195+
196+ deniedRegistry := core.Registry{"web_search": &recordingWebSearchTool{}}
197+ rt.PrepareResolvedRegistry(deniedRegistry, subagents.WebSearchDeny)
198+ if _, ok := deniedRegistry["web_search"]; ok {
199+ t.Fatal("resolved deny policy left web_search in parent registry")
200+ }
201+ if rt.Supervisor().WebSearchPolicy() != subagents.WebSearchDeny {
202+ t.Fatal("resolved deny policy was not propagated to supervisor")
203+ }
204+ denied, err := rt.Supervisor().SpawnReq(context.Background(), subagents.SpawnRequest{
205+ Task: "denied child",
206+ Tools: []string{"web_search"},
207+ })
208+ if err != nil {
209+ t.Fatalf("spawn denied child: %v", err)
210+ }
211+ if denied.WebSearchPolicy != subagents.WebSearchDeny {
212+ t.Fatalf("denied child policy = %v, want deny", denied.WebSearchPolicy)
213+ }
214+ if len(captured) != 2 {
215+ t.Fatalf("captured children = %d, want 2", len(captured))
216+ }
217+}
218+
219+func TestSubagentRuntimeInjectsCanonicalToolsAccordingToPolicy(t *testing.T) {
220+ cases := []struct {
221+ name string
222+ args Args
223+ want []string
224+ }{
225+ {name: "default", want: []string{"subagent_spawn", "subagent_status", "subagent_stop", "subagent_resume"}},
226+ {name: "explicit spawn and status", args: Args{ToolsSet: true, Tools: []string{"subagent_spawn", "subagent_status"}}, want: []string{"subagent_spawn", "subagent_status"}},
227+ {name: "no tools", args: Args{NoTools: true}},
228+ {name: "permission set", args: Args{PermissionSet: &tools.PermissionSet{}}},
229+ }
230+ for _, tc := range cases {
231+ t.Run(tc.name, func(t *testing.T) {
232+ cfg := newRuntimeTestConfig(t.TempDir(), t.TempDir(), func(*subagents.Agent) subagents.Runner {
233+ return &runtimeTestRunner{}
234+ })
235+ cfg.Args = tc.args
236+ rt := newSubagentRuntime(cfg)
237+ defer func() { _ = rt.Close(context.Background()) }()
238+ got := rt.InjectTools(core.NewRegistry())
239+ for _, name := range tc.want {
240+ if _, ok := got[name]; !ok {
241+ t.Errorf("missing injected tool %q in %#v", name, got)
242+ }
243+ }
244+ if len(got) != len(tc.want) {
245+ t.Errorf("injected tools = %#v, want exactly %v", got, tc.want)
246+ }
247+ })
248+ }
249+}
250+
251+func TestSubagentRuntimeFailedTransitionRestoresConfiguration(t *testing.T) {
252+ rt := newSubagentRuntime(newRuntimeTestConfig(t.TempDir(), t.TempDir(), func(*subagents.Agent) subagents.Runner {
253+ return &runtimeTestRunner{}
254+ }))
255+ defer func() { _ = rt.Close(context.Background()) }()
256+ before := rt.snapshotConfiguration()
257+
258+ if err := func() error {
259+ rt.SetProvider("candidate-provider")
260+ rt.SetProviderSettings("https://candidate.example/v1", false)
261+ rt.SetFastMode(false)
262+ rt.SetRepoRoot(t.TempDir())
263+ rt.SetWebSearchPolicy(subagents.WebSearchDeny)
264+ return errors.New("candidate transition failed")
265+ }(); err != nil {
266+ rt.restoreConfiguration(before)
267+ }
268+
269+ if got := rt.snapshotConfiguration(); got != before {
270+ t.Fatalf("runtime configuration after rollback = %+v, want %+v", got, before)
271+ }
272+ if got := rt.Supervisor().WebSearchPolicy(); got != before.webSearchPolicy {
273+ t.Fatalf("supervisor web-search policy after rollback = %v, want %v", got, before.webSearchPolicy)
274+ }
275+}
276+
277+func TestSubagentRuntimeFreshShutdownContextCleansCanceledParent(t *testing.T) {
278+ runner := &runtimeTestRunner{started: make(chan struct{}), block: true}
279+ cfg := newRuntimeTestConfig(t.TempDir(), t.TempDir(), func(*subagents.Agent) subagents.Runner { return runner })
280+ rt := newSubagentRuntime(cfg)
281+ spawn := rt.InjectTools(core.NewRegistry())["subagent_spawn"]
282+ if _, err := spawn.Execute(context.Background(), json.RawMessage(`{"task":"cancel me"}`), nil); err != nil {
283+ t.Fatal(err)
284+ }
285+ select {
286+ case <-runner.started:
287+ case <-time.After(time.Second):
288+ t.Fatal("runner did not start")
289+ }
290+ if err := closeSubagentRuntimeFresh(rt); err != nil {
291+ t.Fatalf("fresh shutdown: %v", err)
292+ }
293+ if got := runner.canceled.Load(); got != 1 {
294+ t.Fatalf("runner cancellation count = %d, want 1", got)
295+ }
296+}
297+
298+func TestSubagentRuntimeCloseRetriesAfterCanceledContext(t *testing.T) {
299+ runner := &stubbornRuntimeTestRunner{started: make(chan struct{}), release: make(chan struct{})}
300+ cfg := newRuntimeTestConfig(t.TempDir(), t.TempDir(), func(*subagents.Agent) subagents.Runner { return runner })
301+ rt := newSubagentRuntime(cfg)
302+ spawn := rt.InjectTools(core.NewRegistry())["subagent_spawn"]
303+ if _, err := spawn.Execute(context.Background(), json.RawMessage(`{"task":"retry cleanup"}`), nil); err != nil {
304+ t.Fatal(err)
305+ }
306+ select {
307+ case <-runner.started:
308+ case <-time.After(time.Second):
309+ t.Fatal("runner did not start")
310+ }
311+
312+ canceled, cancel := context.WithCancel(context.Background())
313+ cancel()
314+ if err := rt.Close(canceled); !errors.Is(err, context.Canceled) {
315+ t.Fatalf("first Close error = %v, want context canceled", err)
316+ }
317+ close(runner.release)
318+ if err := rt.Close(context.Background()); err != nil {
319+ t.Fatalf("retry Close: %v", err)
320+ }
321+ if err := rt.Close(context.Background()); err != nil {
322+ t.Fatalf("idempotent Close: %v", err)
323+ }
324+}
325+
326+func TestSubagentRuntimeCallbacksAndExactlyOnceClose(t *testing.T) {
327+ root := t.TempDir()
328+ runner := &runtimeTestRunner{started: make(chan struct{}), block: true}
329+ var spawned atomic.Int32
330+ var stopped atomic.Int32
331+ cfg := newRuntimeTestConfig(root, t.TempDir(), func(*subagents.Agent) subagents.Runner { return runner })
332+ cfg.OnSpawned = func(*subagents.Agent, string) { spawned.Add(1) }
333+ cfg.OnStopRequested = func(*subagents.Agent) { stopped.Add(1) }
334+ rt := newSubagentRuntime(cfg)
335+ registry := rt.InjectTools(core.NewRegistry())
336+ spawn, ok := registry["subagent_spawn"]
337+ if !ok {
338+ t.Fatal("spawn tool was not injected")
339+ }
340+ if _, err := spawn.Execute(context.Background(), json.RawMessage(`{"task":"callback task"}`), nil); err != nil {
341+ t.Fatalf("spawn tool: %v", err)
342+ }
343+ select {
344+ case <-runner.started:
345+ case <-time.After(time.Second):
346+ t.Fatal("runner did not start")
347+ }
348+ if got := spawned.Load(); got != 1 {
349+ t.Fatalf("OnSpawned calls = %d, want 1", got)
350+ }
351+
352+ closeCtx, cancel := context.WithTimeout(context.Background(), time.Second)
353+ defer cancel()
354+ if err := rt.Close(closeCtx); err != nil {
355+ t.Fatalf("first Close: %v", err)
356+ }
357+ if err := rt.Close(context.Background()); err != nil {
358+ t.Fatalf("second Close: %v", err)
359+ }
360+ if got := runner.canceled.Load(); got != 1 {
361+ t.Fatalf("runner cancellation count = %d, want exactly once", got)
362+ }
363+ if got := stopped.Load(); got != 0 {
364+ t.Fatalf("unexpected stop callback count = %d", got)
365+ }
366+}
367+
368+func TestSubagentRuntimeInjectedSpawnProfileInheritsAndOverridesFrontmatter(t *testing.T) {
369+ cwd := t.TempDir()
370+ profilesDir := filepath.Join(cwd, ".agents")
371+ if err := os.MkdirAll(profilesDir, 0o755); err != nil {
372+ t.Fatal(err)
373+ }
374+ writeRuntimeProfile := func(name, frontmatter string) {
375+ t.Helper()
376+ contents := "---\nname: " + name + "\n" + frontmatter + "---\nDo the work.\n"
377+ if err := os.WriteFile(filepath.Join(profilesDir, name+".md"), []byte(contents), 0o644); err != nil {
378+ t.Fatal(err)
379+ }
380+ }
381+ writeRuntimeProfile("inherited", "")
382+ writeRuntimeProfile("overridden", "model: override-model\nprovider: override-provider\nthinking: low\n")
383+ t.Setenv("ZUT_AGENT_PROFILES", profilesDir)
384+
385+ var captured []*subagents.Agent
386+ cfg := newRuntimeTestConfig(t.TempDir(), cwd, func(a *subagents.Agent) subagents.Runner {
387+ captured = append(captured, a)
388+ return &runtimeTestRunner{}
389+ })
390+ cfg.Reasoning = "high"
391+ rt := newSubagentRuntime(cfg)
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")
395+ defer func() { _ = rt.Close(context.Background()) }()
396+ spawn := rt.InjectTools(core.NewRegistry())["subagent_spawn"]
397+
398+ for _, name := range []string{"inherited", "overridden"} {
399+ raw := []byte(`{"task":"` + name + ` task","agent":"` + name + `"}`)
400+ result, err := spawn.Execute(context.Background(), raw, nil)
401+ if err != nil {
402+ t.Fatalf("spawn %s: %v", name, err)
403+ }
404+ if result.IsError {
405+ t.Fatalf("spawn %s returned tool error: %#v", name, result)
406+ }
407+ }
408+ if len(captured) != 2 {
409+ t.Fatalf("captured agents = %d, want 2", len(captured))
410+ }
411+ inherited, overridden := captured[0], captured[1]
412+ if inherited.Provider != "parent-provider" || inherited.Model != "parent-model" || inherited.Reasoning != "medium" {
413+ t.Fatalf("inherited profile settings = provider=%q model=%q reasoning=%q", inherited.Provider, inherited.Model, inherited.Reasoning)
414+ }
415+ if overridden.Provider != "override-provider" || overridden.Model != "override-model" || overridden.Reasoning != "low" {
416+ t.Fatalf("overridden profile settings = provider=%q model=%q reasoning=%q", overridden.Provider, overridden.Model, overridden.Reasoning)
417+ }
418+}
A packages/agent/subagents/completion.go +525 -0
@@ -0,0 +1,525 @@
1+package subagents
2+
3+import (
4+ "context"
5+ "fmt"
6+ "sort"
7+ "strconv"
8+ "strings"
9+ "sync"
10+)
11+
12+// Completion is the observed outcome of one delegated turn. The values are
13+// kept unbounded so callers can decide how much evidence to retain; the
14+// shared formatter applies the bounds used in parent-agent updates.
15+type Completion struct {
16+ AgentID string
17+ Status string
18+ Task string
19+ Error string
20+ FinalResponse string
21+ Tail string
22+
23+ // turnError preserves the label used by the interactive update for an
24+ // error reported by a prompt-level turn_end rather than by the worker
25+ // process itself.
26+ turnError bool
27+ cancellation bool
28+
29+ registrationSeq uint64
30+}
31+
32+type completionEntry struct {
33+ state *completionAgent
34+ task string
35+ followUp bool
36+ expectedStep int
37+ strictStep bool
38+ registrationSeq uint64
39+ done bool
40+ completion *Completion
41+}
42+
43+type completionAgent struct {
44+ agent *Agent
45+ entries []*completionEntry
46+ seenTurnEnds map[int]struct{}
47+ turnMu sync.Mutex
48+ callbackInstalled bool
49+ waitCancel context.CancelFunc
50+ waitingForProcess bool
51+ waitDone chan struct{}
52+}
53+
54+// CompletionTracker observes delegated turns through Agent's event and
55+// lifecycle callbacks. Completed values are buffered so a worker can finish
56+// before the parent begins waiting for the batch.
57+type CompletionTracker struct {
58+ mu sync.Mutex
59+ active map[*Agent]*completionAgent
60+ ready []Completion
61+ nextRegistration uint64
62+ changed chan struct{}
63+}
64+
65+// NewCompletionTracker creates an empty completion tracker.
66+func NewCompletionTracker() *CompletionTracker {
67+ return &CompletionTracker{
68+ active: make(map[*Agent]*completionAgent),
69+ changed: make(chan struct{}),
70+ }
71+}
72+
73+func (t *CompletionTracker) initLocked() {
74+ if t.active == nil {
75+ t.active = make(map[*Agent]*completionAgent)
76+ }
77+ if t.changed == nil {
78+ t.changed = make(chan struct{})
79+ }
80+}
81+
82+// TrackTurn tracks the worker's current prompt-level turn, or its first turn
83+// when no turn has started yet. Multiple turns for the same long-lived worker
84+// are retained and dispatched in registration order.
85+func (t *CompletionTracker) TrackTurn(a *Agent, task string, followUp bool) func() {
86+ return t.trackTurn(a, task, followUp, false)
87+}
88+
89+// TrackFutureTurn tracks a prompt-level turn before its prompt is delivered.
90+// The returned function removes the registration without producing a
91+// completion; callers use it when prompt delivery is rejected.
92+func (t *CompletionTracker) TrackFutureTurn(a *Agent, task string, followUp bool) func() {
93+ return t.trackTurn(a, task, followUp, true)
94+}
95+
96+func (t *CompletionTracker) trackTurn(a *Agent, task string, followUp, future bool) func() {
97+ if t == nil || a == nil {
98+ return nil
99+ }
100+
101+ state, entry, installCallback := t.addTurn(a, task, followUp, future)
102+ if state == nil {
103+ return nil
104+ }
105+ if installCallback {
106+ // SetOnTurnEnd is deliberately installed before the process waiter and
107+ // before the caller sends a follow-up. It also replays notices that
108+ // arrived while no callback was registered.
109+ a.SetOnTurnEnd(func(step int, errMsg string) {
110+ state.turnMu.Lock()
111+ defer state.turnMu.Unlock()
112+ t.completeNextTurn(state, step, errMsg)
113+ })
114+ }
115+ t.startProcessWaiter(state)
116+ return func() { t.cancelEntry(entry) }
117+}
118+
119+// TrackExit tracks only process termination. It is used for explicit worker
120+// stops when no prompt-level turn watcher owns the worker.
121+func (t *CompletionTracker) TrackExit(a *Agent, task string) {
122+ if t == nil || a == nil {
123+ return
124+ }
125+ state, entry := t.addExit(a, task)
126+ if state == nil || entry == nil {
127+ return
128+ }
129+ t.startProcessWaiter(state)
130+}
131+
132+func (t *CompletionTracker) addTurn(a *Agent, task string, followUp, future bool) (*completionAgent, *completionEntry, bool) {
133+ entry := &completionEntry{task: task, followUp: followUp, expectedStep: expectedTurnStep(a, future), strictStep: future}
134+ t.mu.Lock()
135+ defer t.mu.Unlock()
136+ t.initLocked()
137+ state := t.active[a]
138+ if state == nil {
139+ state = &completionAgent{agent: a, seenTurnEnds: make(map[int]struct{})}
140+ t.active[a] = state
141+ }
142+ if state.seenTurnEnds == nil {
143+ state.seenTurnEnds = make(map[int]struct{})
144+ }
145+ if count := len(state.entries); count != 0 {
146+ previousStep := state.entries[count-1].expectedStep
147+ if entry.expectedStep <= previousStep {
148+ entry.expectedStep = previousStep + 1
149+ }
150+ }
151+ t.nextRegistration++
152+ entry.registrationSeq = t.nextRegistration
153+ entry.state = state
154+ state.entries = append(state.entries, entry)
155+ installCallback := !state.callbackInstalled
156+ state.callbackInstalled = true
157+ return state, entry, installCallback
158+}
159+
160+func (t *CompletionTracker) addExit(a *Agent, task string) (*completionAgent, *completionEntry) {
161+ t.mu.Lock()
162+ defer t.mu.Unlock()
163+ t.initLocked()
164+ if state := t.active[a]; state != nil && len(state.entries) != 0 {
165+ // A turn watcher already owns this worker. Its process fallback is
166+ // sufficient and must not create a duplicate terminal completion.
167+ return nil, nil
168+ }
169+ state := &completionAgent{agent: a}
170+ t.nextRegistration++
171+ entry := &completionEntry{state: state, task: task, registrationSeq: t.nextRegistration}
172+ state.entries = append(state.entries, entry)
173+ t.active[a] = state
174+ return state, entry
175+}
176+
177+func (t *CompletionTracker) startProcessWaiter(state *completionAgent) {
178+ if state == nil || state.agent == nil {
179+ return
180+ }
181+ t.mu.Lock()
182+ if t.active[state.agent] != state || state.waitingForProcess {
183+ t.mu.Unlock()
184+ return
185+ }
186+ waitCtx, cancel := context.WithCancel(context.Background())
187+ state.waitingForProcess = true
188+ state.waitCancel = cancel
189+ state.waitDone = make(chan struct{})
190+ waitDone := state.waitDone
191+ t.mu.Unlock()
192+
193+ go func() {
194+ defer close(waitDone)
195+ err := state.agent.WaitContext(waitCtx)
196+ if err == nil {
197+ t.completeProcess(state)
198+ }
199+ }()
200+}
201+
202+func expectedTurnStep(a *Agent, future bool) int {
203+ step := a.LifetimeTurnsValue()
204+ if turnID := strings.TrimPrefix(a.CurrentTurnID(), "turn-"); turnID != "" {
205+ if current, err := strconv.Atoi(turnID); err == nil && current > step {
206+ step = current
207+ }
208+ }
209+ if future || step == 0 {
210+ step++
211+ }
212+ return step
213+}
214+
215+func (t *CompletionTracker) completeNextTurn(state *completionAgent, step int, turnErr string) {
216+ if state == nil {
217+ return
218+ }
219+ t.mu.Lock()
220+ if t.active[state.agent] != state || len(state.entries) == 0 {
221+ t.mu.Unlock()
222+ return
223+ }
224+ if _, duplicate := state.seenTurnEnds[step]; duplicate {
225+ t.mu.Unlock()
226+ return
227+ }
228+ state.seenTurnEnds[step] = struct{}{}
229+ var entry *completionEntry
230+ for _, candidate := range state.entries {
231+ if !candidate.done && candidate.expectedStep == step {
232+ entry = candidate
233+ break
234+ }
235+ }
236+ if entry == nil {
237+ // Post-success callers can register after a resumed worker was queued
238+ // but before its next turn.start updated metadata. Accept the first
239+ // later step for that compatibility path; pre-delivery registrations
240+ // use strict identity and never consume a stale or unrelated notice.
241+ for _, candidate := range state.entries {
242+ if !candidate.done && !candidate.strictStep && candidate.expectedStep < step {
243+ entry = candidate
244+ candidate.expectedStep = step
245+ break
246+ }
247+ }
248+ }
249+ t.mu.Unlock()
250+ if entry != nil {
251+ t.completeTurn(entry, turnErr)
252+ }
253+}
254+
255+func (t *CompletionTracker) cancelEntry(entry *completionEntry) {
256+ if entry == nil || entry.state == nil {
257+ return
258+ }
259+ var cancel context.CancelFunc
260+ t.mu.Lock()
261+ state := entry.state
262+ if entry.done || t.active[state.agent] != state {
263+ t.mu.Unlock()
264+ return
265+ }
266+ entry.done = true
267+ t.flushReadyLocked(state)
268+ if len(state.entries) == 0 {
269+ delete(t.active, state.agent)
270+ cancel = state.waitCancel
271+ state.waitCancel = nil
272+ state.waitingForProcess = false
273+ }
274+ t.signalLocked()
275+ t.mu.Unlock()
276+ if cancel != nil {
277+ cancel()
278+ }
279+}
280+
281+func (t *CompletionTracker) completeTurn(entry *completionEntry, turnErr string) {
282+ if entry == nil || entry.state == nil || entry.state.agent == nil {
283+ return
284+ }
285+ snap := entry.state.agent.Snapshot()
286+ completion := makeCompletion(entry, snap, turnErr, true)
287+
288+ var cancel context.CancelFunc
289+ t.mu.Lock()
290+ state := entry.state
291+ if entry.done || t.active[state.agent] != state {
292+ t.mu.Unlock()
293+ return
294+ }
295+ entry.done = true
296+ entry.completion = &completion
297+ t.flushReadyLocked(state)
298+ if len(state.entries) == 0 {
299+ delete(t.active, state.agent)
300+ cancel = state.waitCancel
301+ state.waitCancel = nil
302+ state.waitingForProcess = false
303+ }
304+ t.signalLocked()
305+ t.mu.Unlock()
306+ if cancel != nil {
307+ cancel()
308+ }
309+}
310+
311+func (t *CompletionTracker) completeProcess(state *completionAgent) {
312+ if state == nil || state.agent == nil {
313+ return
314+ }
315+ snap := state.agent.Snapshot()
316+
317+ t.mu.Lock()
318+ if t.active[state.agent] != state {
319+ t.mu.Unlock()
320+ return
321+ }
322+ for _, entry := range state.entries {
323+ if entry.done {
324+ continue
325+ }
326+ entry.done = true
327+ completion := makeCompletion(entry, snap, "", false)
328+ entry.completion = &completion
329+ }
330+ t.flushReadyLocked(state)
331+ delete(t.active, state.agent)
332+ state.waitCancel = nil
333+ state.waitingForProcess = false
334+ t.signalLocked()
335+ t.mu.Unlock()
336+}
337+
338+func (t *CompletionTracker) flushReadyLocked(state *completionAgent) {
339+ for len(state.entries) != 0 && state.entries[0].done {
340+ entry := state.entries[0]
341+ state.entries[0] = nil
342+ state.entries = state.entries[1:]
343+ if entry.completion != nil {
344+ t.ready = append(t.ready, *entry.completion)
345+ }
346+ }
347+}
348+
349+func makeCompletion(entry *completionEntry, snap AgentSnapshot, turnErr string, turnEnd bool) Completion {
350+ status := string(snap.Status)
351+ if turnEnd {
352+ status = "completed"
353+ if turnErr != "" {
354+ status = "failed"
355+ }
356+ } else if snap.Status == StatusDone {
357+ status = "completed"
358+ }
359+
360+ task := entry.task
361+ if !entry.followUp && snap.Task != "" {
362+ task = snap.Task
363+ }
364+ if task == "" {
365+ task = snap.Task
366+ }
367+
368+ errText := ""
369+ cancellation := false
370+ if snap.Result != nil && snap.Result.ShutdownOrigin != "" {
371+ if snap.Result.Status == ResultCanceled {
372+ status = "cancelled"
373+ }
374+ if snap.Result.Error != nil {
375+ errText = snap.Result.Error.Message
376+ cancellation = true
377+ }
378+ }
379+ turnError := false
380+ if errText == "" {
381+ errText = snap.Err
382+ }
383+ if errText == "" && turnErr != "" {
384+ errText = turnErr
385+ turnError = true
386+ }
387+ return Completion{
388+ AgentID: snap.ID,
389+ Status: status,
390+ Task: task,
391+ Error: errText,
392+ FinalResponse: snap.LastAssistant,
393+ Tail: snap.Tail,
394+ turnError: turnError,
395+ cancellation: cancellation,
396+ registrationSeq: entry.registrationSeq,
397+ }
398+}
399+
400+func (t *CompletionTracker) signalLocked() {
401+ close(t.changed)
402+ t.changed = make(chan struct{})
403+}
404+
405+func (t *CompletionTracker) takeReadyLocked() []Completion {
406+ if len(t.ready) == 0 {
407+ return nil
408+ }
409+ batch := append([]Completion(nil), t.ready...)
410+ t.ready = nil
411+ sort.SliceStable(batch, func(i, j int) bool {
412+ return batch[i].registrationSeq < batch[j].registrationSeq
413+ })
414+ return batch
415+}
416+
417+// Pending reports the number of tracked entries that have not completed.
418+func (t *CompletionTracker) Pending() int {
419+ if t == nil {
420+ return 0
421+ }
422+ t.mu.Lock()
423+ defer t.mu.Unlock()
424+ pending := 0
425+ for _, state := range t.active {
426+ for _, entry := range state.entries {
427+ if !entry.done {
428+ pending++
429+ }
430+ }
431+ }
432+ return pending
433+}
434+
435+// WaitIdle returns all completions buffered so far and all completions that
436+// arrive until no tracked entry remains. A cancelled wait leaves active and
437+// buffered entries intact for a later caller.
438+func (t *CompletionTracker) WaitIdle(ctx context.Context) ([]Completion, error) {
439+ if t == nil {
440+ return nil, nil
441+ }
442+ if ctx == nil {
443+ ctx = context.Background()
444+ }
445+
446+ for {
447+ t.mu.Lock()
448+ t.initLocked()
449+ if len(t.active) == 0 {
450+ batch := t.takeReadyLocked()
451+ t.mu.Unlock()
452+ return batch, nil
453+ }
454+ changed := t.changed
455+ t.mu.Unlock()
456+
457+ select {
458+ case <-changed:
459+ case <-ctx.Done():
460+ // Prefer an already-finished batch if completion won the race
461+ // with cancellation; otherwise preserve the active work.
462+ t.mu.Lock()
463+ if len(t.active) == 0 {
464+ batch := t.takeReadyLocked()
465+ t.mu.Unlock()
466+ return batch, nil
467+ }
468+ t.mu.Unlock()
469+ return nil, ctx.Err()
470+ }
471+ }
472+}
473+
474+// FormatCompletionUpdate formats the stable evidence payload injected into a
475+// parent agent. Task and error fields are bounded to 240 characters and tail
476+// evidence is bounded to 600 characters; final responses retain their full
477+// inline text as they did in interactive mode.
478+func FormatCompletionUpdate(batch []Completion, instruction string) string {
479+ if len(batch) == 0 {
480+ return ""
481+ }
482+
483+ var sb strings.Builder
484+ fmt.Fprintf(&sb, "[auto-subagents update] %d sub-agent(s) finished:\n\n", len(batch))
485+ for idx, completion := range batch {
486+ fmt.Fprintf(&sb, "%d. agent %s - status: %s\n", idx+1, completion.AgentID, completion.Status)
487+ fmt.Fprintf(&sb, " task: %s\n", truncateCompletionField(completion.Task, 240))
488+ if completion.Error != "" {
489+ label := "error"
490+ if completion.cancellation {
491+ label = "cancellation"
492+ } else if completion.turnError {
493+ label = "turn error"
494+ }
495+ fmt.Fprintf(&sb, " %s: %s\n", label, truncateCompletionField(completion.Error, 240))
496+ }
497+ if completion.FinalResponse != "" {
498+ sb.WriteString(" final response:\n")
499+ sb.WriteString(completion.FinalResponse)
500+ sb.WriteString("\n")
501+ } else if tail := strings.TrimSpace(completion.Tail); tail != "" {
502+ fmt.Fprintf(&sb, " tail: %s\n", truncateCompletionField(tail, 600))
503+ }
504+ sb.WriteString("\n")
505+ }
506+ if instruction != "" {
507+ sb.WriteString(instruction)
508+ }
509+ return sb.String()
510+}
511+
512+func truncateCompletionField(value string, limit int) string {
513+ value = strings.TrimSpace(value)
514+ if limit <= 0 {
515+ return ""
516+ }
517+ runes := []rune(value)
518+ if len(runes) <= limit {
519+ return value
520+ }
521+ if limit <= 3 {
522+ return string(runes[:limit])
523+ }
524+ return string(runes[:limit-3]) + "..."
525+}
A packages/agent/subagents/completion_test.go +410 -0
@@ -0,0 +1,410 @@
1+package subagents
2+
3+import (
4+ "context"
5+ "errors"
6+ "strings"
7+ "testing"
8+ "time"
9+)
10+
11+func newCompletionTestAgent(id, task string, status Status) *Agent {
12+ return &Agent{
13+ ID: id,
14+ Task: task,
15+ status: status,
16+ activity: "testing",
17+ done: make(chan struct{}),
18+ transcript: []string{"tail line 1", "tail line 2"},
19+ lastAssistant: "final response",
20+ }
21+}
22+
23+func TestCompletionTrackerTurnEndCompletesLongLivedWorker(t *testing.T) {
24+ agent := newCompletionTestAgent("worker-1", "original task", StatusRunning)
25+ t.Cleanup(func() { close(agent.done) })
26+ tracker := NewCompletionTracker()
27+ tracker.TrackTurn(agent, "ignored task", false)
28+
29+ if agent.OnTurnEnd == nil {
30+ t.Fatal("TrackTurn did not install an event callback")
31+ }
32+ agent.OnTurnEnd(1, "")
33+
34+ batch, err := tracker.WaitIdle(context.Background())
35+ if err != nil {
36+ t.Fatal(err)
37+ }
38+ if len(batch) != 1 {
39+ t.Fatalf("completion count = %d, want 1", len(batch))
40+ }
41+ if got := batch[0].Status; got != "completed" {
42+ t.Fatalf("status = %q, want completed", got)
43+ }
44+ if got := batch[0].Task; got != agent.Task {
45+ t.Fatalf("task = %q, want %q", got, agent.Task)
46+ }
47+ if tracker.Pending() != 0 {
48+ t.Fatalf("pending = %d, want 0", tracker.Pending())
49+ }
50+}
51+
52+func TestCompletionTrackerStartupAndProcessFailuresUseExitPath(t *testing.T) {
53+ tests := []struct {
54+ name string
55+ status Status
56+ err error
57+ want string
58+ }{
59+ {name: "startup failure", status: StatusFailed, err: errors.New("listener startup failed"), want: "failed"},
60+ {name: "stopped process", status: StatusKilled, err: context.Canceled, want: "killed"},
61+ }
62+ for _, tt := range tests {
63+ t.Run(tt.name, func(t *testing.T) {
64+ agent := newCompletionTestAgent("worker-"+tt.name, "task", tt.status)
65+ agent.lastErr = tt.err
66+ tracker := NewCompletionTracker()
67+ tracker.TrackExit(agent, agent.Task)
68+ close(agent.done)
69+
70+ batch, err := tracker.WaitIdle(context.Background())
71+ if err != nil {
72+ t.Fatal(err)
73+ }
74+ if len(batch) != 1 || batch[0].Status != tt.want {
75+ t.Fatalf("batch = %+v, want one %s completion", batch, tt.want)
76+ }
77+ if batch[0].Error != tt.err.Error() {
78+ t.Fatalf("error = %q, want %q", batch[0].Error, tt.err)
79+ }
80+ })
81+ }
82+}
83+
84+func TestCompletionTrackerQueuesSequentialTurnsForOneWorker(t *testing.T) {
85+ agent := newCompletionTestAgent("worker-1", "original task", StatusRunning)
86+ tracker := NewCompletionTracker()
87+ tracker.TrackTurn(agent, agent.Task, false)
88+ tracker.TrackTurn(agent, "follow-up task", true)
89+ tracker.TrackTurn(agent, "follow-up task", true)
90+
91+ if got := tracker.Pending(); got != 3 {
92+ t.Fatalf("pending after queued turns = %d, want 3", got)
93+ }
94+ // Callback goroutines may be scheduled out of order. Completion still has
95+ // to match the registered turn identity and flush in registration order.
96+ agent.OnTurnEnd(2, "")
97+ if got := tracker.Pending(); got != 2 {
98+ t.Fatalf("pending after second turn completed first = %d, want 2", got)
99+ }
100+ // A duplicate lifecycle event for the same prompt-level turn must not
101+ // consume the next accepted follow-up, even when its prompt is identical.
102+ agent.OnTurnEnd(2, "")
103+ if got := tracker.Pending(); got != 2 {
104+ t.Fatalf("pending after duplicate second turn = %d, want 2", got)
105+ }
106+ agent.OnTurnEnd(1, "")
107+ if got := tracker.Pending(); got != 1 {
108+ t.Fatalf("pending after first turn = %d, want 1", got)
109+ }
110+ agent.OnTurnEnd(3, "")
111+
112+ batch, err := tracker.WaitIdle(context.Background())
113+ if err != nil || len(batch) != 3 {
114+ t.Fatalf("wait = (%+v, %v), want three turn completions", batch, err)
115+ }
116+ if batch[0].Task != agent.Task || batch[1].Task != "follow-up task" || batch[2].Task != "follow-up task" {
117+ t.Fatalf("queued tasks = %q, %q, %q; want original then both follow-ups", batch[0].Task, batch[1].Task, batch[2].Task)
118+ }
119+ close(agent.done)
120+}
121+
122+func TestCompletionTrackerTurnEndReleasesProcessWaiterWithoutDuplicate(t *testing.T) {
123+ agent := newCompletionTestAgent("worker-1", "task", StatusRunning)
124+ agent.lastErr = errors.New("late process failure")
125+ tracker := NewCompletionTracker()
126+ tracker.TrackTurn(agent, agent.Task, false)
127+
128+ tracker.mu.Lock()
129+ state := tracker.active[agent]
130+ waitDone := state.waitDone
131+ tracker.mu.Unlock()
132+ if waitDone == nil {
133+ t.Fatal("turn tracking did not start a process fallback waiter")
134+ }
135+ agent.OnTurnEnd(1, "")
136+ select {
137+ case <-waitDone:
138+ case <-time.After(time.Second):
139+ t.Fatal("process fallback waiter was not released after turn_end")
140+ }
141+ close(agent.done)
142+
143+ batch, err := tracker.WaitIdle(context.Background())
144+ if err != nil || len(batch) != 1 {
145+ t.Fatalf("wait = (%+v, %v), want one completion", batch, err)
146+ }
147+ if batch[0].Status != "completed" {
148+ t.Fatalf("status = %q, want completed", batch[0].Status)
149+ }
150+}
151+
152+func TestCompletionTrackerSingleWinnerDoesNotDuplicate(t *testing.T) {
153+ agent := newCompletionTestAgent("worker-1", "task", StatusRunning)
154+ tracker := NewCompletionTracker()
155+ tracker.TrackTurn(agent, agent.Task, false)
156+
157+ agent.OnTurnEnd(1, "")
158+ close(agent.done)
159+
160+ batch, err := tracker.WaitIdle(context.Background())
161+ if err != nil {
162+ t.Fatal(err)
163+ }
164+ if len(batch) != 1 {
165+ t.Fatalf("completion count = %d, want exactly 1", len(batch))
166+ }
167+ if second, err := tracker.WaitIdle(context.Background()); err != nil || len(second) != 0 {
168+ t.Fatalf("second wait = (%+v, %v), want no duplicate", second, err)
169+ }
170+}
171+
172+func TestCompletionTrackerBatchesUntilAllActiveEntriesComplete(t *testing.T) {
173+ first := newCompletionTestAgent("worker-1", "first", StatusRunning)
174+ second := newCompletionTestAgent("worker-2", "second", StatusRunning)
175+ tracker := NewCompletionTracker()
176+ tracker.TrackTurn(first, first.Task, false)
177+ tracker.TrackTurn(second, second.Task, false)
178+
179+ waited := make(chan []Completion, 1)
180+ go func() {
181+ batch, _ := tracker.WaitIdle(context.Background())
182+ waited <- batch
183+ }()
184+ // Complete the workers in reverse order. The batch must still follow the
185+ // registration sequence rather than callback arrival order.
186+ second.OnTurnEnd(1, "")
187+ if got := tracker.Pending(); got != 1 {
188+ t.Fatalf("pending after second completion = %d, want 1", got)
189+ }
190+ first.OnTurnEnd(1, "")
191+
192+ batch := <-waited
193+ if len(batch) != 2 {
194+ t.Fatalf("batch count = %d, want 2", len(batch))
195+ }
196+ if batch[0].AgentID != first.ID || batch[1].AgentID != second.ID {
197+ t.Fatalf("batch order = %q, %q; want %q, %q", batch[0].AgentID, batch[1].AgentID, first.ID, second.ID)
198+ }
199+ close(first.done)
200+ close(second.done)
201+}
202+
203+func TestCompletionTrackerReplaysTurnEndReceivedBeforeTracking(t *testing.T) {
204+ agent := newCompletionTestAgent("worker-1", "task", StatusRunning)
205+ notifyPromptTurnEnd(agent, NewEvent("turn_end", map[string]any{"step": float64(1)}))
206+ tracker := NewCompletionTracker()
207+ tracker.TrackTurn(agent, agent.Task, false)
208+
209+ batch, err := tracker.WaitIdle(context.Background())
210+ if err != nil || len(batch) != 1 {
211+ t.Fatalf("wait = (%+v, %v), want replayed completion", batch, err)
212+ }
213+ if batch[0].Status != "completed" {
214+ t.Fatalf("replayed status = %q, want completed", batch[0].Status)
215+ }
216+ close(agent.done)
217+}
218+
219+func TestCompletionTrackerFutureTurnIgnoresPriorPendingTurnEnd(t *testing.T) {
220+ agent := newCompletionTestAgent("worker-1", "original task", StatusRunning)
221+ agent.setTurnCounts(1, 1)
222+ agent.setTurnState(TurnIdle, "turn-1")
223+ notifyPromptTurnEnd(agent, NewEvent("turn_end", map[string]any{"step": float64(1)}))
224+
225+ tracker := NewCompletionTracker()
226+ tracker.TrackFutureTurn(agent, "follow-up task", true)
227+ if got := tracker.Pending(); got != 1 {
228+ t.Fatalf("pending after stale turn_end replay = %d, want 1", got)
229+ }
230+ agent.OnTurnEnd(2, "")
231+
232+ batch, err := tracker.WaitIdle(context.Background())
233+ if err != nil || len(batch) != 1 || batch[0].Task != "follow-up task" {
234+ t.Fatalf("future turn completion = (%+v, %v), want follow-up turn", batch, err)
235+ }
236+ close(agent.done)
237+}
238+
239+func TestCompletionTrackerCurrentTurnReplaysFastCompletion(t *testing.T) {
240+ agent := newCompletionTestAgent("worker-1", "original task", StatusRunning)
241+ agent.setTurnCounts(2, 1)
242+ agent.setTurnState(TurnSucceeded, "turn-2")
243+ notifyPromptTurnEnd(agent, NewEvent("turn_end", map[string]any{"step": float64(2)}))
244+
245+ tracker := NewCompletionTracker()
246+ tracker.TrackTurn(agent, "follow-up task", true)
247+ batch, err := tracker.WaitIdle(context.Background())
248+ if err != nil || len(batch) != 1 || batch[0].Task != "follow-up task" {
249+ t.Fatalf("current turn replay = (%+v, %v), want follow-up turn", batch, err)
250+ }
251+ close(agent.done)
252+}
253+
254+func TestCompletionTrackerPostSuccessRegistrationAcceptsNextStartedTurn(t *testing.T) {
255+ agent := newCompletionTestAgent("worker-1", "original task", StatusRunning)
256+ agent.setTurnCounts(1, 1)
257+ agent.setTurnState(TurnQueued, "turn-1")
258+
259+ tracker := NewCompletionTracker()
260+ tracker.TrackTurn(agent, "follow-up task", true)
261+ agent.OnTurnEnd(2, "")
262+ batch, err := tracker.WaitIdle(context.Background())
263+ if err != nil || len(batch) != 1 || batch[0].Task != "follow-up task" {
264+ t.Fatalf("post-success next turn = (%+v, %v), want follow-up turn", batch, err)
265+ }
266+ close(agent.done)
267+}
268+
269+func TestCompletionTrackerKeepsCompletionsBeforeWaiting(t *testing.T) {
270+ agent := newCompletionTestAgent("worker-1", "task", StatusRunning)
271+ tracker := NewCompletionTracker()
272+ tracker.TrackTurn(agent, agent.Task, false)
273+ agent.OnTurnEnd(1, "")
274+
275+ batch, err := tracker.WaitIdle(context.Background())
276+ if err != nil {
277+ t.Fatal(err)
278+ }
279+ if len(batch) != 1 || batch[0].AgentID != agent.ID {
280+ t.Fatalf("batch = %+v, want buffered worker completion", batch)
281+ }
282+ close(agent.done)
283+}
284+
285+func TestCompletionTrackerWaitIdleCancellationLeavesActiveWork(t *testing.T) {
286+ agent := newCompletionTestAgent("worker-1", "task", StatusRunning)
287+ tracker := NewCompletionTracker()
288+ tracker.TrackTurn(agent, agent.Task, false)
289+ ctx, cancel := context.WithCancel(context.Background())
290+ cancel()
291+
292+ batch, err := tracker.WaitIdle(ctx)
293+ if !errors.Is(err, context.Canceled) {
294+ t.Fatalf("error = %v, want context.Canceled", err)
295+ }
296+ if batch != nil {
297+ t.Fatalf("cancelled batch = %+v, want nil", batch)
298+ }
299+ if tracker.Pending() != 1 {
300+ t.Fatalf("pending after cancellation = %d, want 1", tracker.Pending())
301+ }
302+
303+ agent.OnTurnEnd(1, "")
304+ close(agent.done)
305+ batch, err = tracker.WaitIdle(context.Background())
306+ if err != nil || len(batch) != 1 {
307+ t.Fatalf("post-cancellation wait = (%+v, %v), want one completion", batch, err)
308+ }
309+}
310+
311+func TestCompletionTrackerStoresRawFieldsAndResumedTask(t *testing.T) {
312+ longTask := strings.Repeat("task ", 80)
313+ longError := errors.New(strings.Repeat("error ", 80))
314+ agent := newCompletionTestAgent("worker-1", "original task", StatusRunning)
315+ agent.Task = longTask
316+ agent.lastErr = longError
317+ agent.lastAssistant = ""
318+ tracker := NewCompletionTracker()
319+ const followUp = "follow-up task"
320+ tracker.TrackTurn(agent, followUp, true)
321+ agent.OnTurnEnd(1, "")
322+
323+ batch, err := tracker.WaitIdle(context.Background())
324+ if err != nil || len(batch) != 1 {
325+ t.Fatalf("wait = (%+v, %v), want one completion", batch, err)
326+ }
327+ completion := batch[0]
328+ if completion.Task != followUp {
329+ t.Fatalf("resumed task = %q, want %q", completion.Task, followUp)
330+ }
331+ if completion.Error != longError.Error() {
332+ t.Fatalf("stored error was truncated: got %d chars, want %d", len(completion.Error), len(longError.Error()))
333+ }
334+ if completion.Tail == "" {
335+ t.Fatal("stored tail is empty")
336+ }
337+ close(agent.done)
338+}
339+
340+func TestFormatCompletionUpdateBoundsFieldsAndPreservesEvidence(t *testing.T) {
341+ longTask := strings.Repeat("t", 300)
342+ longError := strings.Repeat("e", 300)
343+ longTail := strings.Repeat("x", 700)
344+ instruction := "Summarise the collective outcome without spawning new workers."
345+ update := FormatCompletionUpdate([]Completion{{
346+ AgentID: "worker-1", Status: "failed", Task: longTask, Error: longError, Tail: longTail,
347+ }}, instruction)
348+
349+ if !strings.HasPrefix(update, "[auto-subagents update] 1 sub-agent(s) finished:\n\n") {
350+ t.Fatalf("update has unexpected header: %q", update)
351+ }
352+ if !strings.Contains(update, "1. agent worker-1 - status: failed\n task: ") {
353+ t.Fatalf("update omitted stable labels: %q", update)
354+ }
355+ if !strings.Contains(update, " error: ") || !strings.Contains(update, " tail: ") {
356+ t.Fatalf("update omitted error or tail: %q", update)
357+ }
358+ if !strings.HasSuffix(update, instruction) {
359+ t.Fatalf("update omitted instruction: %q", update)
360+ }
361+ if strings.Contains(strings.ToLower(update), "poll") {
362+ t.Fatalf("update instructs the parent to poll: %q", update)
363+ }
364+
365+ taskLine := ""
366+ errorLine := ""
367+ for _, line := range strings.Split(update, "\n") {
368+ if strings.HasPrefix(line, " task: ") {
369+ taskLine = strings.TrimPrefix(line, " task: ")
370+ }
371+ if strings.HasPrefix(line, " error: ") {
372+ errorLine = strings.TrimPrefix(line, " error: ")
373+ }
374+ }
375+ if got := len([]rune(taskLine)); got != 240 {
376+ t.Fatalf("formatted task length = %d, want 240", got)
377+ }
378+ if got := len([]rune(errorLine)); got != 240 {
379+ t.Fatalf("formatted error length = %d, want 240", got)
380+ }
381+ tailLine := ""
382+ for _, line := range strings.Split(update, "\n") {
383+ if strings.HasPrefix(line, " tail: ") {
384+ tailLine = strings.TrimPrefix(line, " tail: ")
385+ }
386+ }
387+ if got := len([]rune(tailLine)); got != 600 {
388+ t.Fatalf("formatted tail length = %d, want 600", got)
389+ }
390+}
391+
392+func TestFormatCompletionUpdateUsesFinalResponseBeforeTailFallback(t *testing.T) {
393+ final := "complete final response\nwith multiple lines"
394+ withFinal := FormatCompletionUpdate([]Completion{{
395+ AgentID: "worker-1", Status: "completed", FinalResponse: final, Tail: "old tail",
396+ }}, "instruction")
397+ if !strings.Contains(withFinal, " final response:\n"+final+"\n") {
398+ t.Fatalf("final response missing: %q", withFinal)
399+ }
400+ if strings.Contains(withFinal, "tail:") {
401+ t.Fatalf("tail should not be used when final response is present: %q", withFinal)
402+ }
403+
404+ withTail := FormatCompletionUpdate([]Completion{{
405+ AgentID: "worker-2", Status: "completed", Tail: " fallback tail ",
406+ }}, "instruction")
407+ if !strings.Contains(withTail, " tail: fallback tail\n") {
408+ t.Fatalf("tail fallback missing or not trimmed: %q", withTail)
409+ }
410+}
M packages/agent/subagents/persist.go +61 -15
@@ -805,6 +805,17 @@ func (f *Supervisor) ResumeSession(ctx context.Context, id string) (*Agent, erro
805805 // terminal worker is restarted with the follow-up as its initial prompt,
806806 // avoiding a race with inbox listener startup.
807807 func (f *Supervisor) ResumeWithPrompt(ctx context.Context, id, prompt string) (*Agent, error) {
808+ return f.ResumeWithPromptBefore(ctx, id, prompt, nil)
809+}
810+
811+// ResumeWithPromptBefore is ResumeWithPrompt with a pre-delivery hook. The
812+// 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 new
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.
818+func (f *Supervisor) ResumeWithPromptBefore(ctx context.Context, id, prompt string, before func(*Agent, string) func()) (*Agent, error) {
808819 if ctx == nil {
809820 ctx = context.Background()
810821 }
@@ -843,10 +854,17 @@ func (f *Supervisor) ResumeWithPrompt(ctx context.Context, id, prompt string) (*
843854 // Active turns cannot accept a command through the worker inbox yet.
844855 // Keep the prompt durable and let the idle transition deliver it in
845856 // order after the current message turn finishes.
857+ var undo func()
858+ if before != nil {
859+ undo = before(existing, prompt)
860+ }
846861 acceptedAt := f.cfg.Now()
847862 commandID := existing.queueResumePrompt(prompt, acceptedAt)
848863 if err := f.persistAgent(existing); err != nil {
849864 existing.removeQueuedResumePrompt(commandID)
865+ if undo != nil {
866+ undo()
867+ }
850868 f.operationMu.Unlock()
851869 return nil, fmt.Errorf("subagent: persist queued follow-up for %s: %w", existing.ID, err)
852870 }
@@ -859,6 +877,10 @@ func (f *Supervisor) ResumeWithPrompt(ctx context.Context, id, prompt string) (*
859877 }
860878 turnID := existing.CurrentTurnID()
861879 previousRunTurns := existing.resetCurrentRunTurns()
880+ var undo func()
881+ if before != nil {
882+ undo = before(existing, prompt)
883+ }
862884 // Reserve the worker before writing to its inbox. A second manager call
863885 // must not enqueue another turn in the small interval before the worker
864886 // reports its turn.start event. Persist the prompt first so a host exit
@@ -872,6 +894,9 @@ func (f *Supervisor) ResumeWithPrompt(ctx context.Context, id, prompt string) (*
872894 existing.setTurnState(TurnIdle, turnID)
873895 existing.setCurrentRunTurns(previousRunTurns)
874896 existing.restoreResumePrompt(previousPrompt)
897+ if undo != nil {
898+ undo()
899+ }
875900 f.operationMu.Unlock()
876901 return nil, fmt.Errorf("subagent: persist follow-up for %s: %w", existing.ID, err)
877902 }
@@ -888,6 +913,9 @@ func (f *Supervisor) ResumeWithPrompt(ctx context.Context, id, prompt string) (*
888913 retryUnknown = true
889914 } else {
890915 existing.restoreResumePrompt(previousPrompt)
916+ if undo != nil {
917+ undo()
918+ }
891919 }
892920 if persistErr := f.persistAgent(existing); persistErr != nil {
893921 err = fmt.Errorf("%w; persist recovery: %v", err, persistErr)
@@ -905,7 +933,7 @@ func (f *Supervisor) ResumeWithPrompt(ctx context.Context, id, prompt string) (*
905933 }
906934 f.operationMu.Unlock()
907935
908- return f.resume(ctx, id, true, prompt)
936+ return f.resumeWithHook(ctx, id, true, prompt, before)
909937 }
910938
911939 // dispatchQueuedResumeWithTimeout keeps the idle callback bounded. Unlike a
@@ -986,6 +1014,10 @@ func (f *Supervisor) RestartTask(ctx context.Context, id string) (*Agent, error)
9861014 }
9871015
9881016 func (f *Supervisor) resume(ctx context.Context, id string, resuming bool, resumePrompt string) (*Agent, error) {
1017+ return f.resumeWithHook(ctx, id, resuming, resumePrompt, nil)
1018+}
1019+
1020+func (f *Supervisor) resumeWithHook(ctx context.Context, id string, resuming bool, resumePrompt string, before func(*Agent, string) func()) (*Agent, error) {
9891021 f.operationMu.Lock()
9901022 defer f.operationMu.Unlock()
9911023 if ctx == nil {
@@ -1052,20 +1084,10 @@ func (f *Supervisor) resume(ctx context.Context, id string, resuming bool, resum
10521084 if pendingResumePrompt != "" && pendingResumePromptID == "" {
10531085 pendingResumePromptID = uuid.NewString()
10541086 }
1055- if resumePrompt != "" {
1056- currentRunTurns = 0
1057- if pendingResumePrompt == "" {
1058- pendingResumePrompt = resumePrompt
1059- pendingResumePromptAt = now
1060- pendingResumePromptID = uuid.NewString()
1061- } else {
1062- pendingResumeQueue = append(pendingResumeQueue, queuedResumePrompt{Prompt: resumePrompt, AcceptedAt: now, CommandID: uuid.NewString()})
1063- }
1064- }
1065- if resuming && resumePrompt == "" && pendingResumePrompt == "" && len(pendingResumeQueue) > 0 {
1087+ if resuming && pendingResumePrompt == "" && len(pendingResumeQueue) > 0 {
10661088 // A host can exit after accepting an active-worker follow-up but before
1067- // dispatching it. Make the first durable queued prompt the resumed
1068- // worker's initial turn rather than leaving the queue stranded.
1089+ // dispatching it. Promote the oldest durable prompt before accepting a
1090+ // new one so scheduler and completion-tracker order stay aligned.
10691091 queued := pendingResumeQueue[0]
10701092 pendingResumePrompt = queued.Prompt
10711093 pendingResumePromptAt = queued.AcceptedAt
@@ -1075,6 +1097,17 @@ func (f *Supervisor) resume(ctx context.Context, id string, resuming bool, resum
10751097 }
10761098 pendingResumeQueue = pendingResumeQueue[1:]
10771099 }
1100+ hadPendingResumePrompt := pendingResumePrompt != ""
1101+ if resumePrompt != "" {
1102+ currentRunTurns = 0
1103+ if pendingResumePrompt == "" {
1104+ pendingResumePrompt = resumePrompt
1105+ pendingResumePromptAt = now
1106+ pendingResumePromptID = uuid.NewString()
1107+ } else {
1108+ pendingResumeQueue = append(pendingResumeQueue, queuedResumePrompt{Prompt: resumePrompt, AcceptedAt: now, CommandID: uuid.NewString()})
1109+ }
1110+ }
10781111 if resuming && pendingResumePrompt != "" {
10791112 // A retained follow-up is an explicit new run, including a queued
10801113 // follow-up recovered after a host restart. Preserve lifetime turns but
@@ -1254,7 +1287,20 @@ func (f *Supervisor) resume(ctx context.Context, id string, resuming bool, resum
12541287
12551288 // Refreshing metadata happened before queue admission, so a later
12561289 // supervisor can reconstruct this resumed attempt even if the runner
1257- // has not reached its first event yet.
1290+ // has not reached its first event yet. Register the turn before the
1291+ // scheduler can start the worker and emit a fast turn_end. A durable pending
1292+ // prompt is consumed before the newly requested prompt, so tracker
1293+ // registrations must preserve that scheduler order.
1294+ if before != nil {
1295+ if hadPendingResumePrompt {
1296+ _ = before(a, pendingResumePrompt)
1297+ }
1298+ if resumePrompt != "" {
1299+ // No rejecting operation remains before scheduling, so there is no path
1300+ // that needs the hook's rollback after this point.
1301+ _ = before(a, resumePrompt)
1302+ }
1303+ }
12581304 f.armQueueTimeout(a)
12591305 f.schedule()
12601306 return a, nil
M packages/agent/subagents/resume_prompt_test.go +142 -3
@@ -71,9 +71,20 @@ func TestResumeWithPromptQueuesFollowUpsForActiveWorker(t *testing.T) {
7171 firstFollowUp = "Check the parser behavior."
7272 secondFollowUp = "Then check the error handling."
7373 )
74- if _, err := f.ResumeWithPrompt(context.Background(), a.ID, firstFollowUp); err != nil {
74+ tracker := NewCompletionTracker()
75+ if _, err := f.ResumeWithPromptBefore(context.Background(), a.ID, firstFollowUp, func(agent *Agent, prompt string) func() {
76+ return tracker.TrackFutureTurn(agent, prompt, true)
77+ }); err != nil {
7578 t.Fatalf("queue first active follow-up: %v", err)
7679 }
80+ if a.OnTurnEnd == nil {
81+ t.Fatal("active resume did not register its turn callback before queueing")
82+ }
83+ a.OnTurnEnd(2, "")
84+ batch, err := tracker.WaitIdle(context.Background())
85+ if err != nil || len(batch) != 1 || batch[0].Task != firstFollowUp {
86+ t.Fatalf("active resume completion = (%+v, %v), want one %q completion", batch, err, firstFollowUp)
87+ }
7788 if _, err := f.ResumeWithPrompt(context.Background(), a.ID, secondFollowUp); err != nil {
7889 t.Fatalf("queue second active follow-up: %v", err)
7990 }
@@ -147,6 +158,124 @@ func TestResumeWithPromptQueuesFollowUpsForActiveWorker(t *testing.T) {
147158 a.Wait()
148159 }
149160
161+func TestResumeWithPromptRestartTracksExistingPendingPromptBeforeNewPrompt(t *testing.T) {
162+ root := t.TempDir()
163+ started := make(chan *Agent, 2)
164+ f := New(Config{
165+ Root: root, RepoRoot: root,
166+ NewRunner: func(a *Agent) Runner {
167+ return RunnerFunc(func(ctx context.Context, _ Sink) error {
168+ started <- a
169+ <-ctx.Done()
170+ return ctx.Err()
171+ })
172+ },
173+ })
174+ t.Cleanup(f.StopAll)
175+
176+ original, err := f.Spawn(context.Background(), "review the implementation")
177+ if err != nil {
178+ t.Fatal(err)
179+ }
180+ select {
181+ case <-started:
182+ case <-time.After(time.Second):
183+ t.Fatal("original runner did not start")
184+ }
185+ if err := f.Stop(original.ID); err != nil {
186+ t.Fatal(err)
187+ }
188+ original.Wait()
189+
190+ const (
191+ pendingPrompt = "finish the queued review"
192+ newPrompt = "then review the follow-up"
193+ )
194+ original.setResumePrompt(pendingPrompt, time.Now())
195+ if err := f.persistAgent(original); err != nil {
196+ t.Fatal(err)
197+ }
198+
199+ tracker := NewCompletionTracker()
200+ resumed, err := f.ResumeWithPromptBefore(context.Background(), original.ID, newPrompt, func(agent *Agent, prompt string) func() {
201+ return tracker.TrackFutureTurn(agent, prompt, true)
202+ })
203+ if err != nil {
204+ t.Fatal(err)
205+ }
206+ select {
207+ case got := <-started:
208+ if got != resumed {
209+ t.Fatalf("started agent = %p, want resumed agent %p", got, resumed)
210+ }
211+ case <-time.After(time.Second):
212+ t.Fatal("resumed runner did not start")
213+ }
214+ if got := resumed.resumePrompt(); got != pendingPrompt {
215+ t.Fatalf("first scheduled prompt = %q, want pending prompt %q", got, pendingPrompt)
216+ }
217+ queue := resumed.resumePromptQueueSnapshot()
218+ if len(queue) != 1 || queue[0].Prompt != newPrompt {
219+ t.Fatalf("resume queue = %+v, want new prompt queued second", queue)
220+ }
221+ if resumed.OnTurnEnd == nil {
222+ t.Fatal("restart did not install completion tracking before scheduling")
223+ }
224+ resumed.OnTurnEnd(1, "")
225+ resumed.OnTurnEnd(2, "")
226+ batch, err := tracker.WaitIdle(context.Background())
227+ if err != nil {
228+ t.Fatal(err)
229+ }
230+ if len(batch) != 2 || batch[0].Task != pendingPrompt || batch[1].Task != newPrompt {
231+ t.Fatalf("completion order = %+v, want pending prompt then new prompt", batch)
232+ }
233+
234+ // Recreate the durable queue-only state: no in-flight prompt, with the
235+ // previously queued command still awaiting scheduler consumption.
236+ if err := f.Stop(resumed.ID); err != nil {
237+ t.Fatal(err)
238+ }
239+ resumed.Wait()
240+ resumed.clearResumePrompt()
241+ if err := f.persistAgent(resumed); err != nil {
242+ t.Fatal(err)
243+ }
244+
245+ const newestPrompt = "finally synthesize both reviews"
246+ queueTracker := NewCompletionTracker()
247+ restarted, err := f.ResumeWithPromptBefore(context.Background(), resumed.ID, newestPrompt, func(agent *Agent, prompt string) func() {
248+ return queueTracker.TrackFutureTurn(agent, prompt, true)
249+ })
250+ if err != nil {
251+ t.Fatal(err)
252+ }
253+ select {
254+ case got := <-started:
255+ if got != restarted {
256+ t.Fatalf("queue-only restart agent = %p, want %p", got, restarted)
257+ }
258+ case <-time.After(time.Second):
259+ t.Fatal("queue-only restarted runner did not start")
260+ }
261+ if got := restarted.resumePrompt(); got != newPrompt {
262+ t.Fatalf("promoted queue prompt = %q, want %q", got, newPrompt)
263+ }
264+ queue = restarted.resumePromptQueueSnapshot()
265+ if len(queue) != 1 || queue[0].Prompt != newestPrompt {
266+ t.Fatalf("queue-only restart queue = %+v, want newest prompt second", queue)
267+ }
268+ restarted.OnTurnEnd(1, "")
269+ restarted.OnTurnEnd(2, "")
270+ batch, err = queueTracker.WaitIdle(context.Background())
271+ if err != nil {
272+ t.Fatal(err)
273+ }
274+ if len(batch) != 2 || batch[0].Task != newPrompt || batch[1].Task != newestPrompt {
275+ t.Fatalf("queue-only completion order = %+v, want promoted prompt then newest prompt", batch)
276+ }
277+}
278+
150279 func TestResumeWithPromptDeliversFollowUpToIdleWorker(t *testing.T) {
151280 if runtime.GOOS == "windows" {
152281 t.Skip("subagent inbox transport uses Unix-domain sockets")
@@ -202,7 +331,10 @@ func TestResumeWithPromptDeliversFollowUpToIdleWorker(t *testing.T) {
202331 a.setTurnCounts(4, 2)
203332
204333 const followUp = "I applied your review. What do you think now?"
205- continued, err := f.ResumeWithPrompt(context.Background(), a.ID, followUp)
334+ tracker := NewCompletionTracker()
335+ continued, err := f.ResumeWithPromptBefore(context.Background(), a.ID, followUp, func(agent *Agent, prompt string) func() {
336+ return tracker.TrackFutureTurn(agent, prompt, true)
337+ })
206338 if err != nil {
207339 t.Fatalf("continue idle worker: %v", err)
208340 }
@@ -236,10 +368,17 @@ func TestResumeWithPromptDeliversFollowUpToIdleWorker(t *testing.T) {
236368 if !payload.NewRun {
237369 t.Fatal("idle follow-up did not request a fresh run budget")
238370 }
371+ if a.OnTurnEnd == nil {
372+ t.Fatal("idle resume callback was not installed before prompt delivery")
373+ }
374+ a.OnTurnEnd(5, "")
239375 case <-time.After(time.Second):
240376 t.Fatal("idle worker did not receive the follow-up")
241377 }
242-
378+ batch, err := tracker.WaitIdle(context.Background())
379+ if err != nil || len(batch) != 1 || batch[0].Task != followUp {
380+ t.Fatalf("idle resume completion = (%+v, %v), want one %q completion", batch, err, followUp)
381+ }
243382 persisted, err := readAgentMeta(filepath.Join(root, "agents", a.ID))
244383 if err != nil {
245384 t.Fatal(err)
M packages/agent/tools/subagent_lifecycle_test.go +84 -0
@@ -78,6 +78,90 @@ func TestSubagentStopTerminatesStuckWorker(t *testing.T) {
7878 }
7979 }
8080
81+func TestSubagentResumeBeforeHookRunsBeforeRestartedWorkerStarts(t *testing.T) {
82+ root := t.TempDir()
83+ started := make(chan *subagents.Agent, 2)
84+ resumedTurnEnded := make(chan struct{})
85+ tracker := subagents.NewCompletionTracker()
86+ manager := subagents.New(subagents.Config{
87+ Root: filepath.Join(root, "subagents"),
88+ RepoRoot: root,
89+ NewRunner: func(agent *subagents.Agent) subagents.Runner {
90+ return subagents.RunnerFunc(func(ctx context.Context, _ subagents.Sink) error {
91+ started <- agent
92+ if agent.Resuming {
93+ if agent.OnTurnEnd == nil {
94+ return context.Canceled
95+ }
96+ agent.OnTurnEnd(1, "")
97+ close(resumedTurnEnded)
98+ return nil
99+ }
100+ <-ctx.Done()
101+ return ctx.Err()
102+ })
103+ },
104+ })
105+ t.Cleanup(manager.StopAll)
106+
107+ first, err := manager.Spawn(context.Background(), "review the implementation")
108+ if err != nil {
109+ t.Fatal(err)
110+ }
111+ select {
112+ case <-started:
113+ case <-time.After(time.Second):
114+ t.Fatal("initial subagent did not start")
115+ }
116+ if err := manager.Stop(first.ID); err != nil {
117+ t.Fatal(err)
118+ }
119+ first.Wait()
120+
121+ const prompt = "continue the review"
122+ var beforeAgent *subagents.Agent
123+ var beforePrompt string
124+ tool := &SubagentResumeTool{
125+ Supervisor: manager,
126+ Enabled: func() bool { return true },
127+ BeforeResumed: func(agent *subagents.Agent, gotPrompt string) func() {
128+ beforeAgent = agent
129+ beforePrompt = gotPrompt
130+ return tracker.TrackFutureTurn(agent, gotPrompt, true)
131+ },
132+ OnResumed: func(*subagents.Agent, string) {
133+ t.Fatal("post-success callback should not run with a pre-resume hook")
134+ },
135+ }
136+ args, err := json.Marshal(subagentResumeArgs{AgentID: first.ID, Prompt: prompt})
137+ if err != nil {
138+ t.Fatal(err)
139+ }
140+ if res, err := tool.Execute(context.Background(), args, nil); err != nil || res.IsError {
141+ t.Fatalf("resume result = (%+v, %v), want success", res, err)
142+ }
143+ if beforeAgent == nil || beforeAgent == first || beforePrompt != prompt {
144+ t.Fatalf("before hook = (%p, %q), want restarted worker and %q", beforeAgent, beforePrompt, prompt)
145+ }
146+ select {
147+ case resumed := <-started:
148+ if resumed.OnTurnEnd == nil {
149+ t.Fatal("restarted worker started without its pre-registered callback")
150+ }
151+ case <-time.After(time.Second):
152+ t.Fatal("resumed subagent did not start")
153+ }
154+ select {
155+ case <-resumedTurnEnded:
156+ case <-time.After(time.Second):
157+ t.Fatal("resumed turn did not finish")
158+ }
159+ batch, err := tracker.WaitIdle(context.Background())
160+ if err != nil || len(batch) != 1 || batch[0].Task != prompt {
161+ t.Fatalf("resumed completion = (%+v, %v), want one %q completion", batch, err, prompt)
162+ }
163+}
164+
81165 func TestSubagentResumeRestartsSessionWithFollowUp(t *testing.T) {
82166 root := t.TempDir()
83167 started := make(chan *subagents.Agent, 2)
M packages/agent/tools/subagent_resume.go +10 -5
@@ -20,10 +20,15 @@ type SubagentResumeTool struct {
2020 Supervisor *subagents.Supervisor
2121 Enabled func() bool
2222
23+ // BeforeResumed, when set, registers the follow-up before the supervisor
24+ // sends or queues its prompt. It returns a cleanup for resume failures that
25+ // happen before delivery. The interactive host uses this to avoid losing a
26+ // fast idle or restarted-worker turn.
27+ BeforeResumed func(agent *subagents.Agent, prompt string) func()
28+
2329 // OnResumed receives the worker and follow-up after a successful resume.
24- // It runs synchronously, so callbacks must only register non-blocking
25- // tracking work. The interactive host uses it to watch this new delegated
26- // turn and deliver its outcome back to the manager automatically.
30+ // It remains available for callers that only need post-success
31+ // notification; tracking callers should prefer BeforeResumed.
2732 OnResumed func(agent *subagents.Agent, prompt string)
2833 }
2934
@@ -88,11 +93,11 @@ func (t *SubagentResumeTool) Execute(ctx context.Context, raw json.RawMessage, _
8893 if !ok {
8994 return protocolToolError(fmt.Sprintf("%s: no such agent %q", prefix, id))
9095 }
91- resumed, err := t.Supervisor.ResumeWithPrompt(ctx, snapshot.ID, args.Prompt)
96+ resumed, err := t.Supervisor.ResumeWithPromptBefore(ctx, snapshot.ID, args.Prompt, t.BeforeResumed)
9297 if err != nil {
9398 return protocolToolError(prefix + ": " + err.Error())
9499 }
95- if t.OnResumed != nil {
100+ if t.BeforeResumed == nil && t.OnResumed != nil {
96101 t.OnResumed(resumed, args.Prompt)
97102 }
98103
M packages/agent/zutfile.go +13 -0
@@ -114,6 +114,12 @@ func runZutfileCommand(rawArgs []string, version string) (bool, error) {
114114 if yes {
115115 args.Yes = true
116116 }
117+ if args.Orchestrate {
118+ return true, fmt.Errorf("--orchestrate is not supported for packaged agents")
119+ }
120+ if err := validateOrchestrationArgs(args); err != nil {
121+ return true, err
122+ }
117123 return true, runLocalZutfile(ref, args, version)
118124 default:
119125 return false, nil
@@ -132,6 +138,13 @@ func runLocalZutfile(ref string, args Args, version string) error {
132138 if err := checkZutfileRequirements(zf, version); err != nil {
133139 return err
134140 }
141+ // A packaged agent has not yet had its manifest permissions attached to
142+ // Args. Reject orchestration at this boundary, before creating its data
143+ // directory or asking for permission consent.
144+ if args.Orchestrate {
145+ args.AgentName = zf.Manifest.Name
146+ return validateOrchestrationArgs(args)
147+ }
135148 agentData := filepath.Join(ZutHome(), "agents", safeAgentName(zf.Manifest.Name), "data")
136149 if err := os.MkdirAll(agentData, 0o755); err != nil {
137150 return err
M packages/agent/zutfile_test.go +23 -0
@@ -18,6 +18,29 @@ import (
1818 "github.com/bnema/zut/packages/tui"
1919 )
2020
21+func TestRunZutfileOrchestrationRejectsPackagedAgentBeforeSideEffects(t *testing.T) {
22+ home := t.TempDir()
23+ t.Setenv("ZUT_HOME", home)
24+ // A nonexistent reference proves validation happens before local loading,
25+ // remote fetching, unpacking, data-directory creation, or consent.
26+ missingPackage := filepath.Join(t.TempDir(), "missing.zut")
27+
28+ handled, err := runZutfileCommand([]string{"run", missingPackage, "--print", "--orchestrate", "task"}, "test")
29+ if !handled {
30+ t.Fatal("run command was not handled")
31+ }
32+ if err == nil || !strings.Contains(err.Error(), "packaged agents") {
33+ t.Fatalf("run error = %v, want packaged-agent validation error", err)
34+ }
35+ entries, readErr := os.ReadDir(home)
36+ if readErr != nil {
37+ t.Fatal(readErr)
38+ }
39+ if len(entries) != 0 {
40+ t.Fatalf("packaged-agent rejection created state: %v", entries)
41+ }
42+}
43+
2144 func TestReadZutfileConsentKeyAcceptsOnlyYesNo(t *testing.T) {
2245 tests := []struct {
2346 name string

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