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 // Send lifecycle request to manager
manager := getManager(role) 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 { if err := sendLifecycleRequest(manager, role, action, townRoot); err != nil {
return fmt.Errorf("sending lifecycle request: %w", err) return fmt.Errorf("sending lifecycle request: %w", err)
} }
@@ -258,7 +248,7 @@ func determineAction(role Role) HandoffAction {
case RoleMayor, RoleWitness, RoleRefinery: case RoleMayor, RoleWitness, RoleRefinery:
return HandoffCycle // Long-running, preserve context return HandoffCycle // Long-running, preserve context
case RoleCrew: case RoleCrew:
return HandoffCycle // Will only send mail, not actually retire return HandoffCycle // Persistent workspace, preserve context
default: default:
return HandoffCycle return HandoffCycle
} }
@@ -297,7 +287,7 @@ func getManager(role Role) string {
} }
return rig + "/witness" return rig + "/witness"
case RoleCrew: case RoleCrew:
return "human" // Crew is human-managed return "deacon/" // Crew lifecycle managed by deacon
default: default:
return "deacon/" return "deacon/"
} }
@@ -367,29 +357,47 @@ func getPolecatName() string {
return "" 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. // sendLifecycleRequest sends the lifecycle request to our manager.
func sendLifecycleRequest(manager string, role Role, action HandoffAction, townRoot string) error { func sendLifecycleRequest(manager string, role Role, action HandoffAction, townRoot string) error {
if manager == "human" { // Build identity for the LIFECYCLE message
// Crew is human-managed, just print a message // The daemon parses identity from "LIFECYCLE: <identity> requesting <action>"
fmt.Println(style.Dim.Render("(Crew sessions are human-managed, no lifecycle request sent)")) identity := string(role)
return nil
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 subject := fmt.Sprintf("LIFECYCLE: %s requesting %s", identity, action)
polecatName := ""
if role == RolePolecat {
polecatName = getPolecatName()
}
subject := fmt.Sprintf("LIFECYCLE: %s requesting %s", role, action)
body := fmt.Sprintf(`Lifecycle request from %s. body := fmt.Sprintf(`Lifecycle request from %s.
Action: %s Action: %s
Time: %s Time: %s
Polecat: %s
Please verify state and execute lifecycle action. 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>) // Send via gt mail (syntax: gt mail send <recipient> -s <subject> -m <body>)
cmd := exec.Command("gt", "mail", "send", manager, cmd := exec.Command("gt", "mail", "send", manager,

View File

@@ -30,8 +30,8 @@ func TestParseLifecycleRequest_Cycle(t *testing.T) {
for _, tc := range tests { for _, tc := range tests {
msg := &BeadsMessage{ msg := &BeadsMessage{
Title: tc.title, Subject: tc.title,
Sender: "test-sender", From: "test-sender",
} }
result := d.parseLifecycleRequest(msg) result := d.parseLifecycleRequest(msg)
if result == nil { if result == nil {
@@ -64,8 +64,8 @@ func TestParseLifecycleRequest_PrefixMatchesCycle(t *testing.T) {
for _, tc := range tests { for _, tc := range tests {
msg := &BeadsMessage{ msg := &BeadsMessage{
Title: tc.title, Subject: tc.title,
Sender: "test-sender", From: "test-sender",
} }
result := d.parseLifecycleRequest(msg) result := d.parseLifecycleRequest(msg)
if result == nil { if result == nil {
@@ -91,8 +91,8 @@ func TestParseLifecycleRequest_NotLifecycle(t *testing.T) {
for _, title := range tests { for _, title := range tests {
msg := &BeadsMessage{ msg := &BeadsMessage{
Title: title, Subject: title,
Sender: "test-sender", From: "test-sender",
} }
result := d.parseLifecycleRequest(msg) result := d.parseLifecycleRequest(msg)
if result != nil { if result != nil {
@@ -116,8 +116,8 @@ func TestParseLifecycleRequest_ExtractsFrom(t *testing.T) {
for _, tc := range tests { for _, tc := range tests {
msg := &BeadsMessage{ msg := &BeadsMessage{
Title: tc.title, Subject: tc.title,
Sender: tc.sender, From: tc.sender,
} }
result := d.parseLifecycleRequest(msg) result := d.parseLifecycleRequest(msg)
if result == nil { if result == nil {
@@ -135,8 +135,8 @@ func TestParseLifecycleRequest_FallsBackToSender(t *testing.T) {
// When the title doesn't contain a parseable "from", use sender // When the title doesn't contain a parseable "from", use sender
msg := &BeadsMessage{ msg := &BeadsMessage{
Title: "LIFECYCLE: requesting cycle", // no role before "requesting" Subject: "LIFECYCLE: requesting cycle", // no role before "requesting"
Sender: "fallback-sender", From: "fallback-sender",
} }
result := d.parseLifecycleRequest(msg) result := d.parseLifecycleRequest(msg)
if result == nil { if result == nil {
@@ -200,23 +200,23 @@ func TestIdentityToSession_Unknown(t *testing.T) {
func TestBeadsMessage_Serialization(t *testing.T) { func TestBeadsMessage_Serialization(t *testing.T) {
msg := BeadsMessage{ msg := BeadsMessage{
ID: "msg-123", ID: "msg-123",
Title: "Test Message", Subject: "Test Message",
Description: "A test message body", Body: "A test message body",
Sender: "test-sender", From: "test-sender",
Assignee: "test-assignee", To: "test-recipient",
Priority: 1, Priority: "high",
Status: "open", Type: "message",
} }
// Verify all fields are accessible // Verify all fields are accessible
if msg.ID != "msg-123" { if msg.ID != "msg-123" {
t.Errorf("ID mismatch") t.Errorf("ID mismatch")
} }
if msg.Title != "Test Message" { if msg.Subject != "Test Message" {
t.Errorf("Title mismatch") t.Errorf("Subject mismatch")
} }
if msg.Status != "open" { if msg.From != "test-sender" {
t.Errorf("Status mismatch") t.Errorf("From mismatch")
} }
} }