fix(sling): auto-apply mol-polecat-work (#288) and fix wisp orphan lifecycle bug (#842) Fixes the formula-on-bead pattern to hook the base bead instead of the wisp: - Auto-apply mol-polecat-work when slinging bare beads to polecats - Hook BASE bead with attached_molecule pointing to wisp - gt done now closes attached molecule before closing hooked bead - Convoys complete properly when work finishes Fixes #288, #842, #858
801 lines
29 KiB
Go
801 lines
29 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))
|
|
|
|
// 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(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.
|
|
attachment := beads.ParseAttachmentFields(hookedBead)
|
|
if attachment != nil && attachment.AttachedMolecule != "" {
|
|
if err := bd.Close(attachment.AttachedMolecule); err != nil {
|
|
// Non-fatal: warn but continue
|
|
fmt.Fprintf(os.Stderr, "Warning: couldn't close attached molecule %s: %v\n", attachment.AttachedMolecule, 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.
|
|
func getDispatcherFromBead(cwd, issueID string) string {
|
|
if issueID == "" {
|
|
return ""
|
|
}
|
|
|
|
bd := beads.New(beads.ResolveBeadsDir(cwd))
|
|
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
|
|
}
|