detached 755e82e

fix(subagents): remove lifetime spawn budget

commit
755e82eb988ea908426b6123378630e25d0a0fa9
author
brice · · 2026-08-06 19:39 UTC
committer
brice · · 2026-08-06 19:41 UTC
parent
21b66ba
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
10 files · +105 -46
M README.md +1 -1
@@ -387,7 +387,7 @@ Background subagents that run alongside your main session. Each one is a separat
387387
388388 **`/session export` does NOT bundle subagents.** A `.zutsession` is just the main chat transcript; per-agent state (session file, unix-socket inbox) is machine-local and doesn't round-trip through a JSONL file. To share what an agent said, copy it out of the transcript view manually.
389389
390-**Auto-subagents.** With `/settings` -> auto-subagents on, the main agent gets the canonical `subagent_spawn` tool and a system-prompt nudge to delegate independent work. The companion `subagent_status` tool accepts an optional `agent_id`: omit it to list visible workers, or pass a full id / unique prefix to query one without waiting for completion. It returns privacy-safe JSON metadata with the worker id, lifecycle state (`starting`, `running`, `completed`, `failed`, `cancelled`, or `detached`), underlying process/turn state, timestamps, a bounded first-line task summary, and terminal-result metadata when available; it does not return prompts, transcripts, result output, credentials, provider settings, or filesystem paths. The spawn tool returns the child id and initial state immediately; completion is a durable `turn.result` with a `subagent://<id>/result` reference. It accepts named profiles, timeout, `max_turns`, and `isolation:"shared"` or `isolation:"worktree"`. Omit `max_turns` to use the supervisor policy; the default ceiling is 3 and `subagents.max_turns` changes it. Global concurrency and total-spawn budgets apply equally to slash commands, tool calls, and batches. Off by default; toggle from `/settings`.
390+**Auto-subagents.** With `/settings` -> auto-subagents on, the main agent gets the canonical `subagent_spawn` tool and a system-prompt nudge to delegate independent work. The companion `subagent_status` tool accepts an optional `agent_id`: omit it to list visible workers, or pass a full id / unique prefix to query one without waiting for completion. It returns privacy-safe JSON metadata with the worker id, lifecycle state (`starting`, `running`, `completed`, `failed`, `cancelled`, or `detached`), underlying process/turn state, timestamps, a bounded first-line task summary, and terminal-result metadata when available; it does not return prompts, transcripts, result output, credentials, provider settings, or filesystem paths. The spawn tool returns the child id and initial state immediately; completion is a durable `turn.result` with a `subagent://<id>/result` reference. It accepts named profiles, timeout, `max_turns`, and `isolation:"shared"` or `isolation:"worktree"`. Omit `max_turns` to use the supervisor policy; the default ceiling is 3 and `subagents.max_turns` changes it. Global concurrency limits apply equally to slash commands, tool calls, and batches. Off by default; toggle from `/settings`.
391391
392392 ### Named subagent profiles
393393
@@ -387,7 +387,7 @@ Background subagents that run alongside your main session. Each one is a separat
387 387
388 **`/session export` does NOT bundle subagents.** A `.zutsession` is just the main chat transcript; per-agent state (session file, unix-socket inbox) is machine-local and doesn't round-trip through a JSONL file. To share what an agent said, copy it out of the transcript view manually.388 **`/session export` does NOT bundle subagents.** A `.zutsession` is just the main chat transcript; per-agent state (session file, unix-socket inbox) is machine-local and doesn't round-trip through a JSONL file. To share what an agent said, copy it out of the transcript view manually.
389 389
390-**Auto-subagents.** With `/settings` -> auto-subagents on, the main agent gets the canonical `subagent_spawn` tool and a system-prompt nudge to delegate independent work. The companion `subagent_status` tool accepts an optional `agent_id`: omit it to list visible workers, or pass a full id / unique prefix to query one without waiting for completion. It returns privacy-safe JSON metadata with the worker id, lifecycle state (`starting`, `running`, `completed`, `failed`, `cancelled`, or `detached`), underlying process/turn state, timestamps, a bounded first-line task summary, and terminal-result metadata when available; it does not return prompts, transcripts, result output, credentials, provider settings, or filesystem paths. The spawn tool returns the child id and initial state immediately; completion is a durable `turn.result` with a `subagent://<id>/result` reference. It accepts named profiles, timeout, `max_turns`, and `isolation:"shared"` or `isolation:"worktree"`. Omit `max_turns` to use the supervisor policy; the default ceiling is 3 and `subagents.max_turns` changes it. Global concurrency and total-spawn budgets apply equally to slash commands, tool calls, and batches. Off by default; toggle from `/settings`.390+**Auto-subagents.** With `/settings` -> auto-subagents on, the main agent gets the canonical `subagent_spawn` tool and a system-prompt nudge to delegate independent work. The companion `subagent_status` tool accepts an optional `agent_id`: omit it to list visible workers, or pass a full id / unique prefix to query one without waiting for completion. It returns privacy-safe JSON metadata with the worker id, lifecycle state (`starting`, `running`, `completed`, `failed`, `cancelled`, or `detached`), underlying process/turn state, timestamps, a bounded first-line task summary, and terminal-result metadata when available; it does not return prompts, transcripts, result output, credentials, provider settings, or filesystem paths. The spawn tool returns the child id and initial state immediately; completion is a durable `turn.result` with a `subagent://<id>/result` reference. It accepts named profiles, timeout, `max_turns`, and `isolation:"shared"` or `isolation:"worktree"`. Omit `max_turns` to use the supervisor policy; the default ceiling is 3 and `subagents.max_turns` changes it. Global concurrency limits apply equally to slash commands, tool calls, and batches. Off by default; toggle from `/settings`.
391 391
392 ### Named subagent profiles392 ### Named subagent profiles
393 393
M docs/subagents.md +1 -1
@@ -111,4 +111,4 @@ When auto-subagents is enabled, the model also receives the read-only `subagent_
111111
112112 ## Resource policy
113113
114-The persisted `subagents` config object supports `max_concurrent`, `max_concurrent_per_parent`, `max_total_spawned`, `queue_timeout`, `default_timeout`, `max_turns`, output caps, allowed tools/roots, heartbeat and idle timeouts. A missing or non-positive configured `max_turns` uses the default ceiling of 3. The `max_turns` field in `subagent_spawn` is optional: omit it to use the policy ceiling, or provide a value from 1 through the configured maximum. Limits apply to slash commands, `subagent_spawn`, and batch operations. A child cannot create descendants in v1. Per-agent timeouts are retained across reload/resume. Packaged `zut run` agents keep their declared capability ceiling by disabling subagent delegation; profiles are not a substitute for that permission boundary.
114+The persisted `subagents` config object supports `max_concurrent`, `max_concurrent_per_parent`, `queue_timeout`, `default_timeout`, `max_turns`, output caps, allowed tools/roots, heartbeat and idle timeouts. Previously saved `max_total_spawned` values are accepted but ignored. A missing or non-positive configured `max_turns` uses the default ceiling of 3. The `max_turns` field in `subagent_spawn` is optional: omit it to use the policy ceiling, or provide a value from 1 through the configured maximum. Concurrency limits apply to slash commands, `subagent_spawn`, and batch operations. A child cannot create descendants in v1. Per-agent timeouts are retained across reload/resume. Packaged `zut run` agents keep their declared capability ceiling by disabling subagent delegation; profiles are not a substitute for that permission boundary.
@@ -111,4 +111,4 @@ When auto-subagents is enabled, the model also receives the read-only `subagent_
111 111
112 ## Resource policy112 ## Resource policy
113 113
114-The persisted `subagents` config object supports `max_concurrent`, `max_concurrent_per_parent`, `max_total_spawned`, `queue_timeout`, `default_timeout`, `max_turns`, output caps, allowed tools/roots, heartbeat and idle timeouts. A missing or non-positive configured `max_turns` uses the default ceiling of 3. The `max_turns` field in `subagent_spawn` is optional: omit it to use the policy ceiling, or provide a value from 1 through the configured maximum. Limits apply to slash commands, `subagent_spawn`, and batch operations. A child cannot create descendants in v1. Per-agent timeouts are retained across reload/resume. Packaged `zut run` agents keep their declared capability ceiling by disabling subagent delegation; profiles are not a substitute for that permission boundary.114+The persisted `subagents` config object supports `max_concurrent`, `max_concurrent_per_parent`, `queue_timeout`, `default_timeout`, `max_turns`, output caps, allowed tools/roots, heartbeat and idle timeouts. Previously saved `max_total_spawned` values are accepted but ignored. A missing or non-positive configured `max_turns` uses the default ceiling of 3. The `max_turns` field in `subagent_spawn` is optional: omit it to use the policy ceiling, or provide a value from 1 through the configured maximum. Concurrency limits apply to slash commands, `subagent_spawn`, and batch operations. A child cannot create descendants in v1. Per-agent timeouts are retained across reload/resume. Packaged `zut run` agents keep their declared capability ceiling by disabling subagent delegation; profiles are not a substitute for that permission boundary.
M packages/agent/cli.go +0 -1
@@ -1241,7 +1241,6 @@ func subagentPolicyFromConfig(cfg SubagentsConfig) subagents.SubagentPolicy {
12411241 return subagents.SubagentPolicy{
12421242 MaxConcurrent: cfg.MaxConcurrent,
12431243 MaxConcurrentPerParent: cfg.MaxConcurrentPerParent,
1244- MaxTotalSpawned: cfg.MaxTotalSpawned,
12451244 QueueTimeout: parseDuration(cfg.QueueTimeout),
12461245 DefaultTimeout: parseDuration(cfg.DefaultTimeout),
12471246 MaxTurns: cfg.MaxTurns,
@@ -1241,7 +1241,6 @@ func subagentPolicyFromConfig(cfg SubagentsConfig) subagents.SubagentPolicy {
1241 return subagents.SubagentPolicy{1241 return subagents.SubagentPolicy{
1242 MaxConcurrent: cfg.MaxConcurrent,1242 MaxConcurrent: cfg.MaxConcurrent,
1243 MaxConcurrentPerParent: cfg.MaxConcurrentPerParent,1243 MaxConcurrentPerParent: cfg.MaxConcurrentPerParent,
1244- MaxTotalSpawned: cfg.MaxTotalSpawned,
1245 QueueTimeout: parseDuration(cfg.QueueTimeout),1244 QueueTimeout: parseDuration(cfg.QueueTimeout),
1246 DefaultTimeout: parseDuration(cfg.DefaultTimeout),1245 DefaultTimeout: parseDuration(cfg.DefaultTimeout),
1247 MaxTurns: cfg.MaxTurns,1246 MaxTurns: cfg.MaxTurns,
M packages/agent/config.go +4 -1
@@ -27,7 +27,6 @@ type QuickModelShortcut struct {
2727 type SubagentsConfig struct {
2828 MaxConcurrent int `json:"max_concurrent,omitempty"`
2929 MaxConcurrentPerParent int `json:"max_concurrent_per_parent,omitempty"`
30- MaxTotalSpawned int `json:"max_total_spawned,omitempty"`
3130 QueueTimeout string `json:"queue_timeout,omitempty"`
3231 DefaultTimeout string `json:"default_timeout,omitempty"`
3332 MaxTurns int `json:"max_turns,omitempty"`
@@ -39,6 +38,10 @@ type SubagentsConfig struct {
3938 IdleTimeout string `json:"idle_timeout,omitempty"`
4039 ReconnectTimeout string `json:"reconnect_timeout,omitempty"`
4140 CancelGracePeriod string `json:"cancel_grace_period,omitempty"`
41+
42+ // MaxTotalSpawned is retained only to accept existing configuration.
43+ // Lifetime total-spawn budgets are no longer enforced.
44+ MaxTotalSpawned int `json:"max_total_spawned,omitempty"`
4245 }
4346
4447 // Config is the persisted user configuration.
@@ -27,7 +27,6 @@ type QuickModelShortcut struct {
27 type SubagentsConfig struct {27 type SubagentsConfig struct {
28 MaxConcurrent int `json:"max_concurrent,omitempty"`28 MaxConcurrent int `json:"max_concurrent,omitempty"`
29 MaxConcurrentPerParent int `json:"max_concurrent_per_parent,omitempty"`29 MaxConcurrentPerParent int `json:"max_concurrent_per_parent,omitempty"`
30- MaxTotalSpawned int `json:"max_total_spawned,omitempty"`
31 QueueTimeout string `json:"queue_timeout,omitempty"`30 QueueTimeout string `json:"queue_timeout,omitempty"`
32 DefaultTimeout string `json:"default_timeout,omitempty"`31 DefaultTimeout string `json:"default_timeout,omitempty"`
33 MaxTurns int `json:"max_turns,omitempty"`32 MaxTurns int `json:"max_turns,omitempty"`
@@ -39,6 +38,10 @@ type SubagentsConfig struct {
39 IdleTimeout string `json:"idle_timeout,omitempty"`38 IdleTimeout string `json:"idle_timeout,omitempty"`
40 ReconnectTimeout string `json:"reconnect_timeout,omitempty"`39 ReconnectTimeout string `json:"reconnect_timeout,omitempty"`
41 CancelGracePeriod string `json:"cancel_grace_period,omitempty"`40 CancelGracePeriod string `json:"cancel_grace_period,omitempty"`
41+
42+ // MaxTotalSpawned is retained only to accept existing configuration.
43+ // Lifetime total-spawn budgets are no longer enforced.
44+ MaxTotalSpawned int `json:"max_total_spawned,omitempty"`
42 }45 }
43 46
44 // Config is the persisted user configuration.47 // Config is the persisted user configuration.
A packages/agent/subagent_policy_test.go +44 -0
@@ -0,0 +1,44 @@
1+package agent
2+
3+import (
4+ "context"
5+ "os"
6+ "testing"
7+
8+ "github.com/bnema/zut/packages/agent/subagents"
9+)
10+
11+func TestSubagentPolicyIgnoresLegacyMaxTotalSpawned(t *testing.T) {
12+ t.Setenv("ZUT_HOME", t.TempDir())
13+ if err := os.WriteFile(ConfigPath(), []byte(`{"subagents":{"max_concurrent":1,"max_total_spawned":1}}`), 0o600); err != nil {
14+ t.Fatal(err)
15+ }
16+ cfg, err := LoadConfig()
17+ if err != nil {
18+ t.Fatal(err)
19+ }
20+ if got := cfg.Subagents.MaxTotalSpawned; got != 1 {
21+ t.Fatalf("legacy max_total_spawned = %d, want 1", got)
22+ }
23+
24+ supervisor := subagents.New(subagents.Config{
25+ Root: t.TempDir(),
26+ RepoRoot: t.TempDir(),
27+ Policy: subagentPolicyFromConfig(cfg.Subagents),
28+ NewRunner: func(*subagents.Agent) subagents.Runner {
29+ return subagents.RunnerFunc(func(context.Context, subagents.Sink) error { return nil })
30+ },
31+ })
32+ t.Cleanup(supervisor.StopAll)
33+
34+ for _, task := range []string{"first", "second"} {
35+ agent, err := supervisor.Spawn(context.Background(), task)
36+ if err != nil {
37+ t.Fatalf("Spawn(%q): %v", task, err)
38+ }
39+ agent.Wait()
40+ if got := agent.Status(); got != subagents.StatusDone {
41+ t.Fatalf("%s status = %s, want %s", task, got, subagents.StatusDone)
42+ }
43+ }
44+}
@@ -0,0 +1,44 @@
1+package agent
2+
3+import (
4+ "context"
5+ "os"
6+ "testing"
7+
8+ "github.com/bnema/zut/packages/agent/subagents"
9+)
10+
11+func TestSubagentPolicyIgnoresLegacyMaxTotalSpawned(t *testing.T) {
12+ t.Setenv("ZUT_HOME", t.TempDir())
13+ if err := os.WriteFile(ConfigPath(), []byte(`{"subagents":{"max_concurrent":1,"max_total_spawned":1}}`), 0o600); err != nil {
14+ t.Fatal(err)
15+ }
16+ cfg, err := LoadConfig()
17+ if err != nil {
18+ t.Fatal(err)
19+ }
20+ if got := cfg.Subagents.MaxTotalSpawned; got != 1 {
21+ t.Fatalf("legacy max_total_spawned = %d, want 1", got)
22+ }
23+
24+ supervisor := subagents.New(subagents.Config{
25+ Root: t.TempDir(),
26+ RepoRoot: t.TempDir(),
27+ Policy: subagentPolicyFromConfig(cfg.Subagents),
28+ NewRunner: func(*subagents.Agent) subagents.Runner {
29+ return subagents.RunnerFunc(func(context.Context, subagents.Sink) error { return nil })
30+ },
31+ })
32+ t.Cleanup(supervisor.StopAll)
33+
34+ for _, task := range []string{"first", "second"} {
35+ agent, err := supervisor.Spawn(context.Background(), task)
36+ if err != nil {
37+ t.Fatalf("Spawn(%q): %v", task, err)
38+ }
39+ agent.Wait()
40+ if got := agent.Status(); got != subagents.StatusDone {
41+ t.Fatalf("%s status = %s, want %s", task, got, subagents.StatusDone)
42+ }
43+ }
44+}
M packages/agent/subagents/batch.go +0 -6
@@ -80,12 +80,6 @@ func (f *Supervisor) SpawnBatch(ctx context.Context, req BatchRequest) (*Batch,
8080 if len(req.Tasks) == 0 {
8181 return nil, fmt.Errorf("subagents: batch requires at least one task")
8282 }
83- f.mu.Lock()
84- remaining := f.cfg.Policy.MaxTotalSpawned - f.totalSpawned
85- f.mu.Unlock()
86- if len(req.Tasks) > remaining {
87- return nil, fmt.Errorf("subagents: batch needs %d spawn budget entries, only %d remain", len(req.Tasks), remaining)
88- }
8983 if ctx == nil {
9084 ctx = context.Background()
9185 }
@@ -80,12 +80,6 @@ func (f *Supervisor) SpawnBatch(ctx context.Context, req BatchRequest) (*Batch,
80 if len(req.Tasks) == 0 {80 if len(req.Tasks) == 0 {
81 return nil, fmt.Errorf("subagents: batch requires at least one task")81 return nil, fmt.Errorf("subagents: batch requires at least one task")
82 }82 }
83- f.mu.Lock()
84- remaining := f.cfg.Policy.MaxTotalSpawned - f.totalSpawned
85- f.mu.Unlock()
86- if len(req.Tasks) > remaining {
87- return nil, fmt.Errorf("subagents: batch needs %d spawn budget entries, only %d remain", len(req.Tasks), remaining)
88- }
89 if ctx == nil {83 if ctx == nil {
90 ctx = context.Background()84 ctx = context.Background()
91 }85 }
M packages/agent/subagents/batch_test.go +31 -2
@@ -3,6 +3,7 @@ package subagents
33 import (
44 "context"
55 "encoding/json"
6+ "fmt"
67 "os"
78 "path/filepath"
89 "testing"
@@ -13,7 +14,7 @@ func TestBatchIDsRemainUniqueWithFixedClock(t *testing.T) {
1314 now := time.Unix(123, 0)
1415 f := New(Config{
1516 Root: t.TempDir(), RepoRoot: t.TempDir(), Now: func() time.Time { return now },
16- Policy: SubagentPolicy{MaxConcurrent: 2, MaxTotalSpawned: 4},
17+ Policy: SubagentPolicy{MaxConcurrent: 2},
1718 NewRunner: func(*Agent) Runner {
1819 return RunnerFunc(func(context.Context, Sink) error { return nil })
1920 },
@@ -104,7 +105,7 @@ func TestBatchSpawnWaitAndCollect(t *testing.T) {
104105 root := t.TempDir()
105106 f := New(Config{
106107 Root: root, RepoRoot: root,
107- Policy: SubagentPolicy{MaxConcurrent: 2, MaxConcurrentPerParent: 2, MaxTotalSpawned: 4, DefaultTimeout: 0, IdleTimeout: 0},
108+ Policy: SubagentPolicy{MaxConcurrent: 2, MaxConcurrentPerParent: 2, DefaultTimeout: 0, IdleTimeout: 0},
108109 NewRunner: func(*Agent) Runner {
109110 return RunnerFunc(func(context.Context, Sink) error { return nil })
110111 },
@@ -125,3 +126,31 @@ func TestBatchSpawnWaitAndCollect(t *testing.T) {
125126 t.Fatalf("batch status = %s", batch.Status())
126127 }
127128 }
129+
130+func TestBatchAllowsMoreThanFormerLifetimeLimit(t *testing.T) {
131+ const workersBeyondFormerLifetimeLimit = 33
132+ tasks := make([]string, workersBeyondFormerLifetimeLimit)
133+ for i := range tasks {
134+ tasks[i] = fmt.Sprintf("worker-%d", i)
135+ }
136+ f := New(Config{
137+ Root: t.TempDir(), RepoRoot: t.TempDir(),
138+ Policy: SubagentPolicy{MaxConcurrent: 1, MaxConcurrentPerParent: 1},
139+ NewRunner: func(*Agent) Runner {
140+ return RunnerFunc(func(context.Context, Sink) error { return nil })
141+ },
142+ })
143+ t.Cleanup(f.StopAll)
144+
145+ batch, err := f.SpawnBatch(context.Background(), BatchRequest{Tasks: tasks, MaxConcurrent: 1})
146+ if err != nil {
147+ t.Fatal(err)
148+ }
149+ result, err := f.WaitBatch(batch.ID)
150+ if err != nil {
151+ t.Fatal(err)
152+ }
153+ if result.Status != BatchSucceeded || len(result.ChildIDs) != len(tasks) || len(result.Results) != len(tasks) {
154+ t.Fatalf("batch result = %#v", result)
155+ }
156+}
@@ -3,6 +3,7 @@ package subagents
3 import (3 import (
4 "context"4 "context"
5 "encoding/json"5 "encoding/json"
6+ "fmt"
6 "os"7 "os"
7 "path/filepath"8 "path/filepath"
8 "testing"9 "testing"
@@ -13,7 +14,7 @@ func TestBatchIDsRemainUniqueWithFixedClock(t *testing.T) {
13 now := time.Unix(123, 0)14 now := time.Unix(123, 0)
14 f := New(Config{15 f := New(Config{
15 Root: t.TempDir(), RepoRoot: t.TempDir(), Now: func() time.Time { return now },16 Root: t.TempDir(), RepoRoot: t.TempDir(), Now: func() time.Time { return now },
16- Policy: SubagentPolicy{MaxConcurrent: 2, MaxTotalSpawned: 4},17+ Policy: SubagentPolicy{MaxConcurrent: 2},
17 NewRunner: func(*Agent) Runner {18 NewRunner: func(*Agent) Runner {
18 return RunnerFunc(func(context.Context, Sink) error { return nil })19 return RunnerFunc(func(context.Context, Sink) error { return nil })
19 },20 },
@@ -104,7 +105,7 @@ func TestBatchSpawnWaitAndCollect(t *testing.T) {
104 root := t.TempDir()105 root := t.TempDir()
105 f := New(Config{106 f := New(Config{
106 Root: root, RepoRoot: root,107 Root: root, RepoRoot: root,
107- Policy: SubagentPolicy{MaxConcurrent: 2, MaxConcurrentPerParent: 2, MaxTotalSpawned: 4, DefaultTimeout: 0, IdleTimeout: 0},108+ Policy: SubagentPolicy{MaxConcurrent: 2, MaxConcurrentPerParent: 2, DefaultTimeout: 0, IdleTimeout: 0},
108 NewRunner: func(*Agent) Runner {109 NewRunner: func(*Agent) Runner {
109 return RunnerFunc(func(context.Context, Sink) error { return nil })110 return RunnerFunc(func(context.Context, Sink) error { return nil })
110 },111 },
@@ -125,3 +126,31 @@ func TestBatchSpawnWaitAndCollect(t *testing.T) {
125 t.Fatalf("batch status = %s", batch.Status())126 t.Fatalf("batch status = %s", batch.Status())
126 }127 }
127 }128 }
129+
130+func TestBatchAllowsMoreThanFormerLifetimeLimit(t *testing.T) {
131+ const workersBeyondFormerLifetimeLimit = 33
132+ tasks := make([]string, workersBeyondFormerLifetimeLimit)
133+ for i := range tasks {
134+ tasks[i] = fmt.Sprintf("worker-%d", i)
135+ }
136+ f := New(Config{
137+ Root: t.TempDir(), RepoRoot: t.TempDir(),
138+ Policy: SubagentPolicy{MaxConcurrent: 1, MaxConcurrentPerParent: 1},
139+ NewRunner: func(*Agent) Runner {
140+ return RunnerFunc(func(context.Context, Sink) error { return nil })
141+ },
142+ })
143+ t.Cleanup(f.StopAll)
144+
145+ batch, err := f.SpawnBatch(context.Background(), BatchRequest{Tasks: tasks, MaxConcurrent: 1})
146+ if err != nil {
147+ t.Fatal(err)
148+ }
149+ result, err := f.WaitBatch(batch.ID)
150+ if err != nil {
151+ t.Fatal(err)
152+ }
153+ if result.Status != BatchSucceeded || len(result.ChildIDs) != len(tasks) || len(result.Results) != len(tasks) {
154+ t.Fatalf("batch result = %#v", result)
155+ }
156+}
M packages/agent/subagents/model.go +0 -4
@@ -53,7 +53,6 @@ const (
5353 type SubagentPolicy struct {
5454 MaxConcurrent int
5555 MaxConcurrentPerParent int
56- MaxTotalSpawned int
5756 QueueTimeout time.Duration
5857 DefaultTimeout time.Duration
5958 MaxTurns int
@@ -74,9 +73,6 @@ func (p *SubagentPolicy) normalize() {
7473 if p.MaxConcurrentPerParent <= 0 {
7574 p.MaxConcurrentPerParent = 4
7675 }
77- if p.MaxTotalSpawned <= 0 {
78- p.MaxTotalSpawned = 32
79- }
8076 if p.QueueTimeout <= 0 {
8177 p.QueueTimeout = 30 * time.Minute
8278 }
@@ -53,7 +53,6 @@ const (
53 type SubagentPolicy struct {53 type SubagentPolicy struct {
54 MaxConcurrent int54 MaxConcurrent int
55 MaxConcurrentPerParent int55 MaxConcurrentPerParent int
56- MaxTotalSpawned int
57 QueueTimeout time.Duration56 QueueTimeout time.Duration
58 DefaultTimeout time.Duration57 DefaultTimeout time.Duration
59 MaxTurns int58 MaxTurns int
@@ -74,9 +73,6 @@ func (p *SubagentPolicy) normalize() {
74 if p.MaxConcurrentPerParent <= 0 {73 if p.MaxConcurrentPerParent <= 0 {
75 p.MaxConcurrentPerParent = 474 p.MaxConcurrentPerParent = 4
76 }75 }
77- if p.MaxTotalSpawned <= 0 {
78- p.MaxTotalSpawned = 32
79- }
80 if p.QueueTimeout <= 0 {76 if p.QueueTimeout <= 0 {
81 p.QueueTimeout = 30 * time.Minute77 p.QueueTimeout = 30 * time.Minute
82 }78 }
M packages/agent/subagents/scheduler_test.go +24 -13
@@ -2,9 +2,9 @@ package subagents
22
33 import (
44 "context"
5+ "fmt"
56 "os"
67 "path/filepath"
7- "strings"
88 "sync/atomic"
99 "testing"
1010 "time"
@@ -32,7 +32,7 @@ func TestSchedulerBoundsConcurrencyAndReleasesCapacity(t *testing.T) {
3232 var active, maxActive int32
3333 f := New(Config{
3434 Root: root, RepoRoot: root,
35- Policy: SubagentPolicy{MaxConcurrent: 1, MaxConcurrentPerParent: 4, MaxTotalSpawned: 3, QueueTimeout: time.Minute, DefaultTimeout: 0, IdleTimeout: time.Hour},
35+ Policy: SubagentPolicy{MaxConcurrent: 1, MaxConcurrentPerParent: 4, QueueTimeout: time.Minute, DefaultTimeout: 0, IdleTimeout: time.Hour},
3636 NewRunner: func(a *Agent) Runner {
3737 return RunnerFunc(func(ctx context.Context, _ Sink) error {
3838 current := atomic.AddInt32(&active, 1)
@@ -95,20 +95,31 @@ func TestSchedulerBoundsConcurrencyAndReleasesCapacity(t *testing.T) {
9595 }
9696 }
9797
98-func TestSchedulerRejectsChildSpawningAndTotalBudget(t *testing.T) {
99- f := New(Config{Root: t.TempDir(), RepoRoot: t.TempDir(), Policy: SubagentPolicy{MaxTotalSpawned: 1}, NewRunner: func(*Agent) Runner {
98+func TestSchedulerRejectsChildSpawning(t *testing.T) {
99+ f := New(Config{Root: t.TempDir(), RepoRoot: t.TempDir(), NewRunner: func(*Agent) Runner {
100100 return RunnerFunc(func(context.Context, Sink) error { return nil })
101101 }})
102102 if _, err := f.SpawnReq(context.Background(), SpawnRequest{Task: "child", RequesterAgentID: "worker-1"}); err == nil {
103103 t.Fatal("child-originated spawn was accepted")
104104 }
105- first, err := f.Spawn(context.Background(), "one")
106- if err != nil {
107- t.Fatal(err)
108- }
109- first.Wait()
110- if _, err := f.Spawn(context.Background(), "two"); err == nil || !strings.Contains(err.Error(), "budget") {
111- t.Fatalf("second spawn error = %v, want budget rejection", err)
105+}
106+
107+func TestSchedulerAllowsWorkersAfterManyCompletions(t *testing.T) {
108+ f := New(Config{Root: t.TempDir(), RepoRoot: t.TempDir(), Policy: SubagentPolicy{MaxConcurrent: 1}, NewRunner: func(*Agent) Runner {
109+ return RunnerFunc(func(context.Context, Sink) error { return nil })
110+ }})
111+ t.Cleanup(f.StopAll)
112+
113+ const workersBeyondFormerLifetimeLimit = 33
114+ for worker := 0; worker < workersBeyondFormerLifetimeLimit; worker++ {
115+ a, err := f.Spawn(context.Background(), fmt.Sprintf("worker-%d", worker))
116+ if err != nil {
117+ t.Fatalf("Spawn(worker-%d): %v", worker, err)
118+ }
119+ a.Wait()
120+ if got := a.Status(); got != StatusDone {
121+ t.Fatalf("worker-%d status = %s, want %s", worker, got, StatusDone)
122+ }
112123 }
113124 }
114125
@@ -117,7 +128,7 @@ func TestStopQueuedAgentBeforeRunnerAdmission(t *testing.T) {
117128 started := make(chan string, 2)
118129 f := New(Config{
119130 Root: root, RepoRoot: root,
120- Policy: SubagentPolicy{MaxConcurrent: 1, MaxTotalSpawned: 3, IdleTimeout: time.Hour},
131+ Policy: SubagentPolicy{MaxConcurrent: 1, IdleTimeout: time.Hour},
121132 NewRunner: func(a *Agent) Runner {
122133 return RunnerFunc(func(ctx context.Context, _ Sink) error {
123134 started <- a.ID
@@ -165,7 +176,7 @@ func TestCanceledQueuedAgentIsRemovedAndGetsResult(t *testing.T) {
165176 started := make(chan struct{})
166177 f := New(Config{
167178 Root: root, RepoRoot: root,
168- Policy: SubagentPolicy{MaxConcurrent: 1, MaxTotalSpawned: 4, IdleTimeout: time.Hour},
179+ Policy: SubagentPolicy{MaxConcurrent: 1, IdleTimeout: time.Hour},
169180 NewRunner: func(*Agent) Runner {
170181 return RunnerFunc(func(ctx context.Context, _ Sink) error {
171182 select {
@@ -2,9 +2,9 @@ package subagents
2 2
3 import (3 import (
4 "context"4 "context"
5+ "fmt"
5 "os"6 "os"
6 "path/filepath"7 "path/filepath"
7- "strings"
8 "sync/atomic"8 "sync/atomic"
9 "testing"9 "testing"
10 "time"10 "time"
@@ -32,7 +32,7 @@ func TestSchedulerBoundsConcurrencyAndReleasesCapacity(t *testing.T) {
32 var active, maxActive int3232 var active, maxActive int32
33 f := New(Config{33 f := New(Config{
34 Root: root, RepoRoot: root,34 Root: root, RepoRoot: root,
35- Policy: SubagentPolicy{MaxConcurrent: 1, MaxConcurrentPerParent: 4, MaxTotalSpawned: 3, QueueTimeout: time.Minute, DefaultTimeout: 0, IdleTimeout: time.Hour},35+ Policy: SubagentPolicy{MaxConcurrent: 1, MaxConcurrentPerParent: 4, QueueTimeout: time.Minute, DefaultTimeout: 0, IdleTimeout: time.Hour},
36 NewRunner: func(a *Agent) Runner {36 NewRunner: func(a *Agent) Runner {
37 return RunnerFunc(func(ctx context.Context, _ Sink) error {37 return RunnerFunc(func(ctx context.Context, _ Sink) error {
38 current := atomic.AddInt32(&active, 1)38 current := atomic.AddInt32(&active, 1)
@@ -95,20 +95,31 @@ func TestSchedulerBoundsConcurrencyAndReleasesCapacity(t *testing.T) {
95 }95 }
96 }96 }
97 97
98-func TestSchedulerRejectsChildSpawningAndTotalBudget(t *testing.T) {98+func TestSchedulerRejectsChildSpawning(t *testing.T) {
99- f := New(Config{Root: t.TempDir(), RepoRoot: t.TempDir(), Policy: SubagentPolicy{MaxTotalSpawned: 1}, NewRunner: func(*Agent) Runner {99+ f := New(Config{Root: t.TempDir(), RepoRoot: t.TempDir(), NewRunner: func(*Agent) Runner {
100 return RunnerFunc(func(context.Context, Sink) error { return nil })100 return RunnerFunc(func(context.Context, Sink) error { return nil })
101 }})101 }})
102 if _, err := f.SpawnReq(context.Background(), SpawnRequest{Task: "child", RequesterAgentID: "worker-1"}); err == nil {102 if _, err := f.SpawnReq(context.Background(), SpawnRequest{Task: "child", RequesterAgentID: "worker-1"}); err == nil {
103 t.Fatal("child-originated spawn was accepted")103 t.Fatal("child-originated spawn was accepted")
104 }104 }
105- first, err := f.Spawn(context.Background(), "one")105+}
106- if err != nil {106+
107- t.Fatal(err)107+func TestSchedulerAllowsWorkersAfterManyCompletions(t *testing.T) {
108- }108+ f := New(Config{Root: t.TempDir(), RepoRoot: t.TempDir(), Policy: SubagentPolicy{MaxConcurrent: 1}, NewRunner: func(*Agent) Runner {
109- first.Wait()109+ return RunnerFunc(func(context.Context, Sink) error { return nil })
110- if _, err := f.Spawn(context.Background(), "two"); err == nil || !strings.Contains(err.Error(), "budget") {110+ }})
111- t.Fatalf("second spawn error = %v, want budget rejection", err)111+ t.Cleanup(f.StopAll)
112+
113+ const workersBeyondFormerLifetimeLimit = 33
114+ for worker := 0; worker < workersBeyondFormerLifetimeLimit; worker++ {
115+ a, err := f.Spawn(context.Background(), fmt.Sprintf("worker-%d", worker))
116+ if err != nil {
117+ t.Fatalf("Spawn(worker-%d): %v", worker, err)
118+ }
119+ a.Wait()
120+ if got := a.Status(); got != StatusDone {
121+ t.Fatalf("worker-%d status = %s, want %s", worker, got, StatusDone)
122+ }
112 }123 }
113 }124 }
114 125
@@ -117,7 +128,7 @@ func TestStopQueuedAgentBeforeRunnerAdmission(t *testing.T) {
117 started := make(chan string, 2)128 started := make(chan string, 2)
118 f := New(Config{129 f := New(Config{
119 Root: root, RepoRoot: root,130 Root: root, RepoRoot: root,
120- Policy: SubagentPolicy{MaxConcurrent: 1, MaxTotalSpawned: 3, IdleTimeout: time.Hour},131+ Policy: SubagentPolicy{MaxConcurrent: 1, IdleTimeout: time.Hour},
121 NewRunner: func(a *Agent) Runner {132 NewRunner: func(a *Agent) Runner {
122 return RunnerFunc(func(ctx context.Context, _ Sink) error {133 return RunnerFunc(func(ctx context.Context, _ Sink) error {
123 started <- a.ID134 started <- a.ID
@@ -165,7 +176,7 @@ func TestCanceledQueuedAgentIsRemovedAndGetsResult(t *testing.T) {
165 started := make(chan struct{})176 started := make(chan struct{})
166 f := New(Config{177 f := New(Config{
167 Root: root, RepoRoot: root,178 Root: root, RepoRoot: root,
168- Policy: SubagentPolicy{MaxConcurrent: 1, MaxTotalSpawned: 4, IdleTimeout: time.Hour},179+ Policy: SubagentPolicy{MaxConcurrent: 1, IdleTimeout: time.Hour},
169 NewRunner: func(*Agent) Runner {180 NewRunner: func(*Agent) Runner {
170 return RunnerFunc(func(ctx context.Context, _ Sink) error {181 return RunnerFunc(func(ctx context.Context, _ Sink) error {
171 select {182 select {
M packages/agent/subagents/supervisor.go +0 -17
@@ -127,7 +127,6 @@ type Supervisor struct {
127127 active int
128128 activeByParent map[string]int
129129 activeByBatch map[string]int
130- totalSpawned int
131130 batches map[string]*Batch
132131
133132 // activeSession is the host session id the dashboard is
@@ -369,15 +368,8 @@ func (f *Supervisor) SpawnReq(ctx context.Context, req SpawnRequest) (*Agent, er
369368 now := f.cfg.Now()
370369 baseID := newAgentID(task, now)
371370
372- // Reserve a spawn budget before touching disk. This makes the total
373- // budget race-free across slash commands, tools, and batch callers.
374371 f.mu.Lock()
375372 id := f.uniqueAgentIDLocked(baseID)
376- if f.totalSpawned >= f.cfg.Policy.MaxTotalSpawned {
377- f.mu.Unlock()
378- return nil, fmt.Errorf("subagents: total spawn budget exhausted (%d)", f.cfg.Policy.MaxTotalSpawned)
379- }
380- f.totalSpawned++
381373 dir := f.cfg.RepoRoot
382374 fastMode := f.cfg.FastMode
383375 configProvider := f.cfg.Provider
@@ -397,14 +389,6 @@ func (f *Supervisor) SpawnReq(ctx context.Context, req SpawnRequest) (*Agent, er
397389 baseURL = strings.TrimSpace(configBaseURL)
398390 }
399391 insecureTLS := req.InsecureTLS || (inheritProviderSettings && configInsecureTLS)
400- reserved := true
401- defer func() {
402- if reserved {
403- f.mu.Lock()
404- f.totalSpawned--
405- f.mu.Unlock()
406- }
407- }()
408392
409393 stateDir := f.agentStateDir(id)
410394 if err := os.MkdirAll(stateDir, 0o700); err != nil {
@@ -542,7 +526,6 @@ func (f *Supervisor) SpawnReq(ctx context.Context, req SpawnRequest) (*Agent, er
542526 // supervisor can reconstruct work that never reached a process.
543527 f.armQueueTimeout(a)
544528 f.schedule()
545- reserved = false
546529 return a, nil
547530 }
548531
@@ -127,7 +127,6 @@ type Supervisor struct {
127 active int127 active int
128 activeByParent map[string]int128 activeByParent map[string]int
129 activeByBatch map[string]int129 activeByBatch map[string]int
130- totalSpawned int
131 batches map[string]*Batch130 batches map[string]*Batch
132 131
133 // activeSession is the host session id the dashboard is132 // activeSession is the host session id the dashboard is
@@ -369,15 +368,8 @@ func (f *Supervisor) SpawnReq(ctx context.Context, req SpawnRequest) (*Agent, er
369 now := f.cfg.Now()368 now := f.cfg.Now()
370 baseID := newAgentID(task, now)369 baseID := newAgentID(task, now)
371 370
372- // Reserve a spawn budget before touching disk. This makes the total
373- // budget race-free across slash commands, tools, and batch callers.
374 f.mu.Lock()371 f.mu.Lock()
375 id := f.uniqueAgentIDLocked(baseID)372 id := f.uniqueAgentIDLocked(baseID)
376- if f.totalSpawned >= f.cfg.Policy.MaxTotalSpawned {
377- f.mu.Unlock()
378- return nil, fmt.Errorf("subagents: total spawn budget exhausted (%d)", f.cfg.Policy.MaxTotalSpawned)
379- }
380- f.totalSpawned++
381 dir := f.cfg.RepoRoot373 dir := f.cfg.RepoRoot
382 fastMode := f.cfg.FastMode374 fastMode := f.cfg.FastMode
383 configProvider := f.cfg.Provider375 configProvider := f.cfg.Provider
@@ -397,14 +389,6 @@ func (f *Supervisor) SpawnReq(ctx context.Context, req SpawnRequest) (*Agent, er
397 baseURL = strings.TrimSpace(configBaseURL)389 baseURL = strings.TrimSpace(configBaseURL)
398 }390 }
399 insecureTLS := req.InsecureTLS || (inheritProviderSettings && configInsecureTLS)391 insecureTLS := req.InsecureTLS || (inheritProviderSettings && configInsecureTLS)
400- reserved := true
401- defer func() {
402- if reserved {
403- f.mu.Lock()
404- f.totalSpawned--
405- f.mu.Unlock()
406- }
407- }()
408 392
409 stateDir := f.agentStateDir(id)393 stateDir := f.agentStateDir(id)
410 if err := os.MkdirAll(stateDir, 0o700); err != nil {394 if err := os.MkdirAll(stateDir, 0o700); err != nil {
@@ -542,7 +526,6 @@ func (f *Supervisor) SpawnReq(ctx context.Context, req SpawnRequest) (*Agent, er
542 // supervisor can reconstruct work that never reached a process.526 // supervisor can reconstruct work that never reached a process.
543 f.armQueueTimeout(a)527 f.armQueueTimeout(a)
544 f.schedule()528 f.schedule()
545- reserved = false
546 return a, nil529 return a, nil
547 }530 }
548 531

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