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 Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· 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 | |||
| 387 | 387 | ||
| 388 | 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 | 392 | ### Named subagent profiles | |
| 393 | 393 | ||
| @@ -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 profiles | 392 | ### 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_ | |||
| 111 | 111 | ||
| 112 | 112 | ## 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. | |
| @@ -111,4 +111,4 @@ When auto-subagents is enabled, the model also receives the read-only `subagent_ | |||||
| 111 | 111 | ||||
| 112 | ## Resource policy | 112 | ## 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 { | |||
| 1241 | 1241 | return subagents.SubagentPolicy{ | |
| 1242 | 1242 | MaxConcurrent: cfg.MaxConcurrent, | |
| 1243 | 1243 | MaxConcurrentPerParent: cfg.MaxConcurrentPerParent, | |
| 1244 | - | MaxTotalSpawned: cfg.MaxTotalSpawned, | |
| 1245 | 1244 | QueueTimeout: parseDuration(cfg.QueueTimeout), | |
| 1246 | 1245 | DefaultTimeout: parseDuration(cfg.DefaultTimeout), | |
| 1247 | 1246 | 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 { | |||
| 27 | 27 | type SubagentsConfig struct { | |
| 28 | 28 | MaxConcurrent int `json:"max_concurrent,omitempty"` | |
| 29 | 29 | MaxConcurrentPerParent int `json:"max_concurrent_per_parent,omitempty"` | |
| 30 | - | MaxTotalSpawned int `json:"max_total_spawned,omitempty"` | |
| 31 | 30 | QueueTimeout string `json:"queue_timeout,omitempty"` | |
| 32 | 31 | DefaultTimeout string `json:"default_timeout,omitempty"` | |
| 33 | 32 | MaxTurns int `json:"max_turns,omitempty"` | |
| @@ -39,6 +38,10 @@ type SubagentsConfig struct { | |||
| 39 | 38 | IdleTimeout string `json:"idle_timeout,omitempty"` | |
| 40 | 39 | ReconnectTimeout string `json:"reconnect_timeout,omitempty"` | |
| 41 | 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 | 47 | // 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, | |||
| 80 | 80 | if len(req.Tasks) == 0 { | |
| 81 | 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 | 83 | if ctx == nil { | |
| 90 | 84 | ctx = context.Background() | |
| 91 | 85 | } | |
| @@ -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 | |||
| 3 | 3 | import ( | |
| 4 | 4 | "context" | |
| 5 | 5 | "encoding/json" | |
| 6 | + | "fmt" | |
| 6 | 7 | "os" | |
| 7 | 8 | "path/filepath" | |
| 8 | 9 | "testing" | |
| @@ -13,7 +14,7 @@ func TestBatchIDsRemainUniqueWithFixedClock(t *testing.T) { | |||
| 13 | 14 | now := time.Unix(123, 0) | |
| 14 | 15 | f := New(Config{ | |
| 15 | 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 | 18 | NewRunner: func(*Agent) Runner { | |
| 18 | 19 | return RunnerFunc(func(context.Context, Sink) error { return nil }) | |
| 19 | 20 | }, | |
| @@ -104,7 +105,7 @@ func TestBatchSpawnWaitAndCollect(t *testing.T) { | |||
| 104 | 105 | root := t.TempDir() | |
| 105 | 106 | f := New(Config{ | |
| 106 | 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 | 109 | NewRunner: func(*Agent) Runner { | |
| 109 | 110 | return RunnerFunc(func(context.Context, Sink) error { return nil }) | |
| 110 | 111 | }, | |
| @@ -125,3 +126,31 @@ func TestBatchSpawnWaitAndCollect(t *testing.T) { | |||
| 125 | 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 | + | } | |
| @@ -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 ( | |||
| 53 | 53 | type SubagentPolicy struct { | |
| 54 | 54 | MaxConcurrent int | |
| 55 | 55 | MaxConcurrentPerParent int | |
| 56 | - | MaxTotalSpawned int | |
| 57 | 56 | QueueTimeout time.Duration | |
| 58 | 57 | DefaultTimeout time.Duration | |
| 59 | 58 | MaxTurns int | |
| @@ -74,9 +73,6 @@ func (p *SubagentPolicy) normalize() { | |||
| 74 | 73 | if p.MaxConcurrentPerParent <= 0 { | |
| 75 | 74 | p.MaxConcurrentPerParent = 4 | |
| 76 | 75 | } | |
| 77 | - | if p.MaxTotalSpawned <= 0 { | |
| 78 | - | p.MaxTotalSpawned = 32 | |
| 79 | - | } | |
| 80 | 76 | if p.QueueTimeout <= 0 { | |
| 81 | 77 | p.QueueTimeout = 30 * time.Minute | |
| 82 | 78 | } | |
| @@ -53,7 +53,6 @@ const ( | |||||
| 53 | type SubagentPolicy struct { | 53 | type SubagentPolicy struct { | ||
| 54 | MaxConcurrent int | 54 | MaxConcurrent int | ||
| 55 | MaxConcurrentPerParent int | 55 | MaxConcurrentPerParent int | ||
| 56 | - | MaxTotalSpawned int | |||
| 57 | QueueTimeout time.Duration | 56 | QueueTimeout time.Duration | ||
| 58 | DefaultTimeout time.Duration | 57 | DefaultTimeout time.Duration | ||
| 59 | MaxTurns int | 58 | MaxTurns int | ||
| @@ -74,9 +73,6 @@ func (p *SubagentPolicy) normalize() { | |||||
| 74 | if p.MaxConcurrentPerParent <= 0 { | 73 | if p.MaxConcurrentPerParent <= 0 { | ||
| 75 | p.MaxConcurrentPerParent = 4 | 74 | 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.Minute | 77 | p.QueueTimeout = 30 * time.Minute | ||
| 82 | } | 78 | } | ||
M packages/agent/subagents/scheduler_test.go +24 -13
| @@ -2,9 +2,9 @@ package subagents | |||
| 2 | 2 | ||
| 3 | 3 | import ( | |
| 4 | 4 | "context" | |
| 5 | + | "fmt" | |
| 5 | 6 | "os" | |
| 6 | 7 | "path/filepath" | |
| 7 | - | "strings" | |
| 8 | 8 | "sync/atomic" | |
| 9 | 9 | "testing" | |
| 10 | 10 | "time" | |
| @@ -32,7 +32,7 @@ func TestSchedulerBoundsConcurrencyAndReleasesCapacity(t *testing.T) { | |||
| 32 | 32 | var active, maxActive int32 | |
| 33 | 33 | f := New(Config{ | |
| 34 | 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 | 36 | NewRunner: func(a *Agent) Runner { | |
| 37 | 37 | return RunnerFunc(func(ctx context.Context, _ Sink) error { | |
| 38 | 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) { | |
| 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 { | |
| 100 | 100 | return RunnerFunc(func(context.Context, Sink) error { return nil }) | |
| 101 | 101 | }}) | |
| 102 | 102 | if _, err := f.SpawnReq(context.Background(), SpawnRequest{Task: "child", RequesterAgentID: "worker-1"}); err == nil { | |
| 103 | 103 | t.Fatal("child-originated spawn was accepted") | |
| 104 | 104 | } | |
| 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 | + | } | |
| 112 | 123 | } | |
| 113 | 124 | } | |
| 114 | 125 | ||
| @@ -117,7 +128,7 @@ func TestStopQueuedAgentBeforeRunnerAdmission(t *testing.T) { | |||
| 117 | 128 | started := make(chan string, 2) | |
| 118 | 129 | f := New(Config{ | |
| 119 | 130 | Root: root, RepoRoot: root, | |
| 120 | - | Policy: SubagentPolicy{MaxConcurrent: 1, MaxTotalSpawned: 3, IdleTimeout: time.Hour}, | |
| 131 | + | Policy: SubagentPolicy{MaxConcurrent: 1, IdleTimeout: time.Hour}, | |
| 121 | 132 | NewRunner: func(a *Agent) Runner { | |
| 122 | 133 | return RunnerFunc(func(ctx context.Context, _ Sink) error { | |
| 123 | 134 | started <- a.ID | |
| @@ -165,7 +176,7 @@ func TestCanceledQueuedAgentIsRemovedAndGetsResult(t *testing.T) { | |||
| 165 | 176 | started := make(chan struct{}) | |
| 166 | 177 | f := New(Config{ | |
| 167 | 178 | Root: root, RepoRoot: root, | |
| 168 | - | Policy: SubagentPolicy{MaxConcurrent: 1, MaxTotalSpawned: 4, IdleTimeout: time.Hour}, | |
| 179 | + | Policy: SubagentPolicy{MaxConcurrent: 1, IdleTimeout: time.Hour}, | |
| 169 | 180 | NewRunner: func(*Agent) Runner { | |
| 170 | 181 | return RunnerFunc(func(ctx context.Context, _ Sink) error { | |
| 171 | 182 | 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 int32 | 32 | 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.ID | 134 | 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 { | |||
| 127 | 127 | active int | |
| 128 | 128 | activeByParent map[string]int | |
| 129 | 129 | activeByBatch map[string]int | |
| 130 | - | totalSpawned int | |
| 131 | 130 | batches map[string]*Batch | |
| 132 | 131 | ||
| 133 | 132 | // 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 | 368 | now := f.cfg.Now() | |
| 370 | 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 | 371 | f.mu.Lock() | |
| 375 | 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 | 373 | dir := f.cfg.RepoRoot | |
| 382 | 374 | fastMode := f.cfg.FastMode | |
| 383 | 375 | configProvider := f.cfg.Provider | |
| @@ -397,14 +389,6 @@ func (f *Supervisor) SpawnReq(ctx context.Context, req SpawnRequest) (*Agent, er | |||
| 397 | 389 | baseURL = strings.TrimSpace(configBaseURL) | |
| 398 | 390 | } | |
| 399 | 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 | 393 | stateDir := f.agentStateDir(id) | |
| 410 | 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 | 526 | // supervisor can reconstruct work that never reached a process. | |
| 543 | 527 | f.armQueueTimeout(a) | |
| 544 | 528 | f.schedule() | |
| 545 | - | reserved = false | |
| 546 | 529 | return a, nil | |
| 547 | 530 | } | |
| 548 | 531 | ||
| @@ -127,7 +127,6 @@ type Supervisor struct { | |||||
| 127 | active int | 127 | active int | ||
| 128 | activeByParent map[string]int | 128 | activeByParent map[string]int | ||
| 129 | activeByBatch map[string]int | 129 | activeByBatch map[string]int | ||
| 130 | - | totalSpawned int | |||
| 131 | batches map[string]*Batch | 130 | batches map[string]*Batch | ||
| 132 | 131 | ||||
| 133 | // activeSession is the host session id the dashboard is | 132 | // 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.RepoRoot | 373 | dir := f.cfg.RepoRoot | ||
| 382 | fastMode := f.cfg.FastMode | 374 | fastMode := f.cfg.FastMode | ||
| 383 | configProvider := f.cfg.Provider | 375 | 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, nil | 529 | return a, nil | ||
| 547 | } | 530 | } | ||
| 548 | 531 | ||||