Files
gastown/internal/cmd/done.go
slit 01e336edb6 fix(done): use ResolveHookDir for dispatcher lookup (sc-g7bl3)
When a polecat runs gt done after work is complete, it should notify the
dispatcher (the agent that slung the work). This notification was failing
silently when the polecat's worktree was deleted before gt done finished.

The issue was that getDispatcherFromBead() used ResolveBeadsDir(cwd) which
relies on the polecat's .beads/redirect file. If the worktree is deleted
(e.g., by Witness cleanup), the redirect file is gone and bead lookup fails.

Fix: Use ResolveHookDir(townRoot, issueID, cwd) instead. ResolveHookDir uses
prefix-based routing via routes.jsonl which works regardless of worktree
state. This ensures dispatcher notifications are sent reliably even when
the worktree is cleaned up before gt done completes.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-26 12:48:09 -08:00

850 lines
32 KiB
Go

package cmd
import (
"fmt"
"os"
"path/filepath"
"strconv"
"strings"
"github.com/spf13/cobra"
"github.com/steveyegge/gastown/internal/beads"
"github.com/steveyegge/gastown/internal/events"
"github.com/steveyegge/gastown/internal/git"
"github.com/steveyegge/gastown/internal/mail"
"github.com/steveyegge/gastown/internal/polecat"
"github.com/steveyegge/gastown/internal/rig"
"github.com/steveyegge/gastown/internal/style"
"github.com/steveyegge/gastown/internal/tmux"
"github.com/steveyegge/gastown/internal/townlog"
"github.com/steveyegge/gastown/internal/workspace"
)
var doneCmd = &cobra.Command{
Use: "done",
GroupID: GroupWork,
Short: "Signal work ready for merge queue",
Long: `Signal that your work is complete and ready for the merge queue.
This is a convenience command for polecats that:
1. Submits the current branch to the merge queue
2. Auto-detects issue ID from branch name
3. Notifies the Witness with the exit outcome
4. Exits the Claude session (polecats don't stay alive after completion)
Exit statuses:
COMPLETED - Work done, MR submitted (default)
ESCALATED - Hit blocker, needs human intervention
DEFERRED - Work paused, issue still open
PHASE_COMPLETE - Phase done, awaiting gate (use --phase-complete)
Phase handoff workflow:
When a molecule has gate steps (async waits), use --phase-complete to signal
that the current phase is complete but work continues after the gate closes.
The Witness will recycle this polecat and dispatch a new one when the gate
resolves.
Examples:
gt done # Submit branch, notify COMPLETED, exit session
gt done --issue gt-abc # Explicit issue ID
gt done --status ESCALATED # Signal blocker, skip MR
gt done --status DEFERRED # Pause work, skip MR
gt done --phase-complete --gate g-x # Phase done, waiting on gate g-x`,
RunE: runDone,
}
var (
doneIssue string
donePriority int
doneStatus string
donePhaseComplete bool
doneGate string
doneCleanupStatus string
)
// Valid exit types for gt done
const (
ExitCompleted = "COMPLETED"
ExitEscalated = "ESCALATED"
ExitDeferred = "DEFERRED"
ExitPhaseComplete = "PHASE_COMPLETE"
)
func init() {
doneCmd.Flags().StringVar(&doneIssue, "issue", "", "Source issue ID (default: parse from branch name)")
doneCmd.Flags().IntVarP(&donePriority, "priority", "p", -1, "Override priority (0-4, default: inherit from issue)")
doneCmd.Flags().StringVar(&doneStatus, "status", ExitCompleted, "Exit status: COMPLETED, ESCALATED, or DEFERRED")
doneCmd.Flags().BoolVar(&donePhaseComplete, "phase-complete", false, "Signal phase complete - await gate before continuing")
doneCmd.Flags().StringVar(&doneGate, "gate", "", "Gate bead ID to wait on (with --phase-complete)")
doneCmd.Flags().StringVar(&doneCleanupStatus, "cleanup-status", "", "Git cleanup status: clean, uncommitted, unpushed, stash, unknown (ZFC: agent-observed)")
rootCmd.AddCommand(doneCmd)
}
func runDone(cmd *cobra.Command, args []string) error {
// Guard: Only polecats should call gt done
// Crew, deacons, witnesses etc. don't use gt done - they persist across tasks.
// Polecats are ephemeral workers that self-destruct after completing work.
actor := os.Getenv("BD_ACTOR")
if actor != "" && !isPolecatActor(actor) {
return fmt.Errorf("gt done is for polecats only (you are %s)\nPolecats are ephemeral workers that self-destruct after completing work.\nOther roles persist across tasks and don't use gt done.", actor)
}
// Handle --phase-complete flag (overrides --status)
var exitType string
if donePhaseComplete {
exitType = ExitPhaseComplete
if doneGate == "" {
return fmt.Errorf("--phase-complete requires --gate <gate-id>")
}
} else {
// Validate exit status
exitType = strings.ToUpper(doneStatus)
if exitType != ExitCompleted && exitType != ExitEscalated && exitType != ExitDeferred {
return fmt.Errorf("invalid exit status '%s': must be COMPLETED, ESCALATED, or DEFERRED", doneStatus)
}
}
// Find workspace with fallback for deleted worktrees (hq-3xaxy)
// If the polecat's worktree was deleted by Witness before gt done finishes,
// getcwd will fail. We fall back to GT_TOWN_ROOT env var in that case.
townRoot, cwd, err := workspace.FindFromCwdWithFallback()
if err != nil {
return fmt.Errorf("not in a Gas Town workspace: %w", err)
}
// Track if cwd is available - affects which operations we can do
cwdAvailable := cwd != ""
if !cwdAvailable {
style.PrintWarning("working directory deleted (worktree nuked?), using fallback paths")
// Try to get cwd from GT_POLECAT_PATH env var (set by session manager)
if polecatPath := os.Getenv("GT_POLECAT_PATH"); polecatPath != "" {
cwd = polecatPath // May still be gone, but we have a path to use
}
}
// Find current rig
rigName, _, err := findCurrentRig(townRoot)
if err != nil {
return err
}
// Initialize git - use cwd if available, otherwise use rig's mayor clone
var g *git.Git
if cwdAvailable {
g = git.NewGit(cwd)
} else {
// Fallback: use the rig's mayor clone for git operations
mayorClone := filepath.Join(townRoot, rigName, "mayor", "rig")
g = git.NewGit(mayorClone)
}
// Get current branch - try env var first if cwd is gone
var branch string
if !cwdAvailable {
// Try to get branch from GT_BRANCH env var (set by session manager)
branch = os.Getenv("GT_BRANCH")
}
if branch == "" {
var err error
branch, err = g.CurrentBranch()
if err != nil {
// Last resort: try to extract from polecat name (polecat/<name>-<suffix>)
if polecatName := os.Getenv("GT_POLECAT"); polecatName != "" {
branch = fmt.Sprintf("polecat/%s", polecatName)
style.PrintWarning("could not get branch from git, using fallback: %s", branch)
} else {
return fmt.Errorf("getting current branch: %w", err)
}
}
}
// Auto-detect cleanup status if not explicitly provided
// This prevents premature polecat cleanup by ensuring witness knows git state
if doneCleanupStatus == "" {
if !cwdAvailable {
// Can't detect git state without working directory, default to unknown
doneCleanupStatus = "unknown"
style.PrintWarning("cannot detect cleanup status - working directory deleted")
} else {
workStatus, err := g.CheckUncommittedWork()
if err != nil {
style.PrintWarning("could not auto-detect cleanup status: %v", err)
} else {
switch {
case workStatus.HasUncommittedChanges:
doneCleanupStatus = "uncommitted"
case workStatus.StashCount > 0:
doneCleanupStatus = "stash"
default:
// CheckUncommittedWork.UnpushedCommits doesn't work for branches
// without upstream tracking (common for polecats). Use the more
// robust BranchPushedToRemote which compares against origin/main.
pushed, unpushedCount, err := g.BranchPushedToRemote(branch, "origin")
if err != nil {
style.PrintWarning("could not check if branch is pushed: %v", err)
doneCleanupStatus = "unpushed" // err on side of caution
} else if !pushed || unpushedCount > 0 {
doneCleanupStatus = "unpushed"
} else {
doneCleanupStatus = "clean"
}
}
}
}
}
// Parse branch info
info := parseBranchName(branch)
// Override with explicit flags
issueID := doneIssue
if issueID == "" {
issueID = info.Issue
}
worker := info.Worker
// Determine polecat name from sender detection
sender := detectSender()
polecatName := ""
if parts := strings.Split(sender, "/"); len(parts) >= 2 {
polecatName = parts[len(parts)-1]
}
// Get agent bead ID for cross-referencing
var agentBeadID string
if roleInfo, err := GetRoleWithContext(cwd, townRoot); err == nil {
ctx := RoleContext{
Role: roleInfo.Role,
Rig: roleInfo.Rig,
Polecat: roleInfo.Polecat,
TownRoot: townRoot,
WorkDir: cwd,
}
agentBeadID = getAgentBeadID(ctx)
}
// If issue ID not set by flag or branch name, try agent's hook_bead.
// This handles cases where branch name doesn't contain issue ID
// (e.g., "polecat/furiosa-mkb0vq9f" doesn't have the actual issue).
if issueID == "" && agentBeadID != "" {
bd := beads.New(beads.ResolveBeadsDir(cwd))
if hookIssue := getIssueFromAgentHook(bd, agentBeadID); hookIssue != "" {
issueID = hookIssue
}
}
// Get configured default branch for this rig
defaultBranch := "main" // fallback
if rigCfg, err := rig.LoadRigConfig(filepath.Join(townRoot, rigName)); err == nil && rigCfg.DefaultBranch != "" {
defaultBranch = rigCfg.DefaultBranch
}
// For COMPLETED, we need an issue ID and branch must not be the default branch
var mrID string
if exitType == ExitCompleted {
if branch == defaultBranch || branch == "master" {
return fmt.Errorf("cannot submit %s/master branch to merge queue", defaultBranch)
}
// CRITICAL: Verify work exists before completing (hq-xthqf)
// Polecats calling gt done without commits results in lost work.
// We MUST check for:
// 1. Working directory availability (can't verify git state without it)
// 2. Uncommitted changes (work that would be lost)
// 3. Unique commits compared to origin (ensures branch was pushed with actual work)
// Block if working directory not available - can't verify git state
if !cwdAvailable {
return fmt.Errorf("cannot complete: working directory not available (worktree deleted?)\nUse --status DEFERRED to exit without completing")
}
// Block if there are uncommitted changes (would be lost on completion)
workStatus, err := g.CheckUncommittedWork()
if err != nil {
return fmt.Errorf("checking git status: %w", err)
}
if workStatus.HasUncommittedChanges {
return fmt.Errorf("cannot complete: uncommitted changes would be lost\nCommit your changes first, or use --status DEFERRED to exit without completing\nUncommitted: %s", workStatus.String())
}
// Check if branch has commits ahead of origin/default
// If not, work may have been pushed directly to main - that's fine, just skip MR
originDefault := "origin/" + defaultBranch
aheadCount, err := g.CommitsAhead(originDefault, "HEAD")
if err != nil {
// Fallback to local branch comparison if origin not available
aheadCount, err = g.CommitsAhead(defaultBranch, branch)
if err != nil {
// Can't determine - assume work exists and continue
style.PrintWarning("could not check commits ahead of %s: %v", defaultBranch, err)
aheadCount = 1
}
}
// If no commits ahead, work was likely pushed directly to main (or already merged)
// This is valid - skip MR creation but still complete successfully
if aheadCount == 0 {
fmt.Printf("%s Branch has no commits ahead of %s\n", style.Bold.Render("→"), originDefault)
fmt.Printf(" Work was likely pushed directly to main or already merged.\n")
fmt.Printf(" Skipping MR creation - completing without merge request.\n\n")
// Skip straight to witness notification (no MR needed)
goto notifyWitness
}
// CRITICAL: Push branch BEFORE creating MR bead (hq-6dk53, hq-a4ksk)
// The MR bead triggers Refinery to process this branch. If the branch
// isn't pushed yet, Refinery finds nothing to merge. The worktree gets
// nuked at the end of gt done, so the commits are lost forever.
fmt.Printf("Pushing branch to remote...\n")
if err := g.Push("origin", branch, false); err != nil {
return fmt.Errorf("pushing branch '%s' to origin: %w\nCommits exist locally but failed to push. Fix the issue and retry.", branch, err)
}
fmt.Printf("%s Branch pushed to origin\n", style.Bold.Render("✓"))
if issueID == "" {
return fmt.Errorf("cannot determine source issue from branch '%s'; use --issue to specify", branch)
}
// Initialize beads
bd := beads.New(beads.ResolveBeadsDir(cwd))
// Check for no_merge flag - if set, skip merge queue and notify for review
sourceIssueForNoMerge, err := bd.Show(issueID)
if err == nil {
attachmentFields := beads.ParseAttachmentFields(sourceIssueForNoMerge)
if attachmentFields != nil && attachmentFields.NoMerge {
fmt.Printf("%s No-merge mode: skipping merge queue\n", style.Bold.Render("→"))
fmt.Printf(" Branch: %s\n", branch)
fmt.Printf(" Issue: %s\n", issueID)
fmt.Println()
fmt.Printf("%s\n", style.Dim.Render("Work stays on feature branch for human review."))
// Mail dispatcher with READY_FOR_REVIEW
if dispatcher := attachmentFields.DispatchedBy; dispatcher != "" {
townRouter := mail.NewRouter(townRoot)
reviewMsg := &mail.Message{
To: dispatcher,
From: detectSender(),
Subject: fmt.Sprintf("READY_FOR_REVIEW: %s", issueID),
Body: fmt.Sprintf("Branch: %s\nIssue: %s\nReady for review.", branch, issueID),
}
if err := townRouter.Send(reviewMsg); err != nil {
style.PrintWarning("could not notify dispatcher: %v", err)
} else {
fmt.Printf("%s Dispatcher notified: READY_FOR_REVIEW\n", style.Bold.Render("✓"))
}
}
// Skip MR creation, go to witness notification
goto notifyWitness
}
}
// Determine target branch (auto-detect integration branch if applicable)
target := defaultBranch
autoTarget, err := detectIntegrationBranch(bd, g, issueID)
if err == nil && autoTarget != "" {
target = autoTarget
}
// Get source issue for priority inheritance
var priority int
if donePriority >= 0 {
priority = donePriority
} else {
// Try to inherit from source issue
sourceIssue, err := bd.Show(issueID)
if err != nil {
priority = 2 // Default
} else {
priority = sourceIssue.Priority
}
}
// Check if MR bead already exists for this branch (idempotency)
existingMR, err := bd.FindMRForBranch(branch)
if err != nil {
style.PrintWarning("could not check for existing MR: %v", err)
// Continue with creation attempt - Create will fail if duplicate
}
if existingMR != nil {
// MR already exists - use it instead of creating a new one
mrID = existingMR.ID
fmt.Printf("%s MR already exists (idempotent)\n", style.Bold.Render("✓"))
fmt.Printf(" MR ID: %s\n", style.Bold.Render(mrID))
} else {
// Build MR bead title and description
title := fmt.Sprintf("Merge: %s", issueID)
description := fmt.Sprintf("branch: %s\ntarget: %s\nsource_issue: %s\nrig: %s",
branch, target, issueID, rigName)
if worker != "" {
description += fmt.Sprintf("\nworker: %s", worker)
}
if agentBeadID != "" {
description += fmt.Sprintf("\nagent_bead: %s", agentBeadID)
}
// Add conflict resolution tracking fields (initialized, updated by Refinery)
description += "\nretry_count: 0"
description += "\nlast_conflict_sha: null"
description += "\nconflict_task_id: null"
// Create MR bead (ephemeral wisp - will be cleaned up after merge)
mrIssue, err := bd.Create(beads.CreateOptions{
Title: title,
Type: "merge-request",
Priority: priority,
Description: description,
Ephemeral: true,
})
if err != nil {
return fmt.Errorf("creating merge request bead: %w", err)
}
mrID = mrIssue.ID
// Update agent bead with active_mr reference (for traceability)
if agentBeadID != "" {
if err := bd.UpdateAgentActiveMR(agentBeadID, mrID); err != nil {
style.PrintWarning("could not update agent bead with active_mr: %v", err)
}
}
// Success output
fmt.Printf("%s Work submitted to merge queue\n", style.Bold.Render("✓"))
fmt.Printf(" MR ID: %s\n", style.Bold.Render(mrID))
}
fmt.Printf(" Source: %s\n", branch)
fmt.Printf(" Target: %s\n", target)
fmt.Printf(" Issue: %s\n", issueID)
if worker != "" {
fmt.Printf(" Worker: %s\n", worker)
}
fmt.Printf(" Priority: P%d\n", priority)
fmt.Println()
fmt.Printf("%s\n", style.Dim.Render("The Refinery will process your merge request."))
} else if exitType == ExitPhaseComplete {
// Phase complete - register as waiter on gate, then recycle
fmt.Printf("%s Phase complete, awaiting gate\n", style.Bold.Render("→"))
fmt.Printf(" Gate: %s\n", doneGate)
if issueID != "" {
fmt.Printf(" Issue: %s\n", issueID)
}
fmt.Printf(" Branch: %s\n", branch)
fmt.Println()
fmt.Printf("%s\n", style.Dim.Render("Witness will dispatch new polecat when gate closes."))
// Register this polecat as a waiter on the gate
bd := beads.New(beads.ResolveBeadsDir(cwd))
if err := bd.AddGateWaiter(doneGate, sender); err != nil {
style.PrintWarning("could not register as gate waiter: %v", err)
} else {
fmt.Printf("%s Registered as waiter on gate %s\n", style.Bold.Render("✓"), doneGate)
}
} else {
// For ESCALATED or DEFERRED, just print status
fmt.Printf("%s Signaling %s\n", style.Bold.Render("→"), exitType)
if issueID != "" {
fmt.Printf(" Issue: %s\n", issueID)
}
fmt.Printf(" Branch: %s\n", branch)
}
notifyWitness:
// Notify Witness about completion
// Use town-level beads for cross-agent mail
townRouter := mail.NewRouter(townRoot)
witnessAddr := fmt.Sprintf("%s/witness", rigName)
// Build notification body
var bodyLines []string
bodyLines = append(bodyLines, fmt.Sprintf("Exit: %s", exitType))
if issueID != "" {
bodyLines = append(bodyLines, fmt.Sprintf("Issue: %s", issueID))
}
if mrID != "" {
bodyLines = append(bodyLines, fmt.Sprintf("MR: %s", mrID))
}
if doneGate != "" {
bodyLines = append(bodyLines, fmt.Sprintf("Gate: %s", doneGate))
}
bodyLines = append(bodyLines, fmt.Sprintf("Branch: %s", branch))
doneNotification := &mail.Message{
To: witnessAddr,
From: sender,
Subject: fmt.Sprintf("POLECAT_DONE %s", polecatName),
Body: strings.Join(bodyLines, "\n"),
}
fmt.Printf("\nNotifying Witness...\n")
if err := townRouter.Send(doneNotification); err != nil {
style.PrintWarning("could not notify witness: %v", err)
} else {
fmt.Printf("%s Witness notified of %s\n", style.Bold.Render("✓"), exitType)
}
// Notify dispatcher if work was dispatched by another agent
if issueID != "" {
if dispatcher := getDispatcherFromBead(townRoot, cwd, issueID); dispatcher != "" && dispatcher != sender {
dispatcherNotification := &mail.Message{
To: dispatcher,
From: sender,
Subject: fmt.Sprintf("WORK_DONE: %s", issueID),
Body: strings.Join(bodyLines, "\n"),
}
if err := townRouter.Send(dispatcherNotification); err != nil {
style.PrintWarning("could not notify dispatcher %s: %v", dispatcher, err)
} else {
fmt.Printf("%s Dispatcher %s notified of %s\n", style.Bold.Render("✓"), dispatcher, exitType)
}
}
}
// Log done event (townlog and activity feed)
_ = LogDone(townRoot, sender, issueID)
_ = events.LogFeed(events.TypeDone, sender, events.DonePayload(issueID, branch))
// Update agent bead state (ZFC: self-report completion)
updateAgentStateOnDone(cwd, townRoot, exitType, issueID)
// Self-cleaning: Nuke our own sandbox and session (if we're a polecat)
// This is the self-cleaning model - polecats clean up after themselves
// "done means gone" - both worktree and session are terminated
selfCleanAttempted := false
if roleInfo, err := GetRoleWithContext(cwd, townRoot); err == nil && roleInfo.Role == RolePolecat {
selfCleanAttempted = true
// Step 1: Nuke the worktree (only for COMPLETED - other statuses preserve work)
if exitType == ExitCompleted {
if err := selfNukePolecat(roleInfo, townRoot); err != nil {
// Non-fatal: Witness will clean up if we fail
style.PrintWarning("worktree nuke failed: %v (Witness will clean up)", err)
} else {
fmt.Printf("%s Worktree nuked\n", style.Bold.Render("✓"))
}
}
// Step 2: Kill our own session (this terminates Claude and the shell)
// This is the last thing we do - the process will be killed when tmux session dies
// All exit types kill the session - "done means gone"
fmt.Printf("%s Terminating session (done means gone)\n", style.Bold.Render("→"))
if err := selfKillSession(townRoot, roleInfo); err != nil {
// If session kill fails, fall through to os.Exit
style.PrintWarning("session kill failed: %v", err)
}
// If selfKillSession succeeds, we won't reach here (process killed by tmux)
}
// Fallback exit for non-polecats or if self-clean failed
fmt.Println()
fmt.Printf("%s Session exiting\n", style.Bold.Render("→"))
if !selfCleanAttempted {
fmt.Printf(" Witness will handle cleanup.\n")
}
fmt.Printf(" Goodbye!\n")
os.Exit(0)
return nil // unreachable, but keeps compiler happy
}
// updateAgentStateOnDone clears the agent's hook and reports cleanup status.
// Per gt-zecmc: observable states ("done", "idle") removed - use tmux to discover.
// Non-observable states ("stuck", "awaiting-gate") are still set since they represent
// intentional agent decisions that can't be observed from tmux.
//
// Also self-reports cleanup_status for ZFC compliance (#10).
//
// BUG FIX (hq-3xaxy): This function must be resilient to working directory deletion.
// If the polecat's worktree is deleted before gt done finishes, we use env vars as fallback.
// All errors are warnings, not failures - gt done must complete even if bead ops fail.
func updateAgentStateOnDone(cwd, townRoot, exitType, _ string) { // issueID unused but kept for future audit logging
// Get role context - try multiple sources for resilience
roleInfo, err := GetRoleWithContext(cwd, townRoot)
if err != nil {
// Fallback: try to construct role info from environment variables
// This handles the case where cwd is deleted but env vars are set
envRole := os.Getenv("GT_ROLE")
envRig := os.Getenv("GT_RIG")
envPolecat := os.Getenv("GT_POLECAT")
if envRole == "" || envRig == "" {
// Can't determine role, skip agent state update
return
}
// Parse role string to get Role type
parsedRole, _, _ := parseRoleString(envRole)
roleInfo = RoleInfo{
Role: parsedRole,
Rig: envRig,
Polecat: envPolecat,
TownRoot: townRoot,
WorkDir: cwd,
Source: "env-fallback",
}
}
ctx := RoleContext{
Role: roleInfo.Role,
Rig: roleInfo.Rig,
Polecat: roleInfo.Polecat,
TownRoot: townRoot,
WorkDir: cwd,
}
agentBeadID := getAgentBeadID(ctx)
if agentBeadID == "" {
return
}
// Use rig path for slot commands - bd slot doesn't route from town root
// IMPORTANT: Use the rig's directory (not polecat worktree) so bd commands
// work even if the polecat worktree is deleted.
var beadsPath string
switch ctx.Role {
case RoleMayor, RoleDeacon:
beadsPath = townRoot
default:
beadsPath = filepath.Join(townRoot, ctx.Rig)
}
bd := beads.New(beadsPath)
// BUG FIX (gt-vwjz6): Close hooked beads before clearing the hook.
// Previously, the agent's hook_bead slot was cleared but the hooked bead itself
// stayed status=hooked forever. Now we close the hooked bead before clearing.
//
// BUG FIX (hq-i26n2): Check if agent bead exists before clearing hook.
// Old polecats may not have identity beads, so ClearHookBead would fail.
// gt done must be resilient - missing agent bead is not an error.
//
// BUG FIX (hq-3xaxy): All bead operations are non-fatal. If the agent bead
// is deleted by another process (e.g., Witness cleanup), we just warn.
agentBead, err := bd.Show(agentBeadID)
if err != nil {
// Agent bead doesn't exist - nothing to clear, that's fine
// This happens for polecats created before identity beads existed,
// or if the agent bead was deleted by another process
return
}
if agentBead.HookBead != "" {
hookedBeadID := agentBead.HookBead
// Only close if the hooked bead exists and is still in "hooked" status
if hookedBead, err := bd.Show(hookedBeadID); err == nil && hookedBead.Status == beads.StatusHooked {
// BUG FIX: Close attached molecule (wisp) BEFORE closing hooked bead.
// When using formula-on-bead (gt sling formula --on bead), the base bead
// has attached_molecule pointing to the wisp. Without this fix, gt done
// only closed the hooked bead, leaving the wisp orphaned.
// Order matters: wisp closes -> unblocks base bead -> base bead closes.
//
// BUG FIX (gt-zbnr): Close child wisps BEFORE closing the molecule itself.
// Deacon patrol molecules have child step wisps that were being orphaned
// when the patrol completed. Now we cascade-close all descendants first.
attachment := beads.ParseAttachmentFields(hookedBead)
if attachment != nil && attachment.AttachedMolecule != "" {
moleculeID := attachment.AttachedMolecule
// Cascade-close all child wisps before closing the molecule
childrenClosed := closeDescendants(bd, moleculeID)
if childrenClosed > 0 {
fmt.Printf(" Closed %d child step issues\n", childrenClosed)
}
if err := bd.Close(moleculeID); err != nil {
// Non-fatal: warn but continue
fmt.Fprintf(os.Stderr, "Warning: couldn't close attached molecule %s: %v\n", moleculeID, err)
}
}
if err := bd.Close(hookedBeadID); err != nil {
// Non-fatal: warn but continue
fmt.Fprintf(os.Stderr, "Warning: couldn't close hooked bead %s: %v\n", hookedBeadID, err)
}
}
}
// Clear the hook (work is done) - gt-zecmc
// BUG FIX (hq-3xaxy): This is non-fatal - if hook clearing fails, warn and continue.
// The Witness will clean up any orphaned state.
if err := bd.ClearHookBead(agentBeadID); err != nil {
// Non-fatal: warn but don't fail gt done
fmt.Fprintf(os.Stderr, "Warning: couldn't clear agent %s hook: %v\n", agentBeadID, err)
}
// Only set non-observable states - "stuck" and "awaiting-gate" are intentional
// agent decisions that can't be discovered from tmux. Skip "done" and "idle"
// since those are observable (no session = done, session + no hook = idle).
switch exitType {
case ExitEscalated:
// "stuck" = agent is requesting help - not observable from tmux
if _, err := bd.Run("agent", "state", agentBeadID, "stuck"); err != nil {
fmt.Fprintf(os.Stderr, "Warning: couldn't set agent %s to stuck: %v\n", agentBeadID, err)
}
case ExitPhaseComplete:
// "awaiting-gate" = agent is waiting for external trigger - not observable
if _, err := bd.Run("agent", "state", agentBeadID, "awaiting-gate"); err != nil {
fmt.Fprintf(os.Stderr, "Warning: couldn't set agent %s to awaiting-gate: %v\n", agentBeadID, err)
}
// ExitCompleted and ExitDeferred don't set state - observable from tmux
}
// ZFC #10: Self-report cleanup status
// Agent observes git state and passes cleanup status via --cleanup-status flag
if doneCleanupStatus != "" {
cleanupStatus := parseCleanupStatus(doneCleanupStatus)
if cleanupStatus != polecat.CleanupUnknown {
if err := bd.UpdateAgentCleanupStatus(agentBeadID, string(cleanupStatus)); err != nil {
fmt.Fprintf(os.Stderr, "Warning: couldn't update agent %s cleanup status: %v\n", agentBeadID, err)
return
}
}
}
}
// getIssueFromAgentHook retrieves the issue ID from an agent's hook_bead field.
// This is the authoritative source for what work a polecat is doing, since branch
// names may not contain the issue ID (e.g., "polecat/furiosa-mkb0vq9f").
// Returns empty string if agent doesn't exist or has no hook.
func getIssueFromAgentHook(bd *beads.Beads, agentBeadID string) string {
if agentBeadID == "" {
return ""
}
agentBead, err := bd.Show(agentBeadID)
if err != nil {
return ""
}
return agentBead.HookBead
}
// getDispatcherFromBead retrieves the dispatcher agent ID from the bead's attachment fields.
// Returns empty string if no dispatcher is recorded.
//
// BUG FIX (sc-g7bl3): Use townRoot and ResolveHookDir for bead lookup instead of
// ResolveBeadsDir(cwd). When the polecat's worktree is deleted before gt done finishes,
// ResolveBeadsDir(cwd) fails because the redirect file is gone. ResolveHookDir uses
// prefix-based routing via routes.jsonl which works regardless of worktree state.
func getDispatcherFromBead(townRoot, cwd, issueID string) string {
if issueID == "" {
return ""
}
// Use ResolveHookDir for resilient bead lookup - works even if worktree is deleted
beadsDir := beads.ResolveHookDir(townRoot, issueID, cwd)
bd := beads.New(beadsDir)
issue, err := bd.Show(issueID)
if err != nil {
return ""
}
fields := beads.ParseAttachmentFields(issue)
if fields == nil {
return ""
}
return fields.DispatchedBy
}
// parseCleanupStatus converts a string flag value to a CleanupStatus.
// ZFC: Agent observes git state and passes the appropriate status.
func parseCleanupStatus(s string) polecat.CleanupStatus {
switch strings.ToLower(s) {
case "clean":
return polecat.CleanupClean
case "uncommitted", "has_uncommitted":
return polecat.CleanupUncommitted
case "stash", "has_stash":
return polecat.CleanupStash
case "unpushed", "has_unpushed":
return polecat.CleanupUnpushed
default:
return polecat.CleanupUnknown
}
}
// selfNukePolecat deletes this polecat's worktree (self-cleaning model).
// Called by polecats when they complete work via `gt done`.
// This is safe because:
// 1. Work has been pushed to origin (MR is in queue)
// 2. We're about to exit anyway
// 3. Unix allows deleting directories while processes run in them
func selfNukePolecat(roleInfo RoleInfo, _ string) error {
if roleInfo.Role != RolePolecat || roleInfo.Polecat == "" || roleInfo.Rig == "" {
return fmt.Errorf("not a polecat: role=%s, polecat=%s, rig=%s", roleInfo.Role, roleInfo.Polecat, roleInfo.Rig)
}
// Get polecat manager using existing helper
mgr, _, err := getPolecatManager(roleInfo.Rig)
if err != nil {
return fmt.Errorf("getting polecat manager: %w", err)
}
// Use nuclear=true since we know we just pushed our work
// The branch is pushed, MR is created, we're clean
if err := mgr.RemoveWithOptions(roleInfo.Polecat, true, true); err != nil {
return fmt.Errorf("removing worktree: %w", err)
}
return nil
}
// isPolecatActor checks if a BD_ACTOR value represents a polecat.
// Polecat actors have format: rigname/polecats/polecatname
// Non-polecat actors have formats like: gastown/crew/name, rigname/witness, etc.
func isPolecatActor(actor string) bool {
parts := strings.Split(actor, "/")
return len(parts) >= 2 && parts[1] == "polecats"
}
// selfKillSession terminates the polecat's own tmux session after logging the event.
// This completes the self-cleaning model: "done means gone" - both worktree and session.
//
// The polecat determines its session from environment variables:
// - GT_RIG: the rig name
// - GT_POLECAT: the polecat name
// Session name format: gt-<rig>-<polecat>
func selfKillSession(townRoot string, roleInfo RoleInfo) error {
// Get session info from environment (set at session startup)
rigName := os.Getenv("GT_RIG")
polecatName := os.Getenv("GT_POLECAT")
// Fall back to roleInfo if env vars not set (shouldn't happen but be safe)
if rigName == "" {
rigName = roleInfo.Rig
}
if polecatName == "" {
polecatName = roleInfo.Polecat
}
if rigName == "" || polecatName == "" {
return fmt.Errorf("cannot determine session: rig=%q, polecat=%q", rigName, polecatName)
}
sessionName := fmt.Sprintf("gt-%s-%s", rigName, polecatName)
agentID := fmt.Sprintf("%s/polecats/%s", rigName, polecatName)
// Log to townlog (human-readable audit log)
if townRoot != "" {
logger := townlog.NewLogger(townRoot)
_ = logger.Log(townlog.EventKill, agentID, "self-clean: done means gone")
}
// Log to events (JSON audit log with structured payload)
_ = events.LogFeed(events.TypeSessionDeath, agentID,
events.SessionDeathPayload(sessionName, agentID, "self-clean: done means gone", "gt done"))
// Kill our own tmux session with proper process cleanup
// This will terminate Claude and all child processes, completing the self-cleaning cycle.
// We use KillSessionWithProcessesExcluding to ensure no orphaned processes are left behind,
// while excluding our own PID to avoid killing ourselves before cleanup completes.
// The tmux kill-session at the end will terminate us along with the session.
t := tmux.NewTmux()
myPID := strconv.Itoa(os.Getpid())
if err := t.KillSessionWithProcessesExcluding(sessionName, []string{myPID}); err != nil {
return fmt.Errorf("killing session %s: %w", sessionName, err)
}
return nil
}