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