返回 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 activeLive []liveClaim
47 nextClaimID int64
48 // parentClaims are write paths held by the parent agent during a write-tool
49 // Execute. They block overlapping subagent claims without consuming a
50 // subagent concurrency slot (parent is not a subagent).
51 parentClaims []WritePathSet
52
53 // waiters are FIFO waiters for non-nested acquires.
54 waiters []*schedulerWaiter
55 }
56
57 type schedulerWaiter struct {
58 req AcquireRequest
59 ready chan struct{}
60 failed error
61 id int64
62 }
63
64 // NewSubagentScheduler builds a scheduler with the given limits (normalized).
65 func NewSubagentScheduler(maxTotal, maxWriters int) *SubagentScheduler {
66 maxTotal, maxWriters = NormalizeConcurrencyLimits(maxTotal, maxWriters)
67 return &SubagentScheduler{maxTotal: maxTotal, maxWriters: maxWriters}
68 }
69
70 // Limits returns the effective total/writer caps.
71 func (s *SubagentScheduler) Limits() (total, writers int) {
72 if s == nil {
73 return DefaultMaxSubagentConcurrency, DefaultMaxParallelWriters
74 }
75 return s.maxTotal, s.maxWriters
76 }
77
78 // Acquire reserves a concurrency slot (and optional write claim). Nested
79 // requests fail immediately when capacity is exhausted. Non-nested requests
80 // queue until capacity is free or ctx is cancelled.
81 //
82 // The returned release function must be called exactly once when the sub-agent
83 // finishes. release is safe to call even if Acquire returns an error (no-op).
84 func (s *SubagentScheduler) Acquire(ctx context.Context, req AcquireRequest) (release func(), err error) {
85 release, _, err = s.AcquireWithID(ctx, req)
86 return release, err
87 }
88
89 // AcquireWithID is Acquire plus the live claim id used by Realize/MarkOpaque.
90 func (s *SubagentScheduler) AcquireWithID(ctx context.Context, req AcquireRequest) (release func(), claimID int64, err error) {
91 noop := func() {}
92 if s == nil {
93 return noop, 0, nil
94 }
95 if ctx == nil {
96 ctx = context.Background()
97 }
98
99 s.mu.Lock()
100 if ok, reason := s.canStartIncomingLocked(req); ok {
101 id := s.activateLocked(req)
102 s.mu.Unlock()
103 return s.makeReleaseID(id), id, nil
104 } else if req.Nested {
105 s.mu.Unlock()
106 return noop, 0, fmt.Errorf("subagent concurrency limit reached (%s); nested subagents fail fast to avoid parent/child slot deadlock", reason)
107 }
108
109 w := &schedulerWaiter{req: req, ready: make(chan struct{})}
110 s.waiters = append(s.waiters, w)
111 s.mu.Unlock()
112
113 select {
114 case <-w.ready:
115 if w.failed != nil {
116 return noop, 0, w.failed
117 }
118 return s.makeReleaseID(w.id), w.id, nil
119 case <-ctx.Done():
120 s.mu.Lock()
121 s.removeWaiterLocked(w)
122 s.pumpWaitersLocked()
123 s.mu.Unlock()
124 select {
125 case <-w.ready:
126 if w.failed == nil {
127 s.makeReleaseID(w.id)()
128 }
129 default:
130 }
131 return noop, 0, ctx.Err()
132 }
133 }
134
135 // TryClaimWritePaths checks whether paths conflict with active claims without
136 // taking a concurrency slot. Used for diagnostics; prefer ReserveParentWrite
137 // for parent agent writes so the check is not TOCTOU with subagent Acquire.
138 func (s *SubagentScheduler) TryClaimWritePaths(paths WritePathSet) error {
139 if s == nil || paths.Empty() {
140 return nil
141 }
142 s.mu.Lock()
143 defer s.mu.Unlock()
144 return s.conflictLocked(paths)
145 }
146
147 // Realize records path-bound writes against an active claim. Directory and
148 // whole-workspace declarations shrink to the realized files when no opaque
149 // mutation has occurred. Same-file realizes from two live writers fail.
150 func (s *SubagentScheduler) Realize(id int64, paths WritePathSet) error {
151 if s == nil || id == 0 || paths.Empty() {
152 return nil
153 }
154 s.mu.Lock()
155 defer s.mu.Unlock()
156 idx := s.liveIndexLocked(id)
157 if idx < 0 {
158 return fmt.Errorf("write path is claimed by a running background subagent; wait for it to finish before writing the same path")
159 }
160 claim := s.activeLive[idx]
161 if claim.opaque {
162 return nil
163 }
164 nextPaths := mergeRealized(claim.realized, paths)
165 next := fileReservation(claim.declared.WorkspaceRoot, nextPaths)
166 if err := s.conflictAgainstOthersLocked(id, next); err != nil {
167 return err
168 }
169 claim.realized = nextPaths
170 s.activeLive[idx] = claim
171 s.pumpWaitersLocked()
172 return nil
173 }
174
175 // MarkOpaque upgrades a live claim to a whole-workspace reservation (bash/MCP).
176 func (s *SubagentScheduler) MarkOpaque(id int64) error {
177 if s == nil || id == 0 {
178 return nil
179 }
180 s.mu.Lock()
181 defer s.mu.Unlock()
182 idx := s.liveIndexLocked(id)
183 if idx < 0 {
184 return fmt.Errorf("write path is claimed by a running background subagent; wait for it to finish before writing the same path")
185 }
186 claim := s.activeLive[idx]
187 if claim.opaque {
188 return nil
189 }
190 next := wholeReservation(claim.declared.WorkspaceRoot)
191 if err := s.conflictAgainstOthersLocked(id, next); err != nil {
192 return err
193 }
194 claim.opaque = true
195 s.activeLive[idx] = claim
196 return nil
197 }
198
199 // ReserveParentWrite holds paths against overlapping subagent claims for the
200 // duration of a parent write-tool Execute. It does not consume subagent
201 // concurrency slots. On conflict it fails immediately (parent cannot queue
202 // behind background jobs mid-tool-call). release must be called once when the
203 // write finishes so queued subagents can proceed.
204 func (s *SubagentScheduler) ReserveParentWrite(paths WritePathSet) (release func(), err error) {
205 noop := func() {}
206 if s == nil || paths.Empty() {
207 return noop, nil
208 }
209 s.mu.Lock()
210 if err := s.conflictLocked(paths); err != nil {
211 s.mu.Unlock()
212 return noop, err
213 }
214 s.parentClaims = append(s.parentClaims, paths)
215 s.mu.Unlock()
216
217 var once sync.Once
218 return func() {
219 once.Do(func() {
220 s.mu.Lock()
221 s.parentClaims = removeClaim(s.parentClaims, paths)
222 s.pumpWaitersLocked()
223 s.mu.Unlock()
224 })
225 }, nil
226 }
227
228 // ActiveWriterClaims returns a snapshot of subagent + parent write claims.
229 func (s *SubagentScheduler) ActiveWriterClaims() []WritePathSet {
230 if s == nil {
231 return nil
232 }
233 s.mu.Lock()
234 defer s.mu.Unlock()
235 out := make([]WritePathSet, 0, len(s.activeLive)+len(s.parentClaims))
236 for _, live := range s.activeLive {
237 if !live.writer {
238 continue
239 }
240 res := live.reservation()
241 if res.Empty() {
242 if live.declared.Empty() {
243 continue
244 }
245 res = live.declared
246 }
247 out = append(out, res)
248 }
249 out = append(out, s.parentClaims...)
250 return out
251 }
252
253 func (s *SubagentScheduler) conflictLocked(paths WritePathSet) error {
254 if paths.WholeWorkspace {
255 for _, live := range s.activeLive {
256 if live.writer {
257 return fmt.Errorf("write path is claimed by a running background subagent; wait for it to finish before writing the same path")
258 }
259 }
260 }
261 return s.conflictAgainstOthersLocked(0, paths)
262 }
263
264 func (s *SubagentScheduler) conflictAgainstOthersLocked(skipID int64, paths WritePathSet) error {
265 if paths.Empty() {
266 return nil
267 }
268 for _, live := range s.activeLive {
269 if live.id == skipID {
270 continue
271 }
272 if ScheduleOverlaps(live.reservation(), paths) {
273 return fmt.Errorf("write path is claimed by a running background subagent; wait for it to finish before writing the same path")
274 }
275 }
276 for _, active := range s.parentClaims {
277 if ScheduleOverlaps(active, paths) {
278 return fmt.Errorf("write path is claimed by another parent write in progress")
279 }
280 }
281 return nil
282 }
283
284 func (s *SubagentScheduler) makeReleaseID(id int64) func() {
285 var once sync.Once
286 return func() {
287 once.Do(func() {
288 s.mu.Lock()
289 s.deactivateIDLocked(id)
290 s.pumpWaitersLocked()
291 s.mu.Unlock()
292 })
293 }
294 }
295
296 func (s *SubagentScheduler) liveIndexLocked(id int64) int {
297 for i, live := range s.activeLive {
298 if live.id == id {
299 return i
300 }
301 }
302 return -1
303 }
304
305 func (s *SubagentScheduler) canStartLocked(req AcquireRequest) (bool, string) {
306 if s.activeTotal >= s.maxTotal {
307 return false, fmt.Sprintf("total concurrency %d/%d", s.activeTotal, s.maxTotal)
308 }
309 if !req.Writer {
310 return true, ""
311 }
312 if s.activeWriters >= s.maxWriters {
313 return false, fmt.Sprintf("writer concurrency %d/%d", s.activeWriters, s.maxWriters)
314 }
315 if req.WritePaths.WholeWorkspace {
316 for _, live := range s.activeLive {
317 if live.writer {
318 return false, "whole-workspace claim conflicts with a running writer"
319 }
320 }
321 }
322 for _, live := range s.activeLive {
323 if ScheduleOverlaps(req.WritePaths, live.reservation()) {
324 return false, "write path conflict with a running subagent"
325 }
326 }
327 for _, active := range s.parentClaims {
328 if ScheduleOverlaps(req.WritePaths, active) {
329 return false, "write path conflict with a parent write in progress"
330 }
331 }
332 return true, ""
333 }
334
335 func (s *SubagentScheduler) activateLocked(req AcquireRequest) int64 {
336 s.activeTotal++
337 s.nextClaimID++
338 id := s.nextClaimID
339 if req.Writer {
340 s.activeWriters++
341 }
342 s.activeLive = append(s.activeLive, liveClaim{id: id, writer: req.Writer, declared: req.WritePaths})
343 return id
344 }
345
346 func (s *SubagentScheduler) deactivateIDLocked(id int64) {
347 idx := s.liveIndexLocked(id)
348 if idx < 0 {
349 return
350 }
351 if s.activeTotal > 0 {
352 s.activeTotal--
353 }
354 if s.activeLive[idx].writer && s.activeWriters > 0 {
355 s.activeWriters--
356 }
357 s.activeLive = append(s.activeLive[:idx], s.activeLive[idx+1:]...)
358 }
359
360 func (s *SubagentScheduler) pumpWaitersLocked() {
361 if len(s.waiters) == 0 {
362 return
363 }
364 remaining := s.waiters[:0]
365 // A blocked whole-workspace writer is a FIFO barrier for later writers,
366 // while read-only work may still use otherwise available capacity.
367 wholeWriterPending := false
368 for _, w := range s.waiters {
369 if wholeWriterPending && w.req.Writer {
370 remaining = append(remaining, w)
371 continue
372 }
373 if ok, _ := s.canStartLocked(w.req); ok {
374 w.id = s.activateLocked(w.req)
375 close(w.ready)
376 continue
377 }
378 remaining = append(remaining, w)
379 if w.req.Writer && w.req.WritePaths.WholeWorkspace {
380 wholeWriterPending = true
381 }
382 }
383 s.waiters = remaining
384 }
385
386 func (s *SubagentScheduler) removeWaiterLocked(target *schedulerWaiter) {
387 if len(s.waiters) == 0 {
388 return
389 }
390 out := s.waiters[:0]
391 for _, w := range s.waiters {
392 if w == target {
393 continue
394 }
395 out = append(out, w)
396 }
397 s.waiters = out
398 }
399
400 func removeClaim(claims []WritePathSet, target WritePathSet) []WritePathSet {
401 for i, c := range claims {
402 if writeClaimEqual(c, target) {
403 return append(claims[:i], claims[i+1:]...)
404 }
405 }
406 return claims
407 }
408
409 func writeClaimEqual(a, b WritePathSet) bool {
410 if a.WholeWorkspace != b.WholeWorkspace || a.WorkspaceRoot != b.WorkspaceRoot {
411 return false
412 }
413 if len(a.Paths) != len(b.Paths) {
414 return false
415 }
416 for i := range a.Paths {
417 if a.Paths[i] != b.Paths[i] {
418 return false
419 }
420 }
421 return true
422 }
423
423 lines GO