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 38c936b417
commit 5db0a82267
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() {
if opts.Background {
if agent.SubagentMgr != nil {
agent.SubagentMgr.WaitAll()
agent.SubagentMgr.WaitAll(35 * time.Minute) // slightly above spawnTimeout
}
if agent.IsInWorktree(opts.SessionKey) {
commitMsg := "heartbeat: auto-save"

View file

@ -14,6 +14,10 @@ import (
"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 {
ID string
Task string
@ -124,11 +128,11 @@ func (sm *SubagentManager) Spawn(
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)
// which may finish before the subagent completes.
// 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
sm.wg.Add(1)
go func() {
@ -377,10 +381,21 @@ func (sm *SubagentManager) buildPresetRegistry(preset Preset, writeRoot string)
return registry
}
// WaitAll blocks until all spawned subagent goroutines have finished.
// Used by heartbeat cleanup to avoid destroying worktrees while subagents are still running.
func (sm *SubagentManager) WaitAll() {
sm.wg.Wait()
// WaitAll blocks until all spawned subagent goroutines have finished
// or the timeout expires. Returns true if all goroutines finished,
// false on timeout.
func (sm *SubagentManager) WaitAll(timeout time.Duration) bool {
done := make(chan struct{})
go func() {
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.