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