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
|
// 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,
|
||||||
|
|||||||
@@ -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")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user