1package subagents
2
3import (
4 "context"
5 "errors"
6 "fmt"
7 "strings"
8 "sync"
9)
10
11// Completion is one resident child's terminal turn outcome.
12type Completion struct {
13 AgentID string
14 TurnID string
15 Status string
16 Task string
17 Error string
18 Summary string
19}
20
21// Completion projects a typed resident outcome into the parent notification
22// shared by interactive and orchestrated modes.
23func (c ResidentCompletion) Completion() Completion {
24 result := Completion{AgentID: c.ChildID, TurnID: c.TurnID, Status: string(ResidentCompleted), Task: c.Task, Summary: c.Summary}
25 if c.Err != nil {
26 result.Status, result.Error = string(ResidentFailed), c.Err.Error()
27 if errors.Is(c.Err, context.Canceled) {
28 result.Status = string(ResidentInterrupted)
29 }
30 }
31 return result
32}
33
34// CompletionTracker coordinates resident child completions with the parent
35// turn. It has no process or worker-event dependency.
36type CompletionTracker struct {
37 mu sync.Mutex
38 pending map[string]struct{}
39 ready []Completion
40 changed chan struct{}
41}
42
43func NewCompletionTracker() *CompletionTracker {
44 return &CompletionTracker{changed: make(chan struct{}), pending: make(map[string]struct{})}
45}
46
47// TrackResident records an accepted resident turn. It returns false when the
48// turn is incomplete or already tracked.
49func (t *CompletionTracker) TrackResident(agentID, turnID string) bool {
50 if t == nil {
51 return false
52 }
53 key := completionKey(agentID, turnID)
54 if key == "" {
55 return false
56 }
57 t.mu.Lock()
58 defer t.mu.Unlock()
59 if _, exists := t.pending[key]; exists {
60 return false
61 }
62 t.pending[key] = struct{}{}
63 t.signalLocked()
64 return true
65}
66
67// Report accepts one terminal completion for an accepted resident turn. It
68// returns false for duplicate or unknown terminal reports.
69func (t *CompletionTracker) Report(completion Completion) bool {
70 if t == nil {
71 return false
72 }
73 key := completionKey(completion.AgentID, completion.TurnID)
74 if key == "" {
75 return false
76 }
77 t.mu.Lock()
78 defer t.mu.Unlock()
79 if _, exists := t.pending[key]; !exists {
80 return false
81 }
82 delete(t.pending, key)
83 t.ready = append(t.ready, completion)
84 t.signalLocked()
85 return true
86}
87
88func completionKey(agentID, turnID string) string {
89 if agentID == "" || turnID == "" {
90 return ""
91 }
92 return agentID + "\x00" + turnID
93}
94
95func (t *CompletionTracker) Pending() int {
96 if t == nil {
97 return 0
98 }
99 t.mu.Lock()
100 defer t.mu.Unlock()
101 return len(t.pending)
102}
103
104// Reset drops buffered outcomes at a parent-turn cancellation boundary.
105func (t *CompletionTracker) Reset() {
106 if t == nil {
107 return
108 }
109 t.mu.Lock()
110 t.pending = make(map[string]struct{})
111 t.ready = nil
112 t.signalLocked()
113 t.mu.Unlock()
114}
115
116func (t *CompletionTracker) WaitIdle(ctx context.Context) ([]Completion, error) {
117 return t.wait(ctx, true)
118}
119
120// WaitReady returns available completions without waiting for sibling turns.
121// It returns an empty batch when no accepted turns remain.
122func (t *CompletionTracker) WaitReady(ctx context.Context) ([]Completion, error) {
123 return t.wait(ctx, false)
124}
125
126func (t *CompletionTracker) wait(ctx context.Context, idle bool) ([]Completion, error) {
127 if t == nil {
128 return nil, nil
129 }
130 for {
131 t.mu.Lock()
132 if len(t.pending) == 0 || (!idle && len(t.ready) != 0) {
133 ready := append([]Completion(nil), t.ready...)
134 t.ready = nil
135 t.mu.Unlock()
136 return ready, nil
137 }
138 changed := t.changed
139 t.mu.Unlock()
140 select {
141 case <-ctx.Done():
142 return nil, ctx.Err()
143 case <-changed:
144 }
145 }
146}
147
148func (t *CompletionTracker) signalLocked() { close(t.changed); t.changed = make(chan struct{}) }
149
150func FormatCompletionUpdate(batch []Completion, instruction string) string {
151 return FormatCompletionUpdateWithActive(batch, nil, instruction)
152}
153
154// FormatCompletionUpdateWithActive formats terminal outcomes and identifies
155// sibling agents that are still running when this update is emitted.
156func FormatCompletionUpdateWithActive(batch []Completion, activeAgentIDs []string, instruction string) string {
157 if len(batch) == 0 {
158 return ""
159 }
160 var b strings.Builder
161 b.WriteString("[auto-subagents update]\n")
162 for _, completion := range batch {
163 fmt.Fprintf(&b, "- %s: %s", completion.AgentID, completion.Status)
164 if completion.Error != "" {
165 fmt.Fprintf(&b, " (%s)", completion.Error)
166 }
167 if completion.Task != "" {
168 fmt.Fprintf(&b, " — %s", completion.Task)
169 }
170 if completion.Summary != "" {
171 label := "final"
172 if completion.Status != string(ResidentCompleted) {
173 label = "partial"
174 }
175 fmt.Fprintf(&b, "\n %s: %s", label, completion.Summary)
176 }
177 b.WriteByte('\n')
178 }
179 if len(activeAgentIDs) != 0 {
180 b.WriteString("Still running:")
181 for _, agentID := range activeAgentIDs {
182 fmt.Fprintf(&b, " %s", agentID)
183 }
184 b.WriteByte('\n')
185 }
186 if instruction != "" {
187 b.WriteString(instruction)
188 }
189 return strings.TrimSpace(b.String())
190}