The agent_state field was recording observable state like "running", "dead", "idle" which violated the "Discover, Don't Track" principle. This caused stale state bugs where agents were marked "dead" in beads but actually running in tmux. Changes: - Remove daemon's checkStaleAgents() which marked agents "dead" - Simplify ensureXxxRunning() to use tmux.IsClaudeRunning() directly - Remove reportAgentState() calls from gt prime and gt handoff - Add SetHookBead/ClearHookBead helpers that don't update agent_state - Use ClearHookBead in gt done and gt unsling - Simplify gt status to derive state from tmux, not bead Non-observable states (stuck, awaiting-gate, muted, paused) are still set because they represent intentional agent decisions that can't be discovered from tmux state. Fixes: gt-zecmc 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
485 lines
16 KiB
Go
485 lines
16 KiB
Go
package cmd
|
|
|
|
import (
|
|
"fmt"
|
|
"os"
|
|
"path/filepath"
|
|
"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/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. Optionally exits the Claude session (--exit flag)
|
|
|
|
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
|
|
gt done --exit # Submit and exit Claude 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
|
|
doneExit bool
|
|
donePhaseComplete bool
|
|
doneGate 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(&doneExit, "exit", false, "Exit Claude session after MR submission (self-terminate)")
|
|
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)")
|
|
|
|
rootCmd.AddCommand(doneCmd)
|
|
}
|
|
|
|
func runDone(cmd *cobra.Command, args []string) error {
|
|
// 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
|
|
townRoot, err := workspace.FindFromCwdOrError()
|
|
if err != nil {
|
|
return fmt.Errorf("not in a Gas Town workspace: %w", err)
|
|
}
|
|
|
|
// Find current rig
|
|
rigName, _, err := findCurrentRig(townRoot)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
// Initialize git for the current directory
|
|
cwd, err := os.Getwd()
|
|
if err != nil {
|
|
return fmt.Errorf("getting current directory: %w", err)
|
|
}
|
|
g := git.NewGit(cwd)
|
|
|
|
// Get current branch
|
|
branch, err := g.CurrentBranch()
|
|
if err != nil {
|
|
return fmt.Errorf("getting current branch: %w", err)
|
|
}
|
|
|
|
// 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)
|
|
}
|
|
|
|
// 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)
|
|
}
|
|
// Check that branch has commits ahead of default branch (prevents submitting stale branches)
|
|
aheadCount, err := g.CommitsAhead(defaultBranch, branch)
|
|
if err != nil {
|
|
return fmt.Errorf("checking commits ahead of %s: %w", defaultBranch, err)
|
|
}
|
|
if aheadCount == 0 {
|
|
return fmt.Errorf("branch '%s' has 0 commits ahead of %s; nothing to merge", branch, defaultBranch)
|
|
}
|
|
|
|
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,
|
|
})
|
|
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)
|
|
}
|
|
|
|
// 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)
|
|
|
|
// Handle session self-termination if requested
|
|
if doneExit {
|
|
fmt.Println()
|
|
fmt.Printf("%s Session self-terminating (--exit flag)\n", style.Bold.Render("→"))
|
|
fmt.Printf(" Witness will handle worktree cleanup.\n")
|
|
fmt.Printf(" Goodbye!\n")
|
|
os.Exit(0)
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// 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).
|
|
func updateAgentStateOnDone(cwd, townRoot, exitType, _ string) { // issueID unused but kept for future audit logging
|
|
// Get role context
|
|
roleInfo, err := GetRoleWithContext(cwd, townRoot)
|
|
if err != nil {
|
|
return
|
|
}
|
|
|
|
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
|
|
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.
|
|
if agentBead, err := bd.Show(agentBeadID); err == nil && 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 {
|
|
if err := bd.Close(hookedBeadID); err != nil {
|
|
fmt.Fprintf(os.Stderr, "Warning: couldn't close hooked bead %s: %v\n", hookedBeadID, err)
|
|
}
|
|
}
|
|
}
|
|
|
|
// Clear the hook (work is done) - gt-zecmc
|
|
if err := bd.ClearHookBead(agentBeadID); err != nil {
|
|
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
|
|
// Compute git state and report so Witness can decide removal safety
|
|
cleanupStatus := computeCleanupStatus(cwd)
|
|
if cleanupStatus != polecat.CleanupUnknown {
|
|
if err := bd.UpdateAgentCleanupStatus(agentBeadID, string(cleanupStatus)); err != nil {
|
|
// Log warning instead of silent ignore
|
|
fmt.Fprintf(os.Stderr, "Warning: couldn't update agent %s cleanup status: %v\n", agentBeadID, err)
|
|
return
|
|
}
|
|
}
|
|
}
|
|
|
|
// 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
|
|
}
|
|
|
|
// computeCleanupStatus checks git state and returns the cleanup status.
|
|
// Returns the most critical issue: has_unpushed > has_stash > has_uncommitted > clean
|
|
func computeCleanupStatus(cwd string) polecat.CleanupStatus {
|
|
g := git.NewGit(cwd)
|
|
status, err := g.CheckUncommittedWork()
|
|
if err != nil {
|
|
// If we can't check, report unknown - Witness should be cautious
|
|
return polecat.CleanupUnknown
|
|
}
|
|
|
|
// Check in priority order (most critical first)
|
|
if status.UnpushedCommits > 0 {
|
|
return polecat.CleanupUnpushed
|
|
}
|
|
if status.StashCount > 0 {
|
|
return polecat.CleanupStash
|
|
}
|
|
if status.HasUncommittedChanges {
|
|
return polecat.CleanupUncommitted
|
|
}
|
|
return polecat.CleanupClean
|
|
}
|