fix: add spawn timeout and WaitAll deadline to prevent infinite hangs

- Spawn goroutines now have a 30-minute hard timeout (spawnTimeout)
- WaitAll accepts a timeout parameter so heartbeat cleanup doesn't
  block forever if a subagent gets stuck
- CancelTask stores per-task cancel func for explicit cancellation

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
dj-oyu 2026-03-01 05:17:06 +09:00
parent 354497f8a8
commit 3a231766a4
2 changed files with 22 additions and 7 deletions

View file

@ -947,7 +947,7 @@ func (al *AgentLoop) runAgentLoop(ctx context.Context, agent *AgentInstance, opt
defer func() { defer func() {
if opts.Background { if opts.Background {
if agent.SubagentMgr != nil { if agent.SubagentMgr != nil {
agent.SubagentMgr.WaitAll() agent.SubagentMgr.WaitAll(35 * time.Minute) // slightly above spawnTimeout
} }
if agent.IsInWorktree(opts.SessionKey) { if agent.IsInWorktree(opts.SessionKey) {
commitMsg := "heartbeat: auto-save" commitMsg := "heartbeat: auto-save"

View file

@ -14,6 +14,10 @@ import (
"github.com/sipeed/picoclaw/pkg/providers" "github.com/sipeed/picoclaw/pkg/providers"
) )
// spawnTimeout is the hard upper bound for a single spawn goroutine.
// MaxIterations × HTTP timeout provides the soft limit; this is a safety net.
const spawnTimeout = 30 * time.Minute
type SubagentTask struct { type SubagentTask struct {
ID string ID string
Task string Task string
@ -124,11 +128,11 @@ func (sm *SubagentManager) Spawn(
sm.reporter.ReportSpawn(taskID, label, task) sm.reporter.ReportSpawn(taskID, label, task)
// Start task in background with a detached context. // Start task in background with a detached context that has a hard timeout.
// The spawned goroutine must outlive the parent (e.g. heartbeat session) // The spawned goroutine must outlive the parent (e.g. heartbeat session)
// which may finish before the subagent completes. // which may finish before the subagent completes.
// The cancel func is stored on the task so CancelTask() can stop it. // The cancel func is stored on the task so CancelTask() can stop it.
spawnCtx, spawnCancel := context.WithCancel(context.Background()) spawnCtx, spawnCancel := context.WithTimeout(context.Background(), spawnTimeout)
subagentTask.cancel = spawnCancel subagentTask.cancel = spawnCancel
sm.wg.Add(1) sm.wg.Add(1)
go func() { go func() {
@ -377,10 +381,21 @@ func (sm *SubagentManager) buildPresetRegistry(preset Preset, writeRoot string)
return registry return registry
} }
// WaitAll blocks until all spawned subagent goroutines have finished. // WaitAll blocks until all spawned subagent goroutines have finished
// Used by heartbeat cleanup to avoid destroying worktrees while subagents are still running. // or the timeout expires. Returns true if all goroutines finished,
func (sm *SubagentManager) WaitAll() { // false on timeout.
func (sm *SubagentManager) WaitAll(timeout time.Duration) bool {
done := make(chan struct{})
go func() {
sm.wg.Wait() sm.wg.Wait()
close(done)
}()
select {
case <-done:
return true
case <-time.After(timeout):
return false
}
} }
// CancelTask cancels the context for a running subagent task. // CancelTask cancels the context for a running subagent task.