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 // Undelivered lists follow-ups steered into the turn that the child never
20 // read because the turn ended first.
21 Undelivered []string
22}
23
24// CompletionDropped is the status of a queued follow-up that was discarded
25// before the child ever started it.
26const CompletionDropped = "dropped"
27
28// maxUndeliveredPreview bounds each undelivered follow-up in a parent update.
29const maxUndeliveredPreview = 120
30
31// Completion projects a typed resident outcome into the parent notification
32// shared by interactive and orchestrated modes.
33func (c ResidentCompletion) Completion() Completion {
34 result := Completion{AgentID: c.ChildID, TurnID: c.TurnID, Status: string(ResidentCompleted), Task: c.Task, Summary: c.Summary, Undelivered: append([]string(nil), c.Undelivered...)}
35 if c.NotStarted {
36 result.Status = CompletionDropped
37 // Cancellation is the expected reason; any other error, such as a
38 // journal failure, explains why the follow-up could not run.
39 if c.Err != nil && !errors.Is(c.Err, context.Canceled) {
40 result.Error = c.Err.Error()
41 }
42 return result
43 }
44 if c.Err != nil {
45 result.Status, result.Error = string(ResidentFailed), c.Err.Error()
46 if errors.Is(c.Err, context.Canceled) {
47 result.Status = string(ResidentInterrupted)
48 }
49 }
50 return result
51}
52
53// CompletionTracker coordinates resident child completions with the parent
54// turn. It has no process or worker-event dependency.
55type CompletionTracker struct {
56 mu sync.Mutex
57 pending map[string]struct{}
58 ready []Completion
59 changed chan struct{}
60}
61
62func NewCompletionTracker() *CompletionTracker {
63 return &CompletionTracker{changed: make(chan struct{}), pending: make(map[string]struct{})}
64}
65
66// TrackResident records an accepted resident turn. It returns false when the
67// turn is incomplete or already tracked.
68func (t *CompletionTracker) TrackResident(agentID, turnID string) bool {
69 if t == nil {
70 return false
71 }
72 key := completionKey(agentID, turnID)
73 if key == "" {
74 return false
75 }
76 t.mu.Lock()
77 defer t.mu.Unlock()
78 if _, exists := t.pending[key]; exists {
79 return false
80 }
81 t.pending[key] = struct{}{}
82 t.signalLocked()
83 return true
84}
85
86// Report accepts one terminal completion for an accepted resident turn. It
87// returns false for duplicate or unknown terminal reports.
88func (t *CompletionTracker) Report(completion Completion) bool {
89 if t == nil {
90 return false
91 }
92 key := completionKey(completion.AgentID, completion.TurnID)
93 if key == "" {
94 return false
95 }
96 t.mu.Lock()
97 defer t.mu.Unlock()
98 if _, exists := t.pending[key]; !exists {
99 return false
100 }
101 delete(t.pending, key)
102 t.ready = append(t.ready, completion)
103 t.signalLocked()
104 return true
105}
106
107func completionKey(agentID, turnID string) string {
108 if agentID == "" || turnID == "" {
109 return ""
110 }
111 return agentID + "\x00" + turnID
112}
113
114func (t *CompletionTracker) Pending() int {
115 if t == nil {
116 return 0
117 }
118 t.mu.Lock()
119 defer t.mu.Unlock()
120 return len(t.pending)
121}
122
123// Reset drops buffered outcomes at a parent-turn cancellation boundary.
124func (t *CompletionTracker) Reset() {
125 if t == nil {
126 return
127 }
128 t.mu.Lock()
129 t.pending = make(map[string]struct{})
130 t.ready = nil
131 t.signalLocked()
132 t.mu.Unlock()
133}
134
135func (t *CompletionTracker) WaitIdle(ctx context.Context) ([]Completion, error) {
136 return t.wait(ctx, true)
137}
138
139// WaitReady returns available completions without waiting for sibling turns.
140// It returns an empty batch when no accepted turns remain.
141func (t *CompletionTracker) WaitReady(ctx context.Context) ([]Completion, error) {
142 return t.wait(ctx, false)
143}
144
145func (t *CompletionTracker) wait(ctx context.Context, idle bool) ([]Completion, error) {
146 if t == nil {
147 return nil, nil
148 }
149 for {
150 t.mu.Lock()
151 if len(t.pending) == 0 || (!idle && len(t.ready) != 0) {
152 ready := append([]Completion(nil), t.ready...)
153 t.ready = nil
154 t.mu.Unlock()
155 return ready, nil
156 }
157 changed := t.changed
158 t.mu.Unlock()
159 select {
160 case <-ctx.Done():
161 return nil, ctx.Err()
162 case <-changed:
163 }
164 }
165}
166
167func (t *CompletionTracker) signalLocked() { close(t.changed); t.changed = make(chan struct{}) }
168
169func FormatCompletionUpdate(batch []Completion, instruction string) string {
170 return FormatCompletionUpdateWithActive(batch, nil, instruction)
171}
172
173// FormatCompletionUpdateWithActive formats terminal outcomes and identifies
174// sibling agents that are still running when this update is emitted.
175func FormatCompletionUpdateWithActive(batch []Completion, activeAgentIDs []string, instruction string) string {
176 if len(batch) == 0 {
177 return ""
178 }
179 var b strings.Builder
180 b.WriteString("[auto-subagents update]\n")
181 for _, completion := range batch {
182 fmt.Fprintf(&b, "- %s: %s", completion.AgentID, completion.Status)
183 if completion.Status == CompletionDropped {
184 // The child never read this follow-up. Echoing it as a task would
185 // read as work the child attempted.
186 fmt.Fprintf(&b, " (queued follow-up never started: %s)", undeliveredPreview(completion.Task))
187 if completion.Error != "" {
188 fmt.Fprintf(&b, " (%s)", completion.Error)
189 }
190 b.WriteByte('\n')
191 continue
192 }
193 if completion.Error != "" {
194 fmt.Fprintf(&b, " (%s)", completion.Error)
195 }
196 if completion.Task != "" {
197 fmt.Fprintf(&b, " — %s", completion.Task)
198 }
199 if len(completion.Undelivered) != 0 {
200 fmt.Fprintf(&b, "\n undelivered: %d follow-up(s) the child never read:", len(completion.Undelivered))
201 for _, text := range completion.Undelivered {
202 fmt.Fprintf(&b, "\n - %s", undeliveredPreview(text))
203 }
204 }
205 if completion.Summary != "" {
206 label := "final"
207 if completion.Status != string(ResidentCompleted) {
208 label = "partial"
209 }
210 fmt.Fprintf(&b, "\n %s: %s", label, completion.Summary)
211 }
212 b.WriteByte('\n')
213 }
214 if len(activeAgentIDs) != 0 {
215 b.WriteString("Still running:")
216 for _, agentID := range activeAgentIDs {
217 fmt.Fprintf(&b, " %s", agentID)
218 }
219 b.WriteByte('\n')
220 }
221 if instruction != "" {
222 b.WriteString(instruction)
223 }
224 return strings.TrimSpace(b.String())
225}
226
227// undeliveredPreview renders one follow-up on a single bounded line.
228func undeliveredPreview(text string) string {
229 text = strings.Join(strings.Fields(text), " ")
230 runes := []rune(text)
231 if len(runes) <= maxUndeliveredPreview {
232 return text
233 }
234 return strings.TrimSpace(string(runes[:maxUndeliveredPreview-1])) + "…"
235}