Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions internal/cmd/hooks_sync_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -303,6 +303,16 @@ func TestRunHooksSyncNonClaudeAgent(t *testing.T) {
tmpDir := t.TempDir()
t.Setenv("HOME", tmpDir)

// Put a dummy opencode binary on PATH so agent resolution doesn't fall back to claude.
binDir := filepath.Join(tmpDir, "bin")
if err := os.MkdirAll(binDir, 0755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(binDir, "opencode"), []byte("#!/bin/sh\n"), 0755); err != nil {
t.Fatal(err)
}
t.Setenv("PATH", binDir+string(os.PathListSeparator)+os.Getenv("PATH"))

townRoot := filepath.Join(tmpDir, "town")

// Scaffold workspace: mayor, deacon, and a rig with a crew worktree
Expand Down
34 changes: 34 additions & 0 deletions internal/daemon/daemon.go
Original file line number Diff line number Diff line change
Expand Up @@ -2236,6 +2236,29 @@ func (d *Daemon) isBeadClosed(beadID string) bool {
return issues[0].Status == "closed"
}

// hasAssignedOpenWork checks if any work bead is assigned to the given polecat
// with a non-terminal status (hooked, in_progress, or open). This is the
// authoritative source of polecat work — the sling code sets status=hooked +
// assignee on the work bead, but no longer maintains the agent bead's hook_bead
// field (updateAgentHookBead is a no-op). Without this fallback, the idle reaper
// kills working polecats whose agent bead hook_bead is stale.
func (d *Daemon) hasAssignedOpenWork(rigName, assignee string) bool {
for _, status := range []string{"hooked", "in_progress", "open"} {
cmd := exec.Command(d.bdPath, "list", "--rig="+rigName, "--assignee="+assignee, "--status="+status, "--json") //nolint:gosec // G204: args are constructed internally
cmd.Dir = d.config.TownRoot
cmd.Env = os.Environ()
output, err := cmd.Output()
if err != nil {
continue
}
var issues []json.RawMessage
if json.Unmarshal(output, &issues) == nil && len(issues) > 0 {
return true
}
}
return false
}

// notifyWitnessOfCrashedPolecat notifies the witness when a polecat crash is detected.
// The stuck-agent-dog plugin handles context-aware restart decisions.
func (d *Daemon) notifyWitnessOfCrashedPolecat(rigName, polecatName, hookBead string) {
Expand Down Expand Up @@ -2345,6 +2368,17 @@ func (d *Daemon) reapIdlePolecat(rigName, polecatName string, timeout time.Durat
return
}

// Fallback: agent bead hook_bead may be stale (updateAgentHookBead is a
// no-op since the sling code declared work bead assignee as authoritative).
// Before killing, check if any work bead is assigned to this polecat with
// a non-terminal status. This prevents the reaper from killing polecats
// whose agent bead hook_bead points to a closed bead from a previous swarm
// while the polecat is actively working on a newly-slung bead.
assignee := fmt.Sprintf("%s/polecats/%s", rigName, polecatName)
if d.hasAssignedOpenWork(rigName, assignee) {
return
}

// No hooked work + stale heartbeat — but check if the agent process
// is still actively running before reaping. A failed gt sling rollback
// can clear the hook while the agent is still working (GH#3342).
Expand Down
17 changes: 17 additions & 0 deletions internal/doctor/hooks_sync_check_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,23 @@ func scaffoldWorkspace(t *testing.T, roleAgents map[string]string) string {
tmpDir := t.TempDir()
t.Setenv("HOME", tmpDir)

// Put dummy binaries for non-Claude role agents on PATH so agent
// resolution doesn't fall back to claude when the binary is missing.
if len(roleAgents) > 0 {
binDir := filepath.Join(tmpDir, "bin")
if err := os.MkdirAll(binDir, 0755); err != nil {
t.Fatal(err)
}
for _, agent := range roleAgents {
if agent != "" && agent != "claude" {
if err := os.WriteFile(filepath.Join(binDir, agent), []byte("#!/bin/sh\n"), 0755); err != nil {
t.Fatal(err)
}
}
}
t.Setenv("PATH", binDir+string(os.PathListSeparator)+os.Getenv("PATH"))
}

townRoot := filepath.Join(tmpDir, "town")

// Required workspace structure
Expand Down
Loading