返回 DeepSeek-Reasonix
subagent_progress.go
根目录 / internal / agent / subagent_progress.go
1 package agent
2
3 import (
4 "context"
5 "sync"
6 "time"
7 "unicode/utf8"
8
9 "reasonix/internal/event"
10 )
11
12 // Sub-agent progress previews. A tracker per child run converts the child's
13 // Reasoning/Text/Notice/Retrying events into reserved ToolProgress channel
14 // events (event.SubagentProgress*Name) that local frontends render as progress
15 // cards. A shared merger per parent task group paces and bounds the previews:
16 // one pending slot per (child, channel), a 250ms merge window, and a group
17 // budget of 32 non-terminal preview events/sec round-robined across children
18 // so one hot sub-agent cannot starve the rest. The child's Message, and the
19 // child's own reasoning/text bodies, never leave the progress pipeline.
20
21 // subagentProgressPhase is one of the fixed states the status channel carries.
22 type subagentProgressPhase string
23
24 const (
25 subagentPhaseQueued subagentProgressPhase = "queued"
26 subagentPhaseRunning subagentProgressPhase = "running"
27 subagentPhaseReasoning subagentProgressPhase = "reasoning"
28 subagentPhaseResponding subagentProgressPhase = "responding"
29 subagentPhaseTool subagentProgressPhase = "tool"
30 subagentPhaseRetrying subagentProgressPhase = "retrying"
31 subagentPhaseCompleted subagentProgressPhase = "completed"
32 subagentPhaseFailed subagentProgressPhase = "failed"
33 subagentPhaseCancelled subagentProgressPhase = "cancelled"
34 )
35
36 // Progress pacing and memory bounds. Preview slots merge for up to
37 // subagentProgressMergeWindow before one event per (child, channel) is emitted;
38 // a parent task group caps non-terminal preview events at
39 // subagentProgressGroupEventsPerSec, round-robined across children. Terminal
40 // events and the pre-terminal synchronous flush bypass both limits — the flush
41 // is inherently bounded by the per-child pending budget below.
42 const (
43 subagentProgressMergeWindow = 250 * time.Millisecond
44 subagentProgressGroupEventsPerSec = 32
45 subagentProgressGroupBurst = subagentProgressGroupEventsPerSec
46
47 // Per-child pending-send budget: reasoning/text/notice slots share 8 KiB,
48 // with a per-channel cap so one channel cannot crowd out the response
49 // preview. When the shared budget overflows, the notice slot is dropped
50 // first, then reasoning, then text — each keeping a UTF-8-safe tail.
51 subagentProgressMaxPendingBytes = 8 << 10
52 subagentProgressReasoningCap = 8 << 10
53 subagentProgressTextCap = 8 << 10
54 subagentProgressNoticeCap = 2 << 10
55 )
56
57 // progressClock isolates time so tests drive merge windows with a fake clock.
58 type progressClock interface {
59 Now() time.Time
60 NewTimer(d time.Duration) progressTimer
61 }
62
63 // progressTimer mirrors the *time.Timer surface the merger needs.
64 type progressTimer interface {
65 C() <-chan time.Time
66 Reset(d time.Duration) bool
67 Stop() bool
68 }
69
70 type realProgressClock struct{}
71
72 func (realProgressClock) Now() time.Time { return time.Now() }
73
74 func (realProgressClock) NewTimer(d time.Duration) progressTimer {
75 return realProgressTimer{t: time.NewTimer(d)}
76 }
77
78 type realProgressTimer struct{ t *time.Timer }
79
80 func (r realProgressTimer) C() <-chan time.Time { return r.t.C }
81 func (r realProgressTimer) Reset(d time.Duration) bool { return r.t.Reset(d) }
82 func (r realProgressTimer) Stop() bool { return r.t.Stop() }
83
84 // subagentProgressChannel identifies one preview channel.
85 type subagentProgressChannel int
86
87 const (
88 subagentProgressChanReasoning subagentProgressChannel = iota
89 subagentProgressChanText
90 subagentProgressChanNotice
91 )
92
93 func (c subagentProgressChannel) name() string {
94 switch c {
95 case subagentProgressChanReasoning:
96 return event.SubagentProgressReasoningName
97 case subagentProgressChanText:
98 return event.SubagentProgressTextName
99 default:
100 return event.SubagentProgressNoticeName
101 }
102 }
103
104 func (c subagentProgressChannel) cap() int {
105 switch c {
106 case subagentProgressChanReasoning:
107 return subagentProgressReasoningCap
108 case subagentProgressChanText:
109 return subagentProgressTextCap
110 default:
111 return subagentProgressNoticeCap
112 }
113 }
114
115 // progressSlot is the single pending slot for one (child, channel): at most one
116 // unsent merged slice per child+channel, so pending preview memory is bounded
117 // by construction. dueAt is the earliest time the merged slice may be sent.
118 type progressSlot struct {
119 buf string
120 truncated bool
121 dirty bool
122 dueAt time.Time
123 lastSend time.Time
124 }
125
126 // progressStatusSlot holds the latest unsent phase for one child. Ordinary
127 // phase transitions share the group preview budget with content previews (a
128 // fleet of phase-flapping children must not exceed the 32 events/s contract);
129 // only the initial queued/running states and the terminal event bypass it.
130 type progressStatusSlot struct {
131 phase subagentProgressPhase
132 dirty bool
133 dueAt time.Time
134 lastSend time.Time
135 }
136
137 // subagentProgressMerger paces and bounds progress previews for one parent
138 // task group (a single task, a parallel_tasks call, or a fleet). It owns one
139 // flusher goroutine that emits due slots round-robin; every owner must Close it
140 // after all children finish so no timer or goroutine outlives the group.
141 type subagentProgressMerger struct {
142 mu sync.Mutex
143 clock progressClock
144 sink event.Sink // the same sink the group's dispatch events flow through
145 groupParentID string // the group's own call ID (progress events' ParentID)
146
147 slots map[string]map[subagentProgressChannel]*progressSlot
148 status map[string]*progressStatusSlot
149 order []string // child IDs in registration order, for round-robin
150 rr int // rotating scan start for fairness
151
152 tokens float64 // preview budget: subagentProgressGroupEventsPerSec
153 lastRefill time.Time
154
155 timer progressTimer
156 wake chan struct{}
157 done chan struct{}
158 wg sync.WaitGroup
159 closed bool
160
161 // truncatedPending marks children whose buffered content was dropped by a
162 // budget trim while no event carried the Truncated flag yet; the flag is
163 // propagated to the next actually-emitted preview channel.
164 truncatedPending map[string]bool
165 }
166
167 func newSubagentProgressMerger(clock progressClock, sink event.Sink, groupParentID string) *subagentProgressMerger {
168 now := clock.Now()
169 m := &subagentProgressMerger{
170 clock: clock,
171 sink: sink,
172 groupParentID: groupParentID,
173 slots: make(map[string]map[subagentProgressChannel]*progressSlot),
174 status: make(map[string]*progressStatusSlot),
175 tokens: subagentProgressGroupBurst,
176 lastRefill: now,
177 wake: make(chan struct{}, 1),
178 done: make(chan struct{}),
179 timer: clock.NewTimer(0),
180 truncatedPending: make(map[string]bool),
181 }
182 m.wg.Add(1)
183 go m.run()
184 return m
185 }
186
187 // Close stops the flusher goroutine and drops any pending state. The owner
188 // calls it only after every child has finished (each child's finish flushed
189 // its own slots), so Close never discards a needed preview.
190 func (m *subagentProgressMerger) Close() {
191 m.mu.Lock()
192 if m.closed {
193 m.mu.Unlock()
194 return
195 }
196 m.closed = true
197 m.mu.Unlock()
198 close(m.done)
199 m.wg.Wait()
200 }
201
202 // directStatus sends a status event immediately (bypassing the merge slot and
203 // group budget) and records the send on the child's status slot so the next
204 // transition still merges for the 250ms window after this send. Used for the
205 // guaranteed-first states (queued/running); terminal events go through
206 // flushChild instead.
207 func (m *subagentProgressMerger) directStatus(childID string, phase subagentProgressPhase) {
208 m.mu.Lock()
209 st := m.status[childID]
210 if st == nil {
211 st = &progressStatusSlot{}
212 m.status[childID] = st
213 m.ensureOrderLocked(childID)
214 }
215 st.lastSend = m.clock.Now()
216 st.dirty = false
217 st.phase = phase
218 m.mu.Unlock()
219 parentID := m.groupParentID
220 if parentID == childID {
221 parentID = ""
222 }
223 m.sink.Emit(event.Event{
224 Kind: event.ToolProgress,
225 Tool: event.Tool{
226 ID: childID, Name: event.SubagentProgressStatusName,
227 ParentID: parentID, Output: string(phase),
228 },
229 })
230 }
231
232 // statusEvent queues a phase transition for a child. The first transition per
233 // child sends immediately; later transitions merge into the status slot.
234 func (m *subagentProgressMerger) statusEvent(childID string, phase subagentProgressPhase) {
235 m.mu.Lock()
236 defer m.mu.Unlock()
237 if m.closed {
238 return
239 }
240 st := m.status[childID]
241 if st == nil {
242 st = &progressStatusSlot{}
243 m.status[childID] = st
244 m.ensureOrderLocked(childID)
245 }
246 if !st.dirty {
247 st.dirty = true
248 // The first status send is immediate; later transitions merge for the
249 // 250ms window after the previous send.
250 dueAt := m.clock.Now()
251 if !st.lastSend.IsZero() {
252 if after := st.lastSend.Add(subagentProgressMergeWindow); after.After(dueAt) {
253 dueAt = after
254 }
255 }
256 st.dueAt = dueAt
257 }
258 st.phase = phase
259 m.wakeLocked()
260 }
261
262 // deltaEvent appends a text delta to a child's preview slot. The slot is the
263 // only pending slice for that (child, channel); overflow keeps a UTF-8-safe
264 // tail and marks the round truncated.
265 func (m *subagentProgressMerger) deltaEvent(childID string, ch subagentProgressChannel, delta string) {
266 if delta == "" {
267 return
268 }
269 m.mu.Lock()
270 defer m.mu.Unlock()
271 if m.closed {
272 return
273 }
274 if _, ok := m.slots[childID]; !ok {
275 m.slots[childID] = make(map[subagentProgressChannel]*progressSlot)
276 m.ensureOrderLocked(childID)
277 }
278 sl := m.slots[childID][ch]
279 if sl == nil {
280 sl = &progressSlot{}
281 m.slots[childID][ch] = sl
282 }
283 if !sl.dirty {
284 sl.dirty = true
285 sl.dueAt = m.clock.Now().Add(subagentProgressMergeWindow)
286 }
287 sl.buf += delta
288 if len(sl.buf) > ch.cap() {
289 sl.buf = utf8SafeTail(sl.buf, ch.cap())
290 sl.truncated = true
291 }
292 m.trimToBudgetLocked(childID)
293 m.wakeLocked()
294 }
295
296 // flushChild synchronously emits everything pending for the child and then the
297 // terminal status event. Terminal events bypass merge windows and the group
298 // budget; the flush is bounded by the per-child pending budget. Called by the
299 // tracker's finish before any terminal is delivered, and only once per child.
300 func (m *subagentProgressMerger) flushChild(childID string, terminal subagentProgressPhase, durationMs int64) {
301 m.mu.Lock()
302 defer m.mu.Unlock()
303 if m.closed {
304 return
305 }
306 st := m.status[childID]
307 if st != nil && st.dirty {
308 phase := st.phase
309 st.dirty = false
310 m.emitStatusLocked(childID, phase, 0)
311 }
312 for c := subagentProgressChanReasoning; c <= subagentProgressChanNotice; c++ {
313 if sl := m.slots[childID][c]; sl != nil && sl.dirty {
314 m.emitDeltaLocked(childID, c, sl)
315 }
316 }
317 m.emitStatusLocked(childID, terminal, durationMs)
318 // A budget trim that dropped content with no channel left to carry the
319 // Truncated flag is surfaced as a truncated notice so frontends still know
320 // some preview content was lost.
321 if m.truncatedPending[childID] {
322 m.emitToolProgressLocked(childID, event.SubagentProgressNoticeName, "", true, 0)
323 }
324 // Release per-child state; later events for this child are ignored by the
325 // tracker's own done flag, and the flusher has nothing left to wake for.
326 delete(m.status, childID)
327 delete(m.slots, childID)
328 delete(m.truncatedPending, childID)
329 m.removeOrderLocked(childID)
330 }
331
332 // run is the merger's flusher loop: drain due slots, then sleep until the
333 // earliest deadline, a wake, or Close. The loop never holds the mutex while
334 // sleeping, so queueing trackers never block on it.
335 func (m *subagentProgressMerger) run() {
336 defer m.wg.Done()
337 defer m.timer.Stop()
338 for {
339 m.mu.Lock()
340 for m.stepLocked() {
341 }
342 closed := m.closed
343 clean := m.allCleanLocked()
344 if !clean && !closed {
345 d := m.nextDeadlineLocked()
346 m.mu.Unlock()
347 m.timer.Reset(d)
348 select {
349 case <-m.done:
350 return
351 case <-m.timer.C():
352 case <-m.wake:
353 }
354 continue
355 }
356 m.mu.Unlock()
357 if closed {
358 return
359 }
360 select {
361 case <-m.done:
362 return
363 case <-m.wake:
364 }
365 }
366 }
367
368 // stepLocked emits at most one non-terminal progress event, round-robining
369 // across children. Status transitions and content previews share the group
370 // budget; the initial queued/running (directStatus) and terminal events
371 // bypass it. Returns false when nothing can be emitted right now.
372 func (m *subagentProgressMerger) stepLocked() bool {
373 m.refillLocked()
374 n := len(m.order)
375 if n == 0 {
376 return false
377 }
378 now := m.clock.Now()
379 for i := 0; i < n; i++ {
380 idx := (m.rr + i) % n
381 childID := m.order[idx]
382 if m.tokens < 1 {
383 // Budget exhausted: leave the round-robin position in place so no
384 // child is skipped once a token refills.
385 return false
386 }
387 if st := m.status[childID]; st != nil && st.dirty && !now.Before(st.dueAt) {
388 m.rr = (idx + 1) % n
389 phase := st.phase
390 st.dirty = false
391 st.lastSend = now
392 m.tokens--
393 m.emitStatusLocked(childID, phase, 0)
394 return true
395 }
396 for c := subagentProgressChanReasoning; c <= subagentProgressChanNotice; c++ {
397 if sl := m.slots[childID][c]; sl != nil && sl.dirty && !now.Before(sl.dueAt) {
398 m.rr = (idx + 1) % n
399 m.tokens--
400 m.emitDeltaLocked(childID, c, sl)
401 return true
402 }
403 }
404 }
405 return false
406 }
407
408 func (m *subagentProgressMerger) allCleanLocked() bool {
409 for _, st := range m.status {
410 if st.dirty {
411 return false
412 }
413 }
414 for _, chs := range m.slots {
415 for _, sl := range chs {
416 if sl.dirty {
417 return false
418 }
419 }
420 }
421 return true
422 }
423
424 // nextDeadlineLocked returns the wait until the earliest due slot or the next
425 // preview budget token. A zero result means "wake immediately".
426 func (m *subagentProgressMerger) nextDeadlineLocked() time.Duration {
427 now := m.clock.Now()
428 var next time.Time
429 consider := func(t time.Time) {
430 if next.IsZero() || t.Before(next) {
431 next = t
432 }
433 }
434 for _, st := range m.status {
435 if st.dirty {
436 consider(st.dueAt)
437 }
438 }
439 for _, chs := range m.slots {
440 for _, sl := range chs {
441 if sl.dirty {
442 consider(sl.dueAt)
443 }
444 }
445 }
446 if m.tokens < 1 {
447 refillAt := m.lastRefill.Add(time.Duration((1 - m.tokens) * float64(time.Second) / subagentProgressGroupEventsPerSec))
448 consider(refillAt)
449 }
450 if next.IsZero() {
451 return 0
452 }
453 if d := next.Sub(now); d > 0 {
454 return d
455 }
456 return 0
457 }
458
459 func (m *subagentProgressMerger) refillLocked() {
460 now := m.clock.Now()
461 if now.After(m.lastRefill) {
462 elapsed := now.Sub(m.lastRefill).Seconds()
463 m.tokens += elapsed * subagentProgressGroupEventsPerSec
464 if m.tokens > subagentProgressGroupBurst {
465 m.tokens = subagentProgressGroupBurst
466 }
467 m.lastRefill = now
468 }
469 }
470
471 func (m *subagentProgressMerger) emitStatusLocked(childID string, phase subagentProgressPhase, durationMs int64) {
472 m.emitToolProgressLocked(childID, event.SubagentProgressStatusName, string(phase), false, durationMs)
473 }
474
475 func (m *subagentProgressMerger) emitDeltaLocked(childID string, ch subagentProgressChannel, sl *progressSlot) {
476 if sl.buf == "" {
477 sl.dirty = false
478 return
479 }
480 buf, truncated := sl.buf, sl.truncated
481 // Carry a pending trim-truncation on the next actually-emitted channel.
482 if m.truncatedPending[childID] {
483 truncated = true
484 delete(m.truncatedPending, childID)
485 }
486 sl.buf, sl.truncated, sl.dirty = "", false, false
487 sl.lastSend = m.clock.Now()
488 m.emitToolProgressLocked(childID, ch.name(), buf, truncated, 0)
489 }
490
491 func (m *subagentProgressMerger) emitToolProgressLocked(childID, name, output string, truncated bool, durationMs int64) {
492 parentID := m.groupParentID
493 if parentID == childID {
494 parentID = ""
495 }
496 m.sink.Emit(event.Event{
497 Kind: event.ToolProgress,
498 Tool: event.Tool{
499 ID: childID, Name: name, ParentID: parentID,
500 Output: output, Truncated: truncated, DurationMs: durationMs,
501 },
502 })
503 }
504
505 // trimToBudgetLocked keeps the child's pending total at or under
506 // subagentProgressMaxPendingBytes, dropping the lowest-priority channel's
507 // content first (notice < reasoning < text) so the response preview survives.
508 // Every drop marks the child's pending-truncation flag so the loss is
509 // propagated on the next actually-emitted channel (or a truncated notice at
510 // flush when nothing else carries it).
511 func (m *subagentProgressMerger) trimToBudgetLocked(childID string) {
512 if m.pendingBytesLocked(childID) <= subagentProgressMaxPendingBytes {
513 return
514 }
515 if sl := m.slots[childID][subagentProgressChanNotice]; sl != nil && sl.dirty && sl.buf != "" {
516 sl.buf = ""
517 sl.truncated = true
518 m.truncatedPending[childID] = true
519 }
520 for _, ch := range []subagentProgressChannel{subagentProgressChanReasoning, subagentProgressChanText} {
521 over := m.pendingBytesLocked(childID) - subagentProgressMaxPendingBytes
522 if over <= 0 {
523 return
524 }
525 sl := m.slots[childID][ch]
526 if sl == nil || !sl.dirty || sl.buf == "" {
527 continue
528 }
529 keep := len(sl.buf) - over
530 if keep <= 0 {
531 sl.buf = ""
532 } else {
533 sl.buf = utf8SafeTail(sl.buf, keep)
534 }
535 sl.truncated = true
536 m.truncatedPending[childID] = true
537 }
538 }
539
540 func (m *subagentProgressMerger) pendingBytesLocked(childID string) int {
541 total := 0
542 for _, sl := range m.slots[childID] {
543 if sl.dirty {
544 total += len(sl.buf)
545 }
546 }
547 return total
548 }
549
550 func (m *subagentProgressMerger) ensureOrderLocked(childID string) {
551 for _, id := range m.order {
552 if id == childID {
553 return
554 }
555 }
556 m.order = append(m.order, childID)
557 }
558
559 func (m *subagentProgressMerger) removeOrderLocked(childID string) {
560 for i, id := range m.order {
561 if id == childID {
562 m.order = append(m.order[:i], m.order[i+1:]...)
563 return
564 }
565 }
566 }
567
568 func (m *subagentProgressMerger) wakeLocked() {
569 select {
570 case m.wake <- struct{}{}:
571 default:
572 }
573 }
574
575 // utf8SafeTail returns the last maxBytes bytes of s, trimmed to a rune
576 // boundary so a multi-byte character is never split.
577 func utf8SafeTail(s string, maxBytes int) string {
578 if len(s) <= maxBytes {
579 return s
580 }
581 s = s[len(s)-maxBytes:]
582 for len(s) > 0 && !utf8.RuneStart(s[0]) {
583 s = s[1:]
584 }
585 return s
586 }
587
588 // subagentProgressTracker is the per-child state machine installed between a
589 // sub-agent run and its parent sink. It converts the child's reasoning/text/
590 // notice/retrying into preview slots on the group merger, forwards tool
591 // activity unchanged, and guarantees exactly one terminal status event.
592 type subagentProgressTracker struct {
593 mu sync.Mutex
594 merger *subagentProgressMerger
595 childID string
596 sink event.Sink // forwards real tool events (the subSinkFor wrapper)
597 phase subagentProgressPhase
598 started time.Time
599 ownsMerger bool
600 done bool
601 }
602
603 // newSubagentProgressTracker creates (or joins) the group merger and returns a
604 // tracker for one child run. wrapSink is the sink the child's real tool events
605 // already flow through; the tracker's own preview events are emitted through
606 // the merger's sink — the same sink the child's dispatch card flowed through —
607 // so preview IDs always match the card IDs the frontend sees.
608 func newSubagentProgressTracker(ctx context.Context, wrapSink event.Sink) *subagentProgressTracker {
609 parentID, parent, _, ok := CallContext(ctx)
610 merger := subagentProgressMergerFromContext(ctx)
611 owns := false
612 if merger == nil {
613 // Not part of a parent task group: own a merger that emits through
614 // the same sink the dispatch event flowed through (the call context's
615 // raw sink; Discard for headless/direct-execute runs).
616 sink := event.Discard
617 if ok && parent != nil {
618 sink = parent
619 }
620 merger = newSubagentProgressMerger(realProgressClock{}, sink, parentID)
621 owns = true
622 }
623 return &subagentProgressTracker{
624 merger: merger,
625 childID: parentID,
626 sink: wrapSink,
627 started: merger.clock.Now(),
628 ownsMerger: owns,
629 }
630 }
631
632 // queued marks the background registration state; running marks execution
633 // start (or the moment a background job acquires its execution slot).
634 // queued marks the background registration state; running marks execution
635 // start (or the moment a background job acquires its slot). Both are emitted
636 // synchronously — not through the merging status slot — so the first visible
637 // states can never be merged away by a faster follow-up transition: a
638 // background job that grabs its slot microseconds after registration must not
639 // hide the queued state.
640 func (t *subagentProgressTracker) queued() {
641 t.emitStatusDirect(subagentPhaseQueued)
642 }
643
644 func (t *subagentProgressTracker) running() {
645 t.emitStatusDirect(subagentPhaseRunning)
646 }
647
648 func (t *subagentProgressTracker) emitStatusDirect(p subagentProgressPhase) {
649 t.mu.Lock()
650 defer t.mu.Unlock()
651 if t.done {
652 return
653 }
654 t.phase = p
655 t.merger.directStatus(t.childID, p)
656 }
657
658 func (t *subagentProgressTracker) setPhase(p subagentProgressPhase) {
659 t.mu.Lock()
660 defer t.mu.Unlock()
661 t.setPhaseLocked(p)
662 }
663
664 // setPhaseLocked records a phase change and queues the status event; repeat
665 // transitions of the same phase do not re-queue.
666 func (t *subagentProgressTracker) setPhaseLocked(p subagentProgressPhase) {
667 if t.done || t.phase == p {
668 return
669 }
670 t.phase = p
671 t.merger.statusEvent(t.childID, p)
672 }
673
674 // wrap returns the sink the child agent emits into: reasoning/text/notice/
675 // retrying become preview slots; tool activity and usage pass through
676 // unchanged (the child's Message and anything else stay dropped, as before).
677 // Events arriving after the terminal are ignored.
678 func (t *subagentProgressTracker) wrap() event.Sink {
679 return event.FuncSink(func(e event.Event) {
680 t.mu.Lock()
681 if t.done {
682 t.mu.Unlock()
683 return
684 }
685 switch e.Kind {
686 case event.Reasoning:
687 t.setPhaseLocked(subagentPhaseReasoning)
688 t.merger.deltaEvent(t.childID, subagentProgressChanReasoning, e.Text)
689 case event.Text:
690 t.setPhaseLocked(subagentPhaseResponding)
691 t.merger.deltaEvent(t.childID, subagentProgressChanText, e.Text)
692 case event.Notice:
693 text := e.Text
694 if text == "" {
695 text = e.Detail
696 }
697 t.merger.deltaEvent(t.childID, subagentProgressChanNotice, text)
698 case event.Retrying:
699 t.setPhaseLocked(subagentPhaseRetrying)
700 case event.ToolDispatch, event.ToolResult, event.ToolProgress:
701 t.setPhaseLocked(subagentPhaseTool)
702 }
703 t.mu.Unlock()
704 switch e.Kind {
705 case event.ToolDispatch, event.ToolResult, event.ToolProgress:
706 t.sink.Emit(e)
707 case event.Usage:
708 if e.UsageSource == "" {
709 e.UsageSource = event.UsageSourceSubagent
710 }
711 t.sink.Emit(e)
712 }
713 })
714 }
715
716 // finish flushes pending previews, emits the single terminal status, and — if
717 // the tracker owns its merger — closes it. ctxErr non-nil maps to cancelled,
718 // other errors to failed, success to completed. Idempotent: late events and
719 // repeated calls are ignored.
720 func (t *subagentProgressTracker) finish(ctxErr, runErr error) {
721 t.mu.Lock()
722 if t.done {
723 t.mu.Unlock()
724 return
725 }
726 t.done = true
727 phase := subagentPhaseCompleted
728 if ctxErr != nil {
729 phase = subagentPhaseCancelled
730 } else if runErr != nil {
731 phase = subagentPhaseFailed
732 }
733 durationMs := t.merger.clock.Now().Sub(t.started).Milliseconds()
734 t.mu.Unlock()
735 t.merger.flushChild(t.childID, phase, durationMs)
736 if t.ownsMerger {
737 t.merger.Close()
738 }
739 }
740
741 // subagentProgressMergerKey carries the group merger in the child's context so
742 // parallel_tasks/fleet children share one pacing budget per parent call.
743 type subagentProgressMergerKey struct{}
744
745 func withSubagentProgressMerger(ctx context.Context, m *subagentProgressMerger) context.Context {
746 return context.WithValue(ctx, subagentProgressMergerKey{}, m)
747 }
748
749 func subagentProgressMergerFromContext(ctx context.Context) *subagentProgressMerger {
750 m, _ := ctx.Value(subagentProgressMergerKey{}).(*subagentProgressMerger)
751 return m
752 }
753
753 lines GO