fix: move heartbeat worktree cleanup to defer for guaranteed execution

The heartbeat worktree cleanup was inline after runLLMIteration, so
errors or panics from the LLM loop would skip cleanup entirely, leaving
orphaned worktrees with uncommitted changes. Move to a defer block
registered early in runAgentLoop to guarantee execution on all exit paths.

Also document the missing human intervention mechanism for heartbeat
worktrees as a known gap in CLAUDE.md.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
dj-oyu 2026-02-24 12:35:23 +09:00
parent 53c4398487
commit 3c4cf6af6f
2 changed files with 17 additions and 14 deletions

View file

@ -26,3 +26,4 @@ Lint: `golangci-lint run`
## Known Gaps
- **Mini App log viewer has no frontend tests**: `renderLogs()` in `pkg/miniapp/static/index.html` is inline vanilla JS with no unit/E2E test coverage. Backend (Go) tests cover `RecentLogs`, `SanitizeFields`, and JSON serialization, but nothing verifies the JS rendering. This allowed the Fields display bug (fields sent but not rendered) to ship undetected.
- **No human intervention for heartbeat worktrees**: Heartbeat sessions create git worktrees (`.picoclaw/worktrees/heartbeat-YYYYMMDD/`) but there is no CLI or Mini App command to list, inspect, or manually dispose them. Need a `/plan worktrees` command (or similar) that shows active worktrees with branch/commit info and allows manual merge/dispose. `PruneOrphaned` on startup only removes directories without auto-committing first, so uncommitted changes in orphaned worktrees are silently lost.

View file

@ -708,6 +708,22 @@ func (al *AgentLoop) runAgentLoop(ctx context.Context, agent *AgentInstance, opt
interrupt: make(chan string, 1),
}
// Guarantee heartbeat worktree cleanup on ALL exit paths (error, panic, normal).
defer func() {
if opts.Background && agent.IsInWorktree(opts.SessionKey) {
commitMsg := "heartbeat: auto-save"
wtResult, _ := agent.DeactivateWorktree(opts.SessionKey, commitMsg, false)
if wtResult != nil && wtResult.CommitsAhead > 0 && !constants.IsInternalChannel(opts.Channel) {
al.bus.PublishOutbound(bus.OutboundMessage{
Channel: opts.Channel,
ChatID: opts.ChatID,
Content: fmt.Sprintf("Heartbeat made code changes on branch `%s` (%d commits).",
wtResult.Branch, wtResult.CommitsAhead),
})
}
}
}()
// For background tasks (cron/heartbeat), generate a TaskID and send notification
isBackgroundTask := opts.Background && al.state != nil
if isBackgroundTask && opts.TaskID == "" {
@ -1003,20 +1019,6 @@ func (al *AgentLoop) runAgentLoop(ctx context.Context, agent *AgentInstance, opt
"final_length": len(finalContent),
})
// 10. Heartbeat worktree cleanup: auto-commit and dispose after background task
if opts.Background && agent.IsInWorktree(opts.SessionKey) {
commitMsg := "heartbeat: auto-save"
wtResult, _ := agent.DeactivateWorktree(opts.SessionKey, commitMsg, false)
if wtResult != nil && wtResult.CommitsAhead > 0 && !constants.IsInternalChannel(opts.Channel) {
al.bus.PublishOutbound(bus.OutboundMessage{
Channel: opts.Channel,
ChatID: opts.ChatID,
Content: fmt.Sprintf("Heartbeat made code changes on branch `%s` (%d commits).",
wtResult.Branch, wtResult.CommitsAhead),
})
}
}
return finalContent, nil
}