diff --git a/.beads/formulas/mol-witness-patrol.formula.toml b/.beads/formulas/mol-witness-patrol.formula.toml index d6fa3fb6..e9a122c2 100644 --- a/.beads/formulas/mol-witness-patrol.formula.toml +++ b/.beads/formulas/mol-witness-patrol.formula.toml @@ -187,8 +187,10 @@ Each polecat agent bead has fields in its description: |-------------|---------|--------| | running | Actively working | Check progress (Step 3) | | idle | No work assigned | Skip (no action needed) | -| stuck | Self-reported stuck | Handle stuck protocol | -| done | Work complete | Verify cleanup triggered (see Step 4a) | +| stuck | Self-reported stuck (via gt done --exit ESCALATED) | Handle stuck protocol | +| done | Work complete (via gt done) | Verify cleanup triggered (see Step 4a) | +| dead | Marked dead by daemon (unresponsive) | Clean up and respawn | +| spawning | Recently created, initializing | Wait for running state | **Step 3: For running polecats, assess progress** @@ -216,6 +218,8 @@ Look for: | agent_state=running, idle 15+ min | Direct nudge with deadline | | agent_state=stuck | Assess and help or escalate | | agent_state=done | Verify cleanup triggered (see Step 4a) | +| agent_state=dead | Create cleanup wisp, mark for respawn if needed | +| agent_state=spawning | Check if stuck in spawn; if >2 min, investigate | **Step 4a: Handle agent_state=done** diff --git a/internal/cmd/done.go b/internal/cmd/done.go index 7482e109..d548733a 100644 --- a/internal/cmd/done.go +++ b/internal/cmd/done.go @@ -223,5 +223,55 @@ func runDone(cmd *cobra.Command, args []string) error { // Log done event LogDone(townRoot, sender, issueID) + // Update agent bead state (ZFC: self-report completion) + updateAgentStateOnDone(cwd, townRoot, exitType, issueID) + return nil } + +// updateAgentStateOnDone updates the agent bead state when work is complete. +// Maps exit type to agent state: +// - COMPLETED → "done" +// - ESCALATED → "stuck" +// - DEFERRED → "idle" +func updateAgentStateOnDone(cwd, townRoot, exitType, issueID string) { + // 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 + } + + // Map exit type to agent state + var newState string + switch exitType { + case ExitCompleted: + newState = "done" + case ExitEscalated: + newState = "stuck" + case ExitDeferred: + newState = "idle" + default: + return + } + + // Update agent bead with new state and clear hook_bead (work is done) + bd := beads.New(cwd) + emptyHook := "" + if err := bd.UpdateAgentState(agentBeadID, newState, &emptyHook); err != nil { + // Silently ignore - beads might not be configured + return + } +} diff --git a/internal/cmd/prime.go b/internal/cmd/prime.go index 050af2ad..963247a6 100644 --- a/internal/cmd/prime.go +++ b/internal/cmd/prime.go @@ -1055,6 +1055,7 @@ func acquireIdentityLock(ctx RoleContext) error { // reportAgentState updates the agent bead to report the agent's current state. // This implements ZFC-compliant self-reporting of agent state. // Agents call this on startup (running) and shutdown (stopped). +// For crew workers, creates the agent bead if it doesn't exist. func reportAgentState(ctx RoleContext, state string) { agentBeadID := getAgentBeadID(ctx) if agentBeadID == "" { @@ -1064,13 +1065,77 @@ func reportAgentState(ctx RoleContext, state string) { // Use the beads API directly to update agent state // This is more reliable than shelling out to bd bd := beads.New(ctx.WorkDir) + + // Check if agent bead exists, create if needed (especially for crew workers) + if _, err := bd.Show(agentBeadID); err != nil { + // Agent bead doesn't exist - create it + fields := getAgentFields(ctx, state) + if fields != nil { + _, createErr := bd.CreateAgentBead(agentBeadID, agentBeadID, fields) + if createErr != nil { + // Silently ignore - beads might not be configured + return + } + // Bead created with initial state, no need to update + return + } + } + + // Update existing agent bead state if err := bd.UpdateAgentState(agentBeadID, state, nil); err != nil { // Silently ignore errors - don't fail prime if state reporting fails - // This can fail if beads isn't set up or agent bead doesn't exist return } } +// getAgentFields returns the AgentFields for creating a new agent bead. +func getAgentFields(ctx RoleContext, state string) *beads.AgentFields { + switch ctx.Role { + case RoleCrew: + return &beads.AgentFields{ + RoleType: "crew", + Rig: ctx.Rig, + AgentState: state, + RoleBead: fmt.Sprintf("gt-crew-%s-%s-role", ctx.Rig, ctx.Polecat), + } + case RolePolecat: + return &beads.AgentFields{ + RoleType: "polecat", + Rig: ctx.Rig, + AgentState: state, + RoleBead: fmt.Sprintf("gt-polecat-%s-%s-role", ctx.Rig, ctx.Polecat), + } + case RoleMayor: + return &beads.AgentFields{ + RoleType: "mayor", + AgentState: state, + RoleBead: "gt-mayor-role", + } + case RoleDeacon: + return &beads.AgentFields{ + RoleType: "deacon", + AgentState: state, + RoleBead: "gt-deacon-role", + } + case RoleWitness: + return &beads.AgentFields{ + RoleType: "witness", + Rig: ctx.Rig, + AgentState: state, + RoleBead: fmt.Sprintf("gt-witness-%s-role", ctx.Rig), + } + case RoleRefinery: + return &beads.AgentFields{ + RoleType: "refinery", + Rig: ctx.Rig, + AgentState: state, + RoleBead: fmt.Sprintf("gt-refinery-%s-role", ctx.Rig), + } + default: + return nil + } +} + // getAgentBeadID returns the agent bead ID for the current role. // Returns empty string for unknown roles. func getAgentBeadID(ctx RoleContext) string { diff --git a/internal/cmd/sling.go b/internal/cmd/sling.go index 6c66deef..a450904b 100644 --- a/internal/cmd/sling.go +++ b/internal/cmd/sling.go @@ -239,6 +239,9 @@ func runSling(cmd *cobra.Command, args []string) error { fmt.Printf("%s Work attached to hook (pinned bead)\n", style.Bold.Render("✓")) + // Update agent bead's hook_bead field (ZFC: agents track their current work) + updateAgentHookBead(targetAgent, beadID) + // Store args in bead description (no-tmux mode: beads as data plane) if slingArgs != "" { if err := storeArgsInBead(beadID, slingArgs); err != nil { @@ -600,6 +603,9 @@ func runSlingFormula(args []string) error { } fmt.Printf("%s Attached to hook (pinned bead)\n", style.Bold.Render("✓")) + // Update agent bead's hook_bead field (ZFC: agents track their current work) + updateAgentHookBead(targetAgent, wispResult.RootID) + // Store args in wisp bead if provided (no-tmux mode: beads as data plane) if slingArgs != "" { if err := storeArgsInBead(wispResult.RootID, slingArgs); err != nil { @@ -632,3 +638,62 @@ func runSlingFormula(args []string) error { return nil } + +// updateAgentHookBead updates the agent bead's hook_bead field when work is slung. +// This enables the witness to see what each agent is working on. +func updateAgentHookBead(agentID, beadID string) { + // Convert agent ID to agent bead ID + // Format examples: + // gastown/crew/max -> gt-crew-gastown-max + // gastown/polecats/Toast -> gt-polecat-gastown-Toast + // mayor -> gt-mayor + // gastown/witness -> gt-witness-gastown + agentBeadID := agentIDToBeadID(agentID) + if agentBeadID == "" { + return + } + + // Find beads directory - try current directory first + workDir, err := os.Getwd() + if err != nil { + return + } + + bd := beads.New(workDir) + if err := bd.UpdateAgentState(agentBeadID, "running", &beadID); err != nil { + // Silently ignore - agent bead might not exist yet + return + } +} + +// agentIDToBeadID converts an agent ID to its corresponding agent bead ID. +func agentIDToBeadID(agentID string) string { + // Handle simple cases + if agentID == "mayor" { + return "gt-mayor" + } + if agentID == "deacon" { + return "gt-deacon" + } + + // Parse path-style agent IDs + parts := strings.Split(agentID, "/") + if len(parts) < 2 { + return "" + } + + rig := parts[0] + + switch { + case len(parts) == 2 && parts[1] == "witness": + return fmt.Sprintf("gt-witness-%s", rig) + case len(parts) == 2 && parts[1] == "refinery": + return fmt.Sprintf("gt-refinery-%s", rig) + case len(parts) == 3 && parts[1] == "crew": + return fmt.Sprintf("gt-crew-%s-%s", rig, parts[2]) + case len(parts) == 3 && parts[1] == "polecats": + return fmt.Sprintf("gt-polecat-%s-%s", rig, parts[2]) + default: + return "" + } +}