返回 DeepSeek-Reasonix
scheduler.go
根目录 / internal / agent / scheduler.go
1 package agent
2
3 import (
4 "context"
5 "fmt"
6 "sync"
7 )
8
9 // SubagentSlotStatus is the queue lifecycle shown for background task/fleet
10 // items that share the session scheduler.
11 type SubagentSlotStatus string
12
13 const (
14 SubagentSlotQueued SubagentSlotStatus = "queued"
15 SubagentSlotRunning SubagentSlotStatus = "running"
16 SubagentSlotDone SubagentSlotStatus = "done"
17 SubagentSlotFailed SubagentSlotStatus = "failed"
18 )
19
20 // AcquireRequest describes a sub-agent slot request against the session pool.
21 type AcquireRequest struct {
22 // Writer is true for writer-capable runs (task without read_only, profile
23 // that is not read-only, fleet items that can write).
24 Writer bool
25 // WritePaths is the claim held while the slot is active. Empty for
26 // read-only work. Whole-workspace claims count as writers and serialize
27 // against every other writer.
28 WritePaths WritePathSet
29 // Nested fails immediately when no capacity is free instead of queueing.
30 // Nested sub-agents must not block waiting for a parent-held slot.
31 Nested bool
32 // Label is optional diagnostics text.
33 Label string
34 }
35
36 // SubagentScheduler is a session-scoped concurrency controller shared by task,
37 // fleet, parallel_tasks, profile skills, and nested sub-agents.
38 type SubagentScheduler struct {
39 mu sync.Mutex
40
41 maxTotal int
42 maxWriters int
43
44 activeTotal int
45 activeWriters int
46 activeClaims []WritePathSet
47 // parentClaims are write paths held by the parent agent during a write-tool
48 // Execute. They block overlapping subagent claims without consuming a
49 // subagent concurrency slot (parent is not a subagent).
50 parentClaims []WritePathSet
51
52 // waiters are FIFO waiters for non-nested acquires.
53 waiters []*schedulerWaiter
54 }
55
56 type schedulerWaiter struct {
57 req AcquireRequest
58 ready chan struct{}
59 failed error
60 }
61
62 // NewSubagentScheduler builds a scheduler with the given limits (normalized).
63 func NewSubagentScheduler(maxTotal, maxWriters int) *SubagentScheduler {
64 maxTotal, maxWriters = NormalizeConcurrencyLimits(maxTotal, maxWriters)
65 return &SubagentScheduler{maxTotal: maxTotal, maxWriters: maxWriters}
66 }
67
68 // Limits returns the effective total/writer caps.
69 func (s *SubagentScheduler) Limits() (total, writers int) {
70 if s == nil {
71 return DefaultMaxSubagentConcurrency, DefaultMaxParallelWriters
72 }
73 return s.maxTotal, s.maxWriters
74 }
75
76 // Acquire reserves a concurrency slot (and optional write claim). Nested
77 // requests fail immediately when capacity is exhausted. Non-nested requests
78 // queue until capacity is free or ctx is cancelled.
79 //
80 // The returned release function must be called exactly once when the sub-agent
81 // finishes. release is safe to call even if Acquire returns an error (no-op).
82 func (s *SubagentScheduler) Acquire(ctx context.Context, req AcquireRequest) (release func(), err error) {
83 noop := func() {}
84 if s == nil {
85 return noop, nil
86 }
87 if ctx == nil {
88 ctx = context.Background()
89 }
90
91 s.mu.Lock()
92 if ok, reason := s.canStartLocked(req); ok {
93 s.activateLocked(req)
94 s.mu.Unlock()
95 return s.makeRelease(req), nil
96 } else if req.Nested {
97 s.mu.Unlock()
98 return noop, fmt.Errorf("subagent concurrency limit reached (%s); nested subagents fail fast to avoid parent/child slot deadlock", reason)
99 }
100
101 w := &schedulerWaiter{req: req, ready: make(chan struct{})}
102 s.waiters = append(s.waiters, w)
103 s.mu.Unlock()
104
105 select {
106 case <-w.ready:
107 if w.failed != nil {
108 return noop, w.failed
109 }
110 return s.makeRelease(req), nil
111 case <-ctx.Done():
112 s.mu.Lock()
113 s.removeWaiterLocked(w)
114 s.mu.Unlock()
115 // If we were activated between cancel and remove, release.
116 select {
117 case <-w.ready:
118 if w.failed == nil {
119 s.makeRelease(req)()
120 }
121 default:
122 }
123 return noop, ctx.Err()
124 }
125 }
126
127 // TryClaimWritePaths checks whether paths conflict with active claims without
128 // taking a concurrency slot. Used for diagnostics; prefer ReserveParentWrite
129 // for parent agent writes so the check is not TOCTOU with subagent Acquire.
130 func (s *SubagentScheduler) TryClaimWritePaths(paths WritePathSet) error {
131 if s == nil || paths.Empty() {
132 return nil
133 }
134 s.mu.Lock()
135 defer s.mu.Unlock()
136 return s.conflictLocked(paths)
137 }
138
139 // ReserveParentWrite holds paths against overlapping subagent claims for the
140 // duration of a parent write-tool Execute. It does not consume subagent
141 // concurrency slots. On conflict it fails immediately (parent cannot queue
142 // behind background jobs mid-tool-call). release must be called once when the
143 // write finishes so queued subagents can proceed.
144 func (s *SubagentScheduler) ReserveParentWrite(paths WritePathSet) (release func(), err error) {
145 noop := func() {}
146 if s == nil || paths.Empty() {
147 return noop, nil
148 }
149 s.mu.Lock()
150 if err := s.conflictLocked(paths); err != nil {
151 s.mu.Unlock()
152 return noop, err
153 }
154 s.parentClaims = append(s.parentClaims, paths)
155 s.mu.Unlock()
156
157 var once sync.Once
158 return func() {
159 once.Do(func() {
160 s.mu.Lock()
161 s.parentClaims = removeClaim(s.parentClaims, paths)
162 s.pumpWaitersLocked()
163 s.mu.Unlock()
164 })
165 }, nil
166 }
167
168 // ActiveWriterClaims returns a snapshot of subagent + parent write claims.
169 func (s *SubagentScheduler) ActiveWriterClaims() []WritePathSet {
170 if s == nil {
171 return nil
172 }
173 s.mu.Lock()
174 defer s.mu.Unlock()
175 out := make([]WritePathSet, 0, len(s.activeClaims)+len(s.parentClaims))
176 out = append(out, s.activeClaims...)
177 out = append(out, s.parentClaims...)
178 return out
179 }
180
181 func (s *SubagentScheduler) conflictLocked(paths WritePathSet) error {
182 if paths.Empty() {
183 return nil
184 }
185 for _, active := range s.activeClaims {
186 if active.Overlaps(paths) {
187 return fmt.Errorf("write path is claimed by a running background subagent; wait for it to finish before writing the same path")
188 }
189 }
190 for _, active := range s.parentClaims {
191 if active.Overlaps(paths) {
192 return fmt.Errorf("write path is claimed by another parent write in progress")
193 }
194 }
195 return nil
196 }
197
198 func (s *SubagentScheduler) makeRelease(req AcquireRequest) func() {
199 var once sync.Once
200 return func() {
201 once.Do(func() {
202 s.mu.Lock()
203 s.deactivateLocked(req)
204 s.pumpWaitersLocked()
205 s.mu.Unlock()
206 })
207 }
208 }
209
210 func (s *SubagentScheduler) canStartLocked(req AcquireRequest) (bool, string) {
211 if s.activeTotal >= s.maxTotal {
212 return false, fmt.Sprintf("total concurrency %d/%d", s.activeTotal, s.maxTotal)
213 }
214 if !req.Writer {
215 return true, ""
216 }
217 if s.activeWriters >= s.maxWriters {
218 return false, fmt.Sprintf("writer concurrency %d/%d", s.activeWriters, s.maxWriters)
219 }
220 for _, active := range s.activeClaims {
221 if active.Overlaps(req.WritePaths) {
222 return false, "write path conflict with a running subagent"
223 }
224 }
225 for _, active := range s.parentClaims {
226 if active.Overlaps(req.WritePaths) {
227 return false, "write path conflict with a parent write in progress"
228 }
229 }
230 return true, ""
231 }
232
233 func (s *SubagentScheduler) activateLocked(req AcquireRequest) {
234 s.activeTotal++
235 if req.Writer {
236 s.activeWriters++
237 if !req.WritePaths.Empty() {
238 s.activeClaims = append(s.activeClaims, req.WritePaths)
239 }
240 }
241 }
242
243 func (s *SubagentScheduler) deactivateLocked(req AcquireRequest) {
244 if s.activeTotal > 0 {
245 s.activeTotal--
246 }
247 if req.Writer {
248 if s.activeWriters > 0 {
249 s.activeWriters--
250 }
251 if !req.WritePaths.Empty() {
252 s.activeClaims = removeClaim(s.activeClaims, req.WritePaths)
253 }
254 }
255 }
256
257 func (s *SubagentScheduler) pumpWaitersLocked() {
258 if len(s.waiters) == 0 {
259 return
260 }
261 remaining := s.waiters[:0]
262 for _, w := range s.waiters {
263 if ok, _ := s.canStartLocked(w.req); ok {
264 s.activateLocked(w.req)
265 close(w.ready)
266 continue
267 }
268 remaining = append(remaining, w)
269 }
270 s.waiters = remaining
271 }
272
273 func (s *SubagentScheduler) removeWaiterLocked(target *schedulerWaiter) {
274 if len(s.waiters) == 0 {
275 return
276 }
277 out := s.waiters[:0]
278 for _, w := range s.waiters {
279 if w == target {
280 continue
281 }
282 out = append(out, w)
283 }
284 s.waiters = out
285 }
286
287 func removeClaim(claims []WritePathSet, target WritePathSet) []WritePathSet {
288 for i, c := range claims {
289 if writeClaimEqual(c, target) {
290 return append(claims[:i], claims[i+1:]...)
291 }
292 }
293 return claims
294 }
295
296 func writeClaimEqual(a, b WritePathSet) bool {
297 if a.WholeWorkspace != b.WholeWorkspace || a.WorkspaceRoot != b.WorkspaceRoot {
298 return false
299 }
300 if len(a.Paths) != len(b.Paths) {
301 return false
302 }
303 for i := range a.Paths {
304 if a.Paths[i] != b.Paths[i] {
305 return false
306 }
307 }
308 return true
309 }
310
310 lines GO