feat(handoff): route crew lifecycle requests to deacon

Crew workers now use deacon for lifecycle management instead of
requiring manual session termination. When a crew worker runs
'gt handoff', it sends a lifecycle request to the deacon which
handles session kill/restart like it does for Mayor and Witness.

Changes:
- Route crew manager to deacon/ instead of "human"
- Add getCrewIdentity() to extract <rig>-crew-<name> from session
- Include full crew identity in LIFECYCLE subject for daemon parsing
- Remove special case that skipped lifecycle flow for crew

Also fixes pre-existing test failures in daemon/lifecycle_test.go
where BeadsMessage field names were out of sync with the struct.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Steve Yegge
2025-12-21 01:17:32 -08:00
parent bdbba025f5
commit dbecfe1d38
2 changed files with 54 additions and 46 deletions

View File

@@ -127,16 +127,6 @@ func runHandoff(cmd *cobra.Command, args []string) error {
// Send lifecycle request to manager
manager := getManager(role)
// Crew workers are human-managed - no automated manager to wait for
if role == RoleCrew {
fmt.Printf("\n%s Handoff complete\n", style.Bold.Render("✓"))
fmt.Println(style.Dim.Render("Crew workers are human-managed. To complete the cycle:"))
fmt.Println(style.Dim.Render(" 1. Exit this session (Ctrl+D or 'exit')"))
fmt.Println(style.Dim.Render(" 2. Run 'gt crew attach' to start fresh"))
fmt.Println(style.Dim.Render(" 3. New session will see handoff message in inbox"))
return nil
}
if err := sendLifecycleRequest(manager, role, action, townRoot); err != nil {
return fmt.Errorf("sending lifecycle request: %w", err)
}
@@ -258,7 +248,7 @@ func determineAction(role Role) HandoffAction {
case RoleMayor, RoleWitness, RoleRefinery:
return HandoffCycle // Long-running, preserve context
case RoleCrew:
return HandoffCycle // Will only send mail, not actually retire
return HandoffCycle // Persistent workspace, preserve context
default:
return HandoffCycle
}
@@ -297,7 +287,7 @@ func getManager(role Role) string {
}
return rig + "/witness"
case RoleCrew:
return "human" // Crew is human-managed
return "deacon/" // Crew lifecycle managed by deacon
default:
return "deacon/"
}
@@ -367,29 +357,47 @@ func getPolecatName() string {
return ""
}
// getCrewIdentity extracts the crew identity from the tmux session.
// Returns format: <rig>-crew-<name> (e.g., gastown-crew-max)
func getCrewIdentity() string {
out, err := exec.Command("tmux", "display-message", "-p", "#{session_name}").Output()
if err != nil {
return ""
}
sessionName := strings.TrimSpace(string(out))
// Crew sessions: gt-<rig>-crew-<name>
if strings.HasPrefix(sessionName, "gt-") && strings.Contains(sessionName, "-crew-") {
// Remove "gt-" prefix to get <rig>-crew-<name>
return strings.TrimPrefix(sessionName, "gt-")
}
return ""
}
// sendLifecycleRequest sends the lifecycle request to our manager.
func sendLifecycleRequest(manager string, role Role, action HandoffAction, townRoot string) error {
if manager == "human" {
// Crew is human-managed, just print a message
fmt.Println(style.Dim.Render("(Crew sessions are human-managed, no lifecycle request sent)"))
return nil
// Build identity for the LIFECYCLE message
// The daemon parses identity from "LIFECYCLE: <identity> requesting <action>"
identity := string(role)
switch role {
case RoleCrew:
// Crew identity: <rig>-crew-<name> (e.g., gastown-crew-max)
if crewID := getCrewIdentity(); crewID != "" {
identity = crewID
}
case RolePolecat:
// Polecat identity would need similar handling if routed to deacon
}
// For polecats, include the specific name
polecatName := ""
if role == RolePolecat {
polecatName = getPolecatName()
}
subject := fmt.Sprintf("LIFECYCLE: %s requesting %s", role, action)
subject := fmt.Sprintf("LIFECYCLE: %s requesting %s", identity, action)
body := fmt.Sprintf(`Lifecycle request from %s.
Action: %s
Time: %s
Polecat: %s
Please verify state and execute lifecycle action.
`, role, action, time.Now().Format(time.RFC3339), polecatName)
`, identity, action, time.Now().Format(time.RFC3339))
// Send via gt mail (syntax: gt mail send <recipient> -s <subject> -m <body>)
cmd := exec.Command("gt", "mail", "send", manager,