Skip to content

Commit 1de9ad5

Browse files
authored
Merge pull request #2392 from dgageot/board/fix-docker-agent-issue-2357-bcd7a443
fix: decouple background agent context from parent message lifecycle
2 parents 46d864a + 814687d commit 1de9ad5

File tree

2 files changed

+111
-97
lines changed

2 files changed

+111
-97
lines changed

pkg/tools/builtin/agent/agent.go

Lines changed: 110 additions & 96 deletions
Original file line numberDiff line numberDiff line change
@@ -84,18 +84,20 @@ const (
8484
taskFailed
8585
)
8686

87-
var taskStatusStrings = map[taskStatus]string{
88-
taskRunning: "running",
89-
taskCompleted: "completed",
90-
taskStopped: "stopped",
91-
taskFailed: "failed",
92-
}
93-
94-
func statusToString(s taskStatus) string {
95-
if str, ok := taskStatusStrings[s]; ok {
96-
return str
87+
// String returns a human-readable name for the status.
88+
func (s taskStatus) String() string {
89+
switch s {
90+
case taskRunning:
91+
return "running"
92+
case taskCompleted:
93+
return "completed"
94+
case taskStopped:
95+
return "stopped"
96+
case taskFailed:
97+
return "failed"
98+
default:
99+
return "unknown"
97100
}
98-
return "unknown"
99101
}
100102

101103
// task tracks a single background sub-agent execution.
@@ -104,18 +106,16 @@ type task struct {
104106
agentName string
105107
taskDesc string
106108

107-
cancel context.CancelFunc
108-
outputMu sync.RWMutex
109-
output strings.Builder
110-
outputBytes int
111-
startTime time.Time
112-
status atomic.Int32
113-
result string
114-
errMsg string
115-
116-
// viewCount tracks how many consecutive HandleView calls observed no
117-
// new output. It is reset whenever the buffered output grows.
118-
// Protected by outputMu.
109+
cancel context.CancelFunc
110+
startTime time.Time
111+
status atomic.Int32
112+
result string
113+
errMsg string
114+
115+
// outputMu protects output, outputBytes, viewCount, and lastViewedOutputBytes.
116+
outputMu sync.RWMutex
117+
output strings.Builder
118+
outputBytes int
119119
viewCount int
120120
lastViewedOutputBytes int
121121
}
@@ -132,6 +132,79 @@ func (t *task) casStatus(old, next taskStatus) bool {
132132
return t.status.CompareAndSwap(int32(old), int32(next))
133133
}
134134

135+
// writeOutput appends content to the task's live output buffer, respecting the
136+
// maxOutputBytes cap. It is safe for concurrent use.
137+
func (t *task) writeOutput(content string) {
138+
t.outputMu.Lock()
139+
defer t.outputMu.Unlock()
140+
141+
if t.outputBytes < maxOutputBytes {
142+
n, _ := t.output.WriteString(content)
143+
t.outputBytes += n
144+
}
145+
}
146+
147+
// formatView builds the human-readable output section for HandleView.
148+
// It covers all terminal and in-progress states. The caller supplies the
149+
// pre-loaded status and elapsed duration.
150+
func (t *task) formatView(status taskStatus, elapsed time.Duration) string {
151+
var out strings.Builder
152+
fmt.Fprintf(&out, "Task ID: %s\n", t.id)
153+
fmt.Fprintf(&out, "Agent: %s\n", t.agentName)
154+
fmt.Fprintf(&out, "Status: %s\n", status)
155+
fmt.Fprintf(&out, "Runtime: %s\n", elapsed)
156+
out.WriteString("\n--- Output ---\n")
157+
158+
switch status {
159+
case taskCompleted:
160+
if t.result != "" {
161+
out.WriteString(t.result)
162+
} else {
163+
out.WriteString("<no output>")
164+
}
165+
166+
case taskFailed:
167+
out.WriteString("<task failed>")
168+
if t.errMsg != "" {
169+
fmt.Fprintf(&out, "\nError: %s", t.errMsg)
170+
}
171+
172+
case taskStopped:
173+
out.WriteString("<task was stopped>")
174+
175+
default: // taskRunning (or any unexpected value)
176+
t.outputMu.Lock()
177+
progress := t.output.String()
178+
truncated := t.outputBytes >= maxOutputBytes
179+
currentBytes := t.outputBytes
180+
181+
if currentBytes == t.lastViewedOutputBytes {
182+
t.viewCount++
183+
} else {
184+
t.viewCount = 1
185+
t.lastViewedOutputBytes = currentBytes
186+
}
187+
viewCount := t.viewCount
188+
t.outputMu.Unlock()
189+
190+
if progress != "" {
191+
out.WriteString(progress)
192+
if truncated {
193+
out.WriteString("\n\n[output truncated at 10MB limit — still running...]")
194+
} else {
195+
out.WriteString("\n\n[still running...]")
196+
}
197+
} else {
198+
out.WriteString("<no output yet — still running>")
199+
}
200+
if viewCount > 1 {
201+
fmt.Fprintf(&out, "\n\n[No new output since last check — poll #%d]", viewCount)
202+
}
203+
}
204+
205+
return out.String()
206+
}
207+
135208
// Handler owns all background agent tasks and provides tool handlers.
136209
type Handler struct {
137210
runner Runner
@@ -169,8 +242,7 @@ func (h *Handler) totalTaskCount() int {
169242
func (h *Handler) pruneCompleted() {
170243
var toDelete []string
171244
h.tasks.Range(func(id string, t *task) bool {
172-
s := t.loadStatus()
173-
if s == taskCompleted || s == taskStopped || s == taskFailed {
245+
if s := t.loadStatus(); s != taskRunning {
174246
toDelete = append(toDelete, id)
175247
}
176248
return true
@@ -195,8 +267,7 @@ func (h *Handler) HandleRun(ctx context.Context, sess *session.Session, toolCall
195267
}
196268

197269
subAgentNames := h.runner.CurrentAgentSubAgentNames()
198-
valid := slices.Contains(subAgentNames, params.Agent)
199-
if !valid {
270+
if !slices.Contains(subAgentNames, params.Agent) {
200271
if len(subAgentNames) > 0 {
201272
return tools.ResultError(fmt.Sprintf("agent %q is not in the sub-agents list. Available: %s", params.Agent, strings.Join(subAgentNames, ", "))), nil
202273
}
@@ -218,7 +289,11 @@ func (h *Handler) HandleRun(ctx context.Context, sess *session.Session, toolCall
218289

219290
taskID := newTaskID()
220291

221-
taskCtx, cancel := context.WithCancel(ctx)
292+
// Use WithoutCancel so the background task is not killed when the
293+
// parent message context is cancelled (e.g. the user sends a new
294+
// message in the TUI). The task can still be explicitly stopped
295+
// via HandleStop which calls cancel().
296+
taskCtx, cancel := context.WithCancel(context.WithoutCancel(ctx))
222297

223298
t := &task{
224299
id: taskID,
@@ -240,14 +315,7 @@ func (h *Handler) HandleRun(ctx context.Context, sess *session.Session, toolCall
240315
Task: params.Task,
241316
ExpectedOutput: params.ExpectedOutput,
242317
ParentSession: sess,
243-
OnContent: func(content string) {
244-
t.outputMu.Lock()
245-
if t.outputBytes < maxOutputBytes {
246-
n, _ := t.output.WriteString(content)
247-
t.outputBytes += n
248-
}
249-
t.outputMu.Unlock()
250-
},
318+
OnContent: t.writeOutput,
251319
})
252320

253321
if result.ErrMsg != "" {
@@ -283,11 +351,10 @@ func (h *Handler) HandleList(_ context.Context, _ *session.Session, _ tools.Tool
283351
var count int
284352
h.tasks.Range(func(_ string, t *task) bool {
285353
count++
286-
status := t.loadStatus()
287354
elapsed := time.Since(t.startTime).Round(time.Second)
288355
fmt.Fprintf(&out, "ID: %s\n", t.id)
289356
fmt.Fprintf(&out, " Agent: %s\n", t.agentName)
290-
fmt.Fprintf(&out, " Status: %s\n", statusToString(status))
357+
fmt.Fprintf(&out, " Status: %s\n", t.loadStatus())
291358
fmt.Fprintf(&out, " Runtime: %s\n", elapsed)
292359
out.WriteString("\n")
293360
return true
@@ -315,59 +382,7 @@ func (h *Handler) HandleView(_ context.Context, _ *session.Session, toolCall too
315382
status := t.loadStatus()
316383
elapsed := time.Since(t.startTime).Round(time.Second)
317384

318-
var out strings.Builder
319-
fmt.Fprintf(&out, "Task ID: %s\n", t.id)
320-
fmt.Fprintf(&out, "Agent: %s\n", t.agentName)
321-
fmt.Fprintf(&out, "Status: %s\n", statusToString(status))
322-
fmt.Fprintf(&out, "Runtime: %s\n", elapsed)
323-
out.WriteString("\n--- Output ---\n")
324-
325-
switch status {
326-
case taskCompleted:
327-
if t.result != "" {
328-
out.WriteString(t.result)
329-
} else {
330-
out.WriteString("<no output>")
331-
}
332-
case taskFailed:
333-
out.WriteString("<task failed>")
334-
if t.errMsg != "" {
335-
fmt.Fprintf(&out, "\nError: %s", t.errMsg)
336-
}
337-
case taskStopped:
338-
out.WriteString("<task was stopped>")
339-
default:
340-
t.outputMu.Lock()
341-
progress := t.output.String()
342-
truncated := t.outputBytes >= maxOutputBytes
343-
currentBytes := t.outputBytes
344-
345-
// Track whether output has changed since the last view.
346-
if currentBytes == t.lastViewedOutputBytes {
347-
t.viewCount++
348-
} else {
349-
t.viewCount = 1
350-
t.lastViewedOutputBytes = currentBytes
351-
}
352-
viewCount := t.viewCount
353-
t.outputMu.Unlock()
354-
355-
if progress != "" {
356-
out.WriteString(progress)
357-
if truncated {
358-
out.WriteString("\n\n[output truncated at 10MB limit — still running...]")
359-
} else {
360-
out.WriteString("\n\n[still running...]")
361-
}
362-
} else {
363-
out.WriteString("<no output yet — still running>")
364-
}
365-
if viewCount > 1 {
366-
fmt.Fprintf(&out, "\n\n[No new output since last check — poll #%d]", viewCount)
367-
}
368-
}
369-
370-
return tools.ResultSuccess(out.String()), nil
385+
return tools.ResultSuccess(t.formatView(status, elapsed)), nil
371386
}
372387

373388
// HandleStop cancels a running background agent task.
@@ -383,8 +398,7 @@ func (h *Handler) HandleStop(_ context.Context, _ *session.Session, toolCall too
383398
}
384399

385400
if !t.casStatus(taskRunning, taskStopped) {
386-
current := t.loadStatus()
387-
return tools.ResultError(fmt.Sprintf("task %s is not running (status: %s)", params.TaskID, statusToString(current))), nil
401+
return tools.ResultError(fmt.Sprintf("task %s is not running (status: %s)", params.TaskID, t.loadStatus())), nil
388402
}
389403

390404
t.cancel()
@@ -424,7 +438,7 @@ func NewToolSet() tools.ToolSet {
424438
type toolSet struct{}
425439

426440
func (t *toolSet) Tools(context.Context) ([]tools.Tool, error) {
427-
return backgroundAgentTools()
441+
return backgroundAgentTools(), nil
428442
}
429443

430444
func (t *toolSet) Instructions() string {
@@ -440,7 +454,7 @@ Use background agent tasks to dispatch work to sub-agents concurrently.
440454
**Notes**: Output capped at 10MB per task. All tasks auto-terminate when the agent stops.`
441455
}
442456

443-
func backgroundAgentTools() ([]tools.Tool, error) {
457+
func backgroundAgentTools() []tools.Tool {
444458
return []tools.Tool{
445459
{
446460
Name: ToolNameRunBackgroundAgent,
@@ -480,5 +494,5 @@ view_background_agent and collect results once the task is complete.`,
480494
Title: "Stop Background Agent",
481495
},
482496
},
483-
}, nil
497+
}
484498
}

pkg/tools/builtin/agent/agent_test.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -106,7 +106,7 @@ func TestStatusToString(t *testing.T) {
106106
{99, "unknown"},
107107
}
108108
for _, tc := range cases {
109-
assert.Equal(t, tc.expected, statusToString(tc.status))
109+
assert.Equal(t, tc.expected, tc.status.String())
110110
}
111111
}
112112

0 commit comments

Comments
 (0)