v0.48.1 deaab59

completion.go RawHistoryFolder

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}

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 1

Tags 476

↑↓ moveenter openesc close