fix(startup): unify agent startup with beacon + instructions in CLI prompt (#977)
All agents now receive their startup beacon + role-specific instructions via the CLI prompt, making sessions identifiable in /resume picker while removing unreliable post-startup nudges. Changes: - Rename FormatStartupNudge → FormatStartupBeacon, StartupNudgeConfig → BeaconConfig - Remove StartupNudge() function (no longer needed) - Remove PropulsionNudge() and PropulsionNudgeForRole() functions - Update deacon, witness, refinery, polecat managers to include beacon in CLI prompt - Update boot to inline beacon (can't import session due to import cycle) - Update daemon/lifecycle.go to include beacon via BuildCommandWithPrompt - Update cmd/deacon.go to include beacon in startup command - Remove redundant StartupNudge and PropulsionNudge calls from all startup paths The beacon is now part of the CLI prompt which is queued before Claude starts, making it more reliable than post-startup nudges which had timing issues. SessionStart hook runs gt prime automatically, so PropulsionNudge was redundant. Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -12,16 +12,10 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/steveyegge/gastown/internal/config"
|
"github.com/steveyegge/gastown/internal/config"
|
||||||
|
"github.com/steveyegge/gastown/internal/session"
|
||||||
"github.com/steveyegge/gastown/internal/tmux"
|
"github.com/steveyegge/gastown/internal/tmux"
|
||||||
)
|
)
|
||||||
|
|
||||||
// SessionName is the tmux session name for Boot.
|
|
||||||
// Note: We use "gt-boot" instead of "hq-deacon-boot" to avoid tmux prefix
|
|
||||||
// matching collisions. Tmux matches session names by prefix, so "hq-deacon-boot"
|
|
||||||
// would match when checking for "hq-deacon", causing HasSession("hq-deacon")
|
|
||||||
// to return true when only Boot is running.
|
|
||||||
const SessionName = "gt-boot"
|
|
||||||
|
|
||||||
// MarkerFileName is the lock file for Boot startup coordination.
|
// MarkerFileName is the lock file for Boot startup coordination.
|
||||||
const MarkerFileName = ".boot-running"
|
const MarkerFileName = ".boot-running"
|
||||||
|
|
||||||
@@ -81,7 +75,7 @@ func (b *Boot) IsRunning() bool {
|
|||||||
|
|
||||||
// IsSessionAlive checks if the Boot tmux session exists.
|
// IsSessionAlive checks if the Boot tmux session exists.
|
||||||
func (b *Boot) IsSessionAlive() bool {
|
func (b *Boot) IsSessionAlive() bool {
|
||||||
has, err := b.tmux.HasSession(SessionName)
|
has, err := b.tmux.HasSession(session.BootSessionName())
|
||||||
return err == nil && has
|
return err == nil && has
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -163,7 +157,7 @@ func (b *Boot) spawnTmux(agentOverride string) error {
|
|||||||
// Kill any stale session first.
|
// Kill any stale session first.
|
||||||
// Use KillSessionWithProcesses to ensure all descendant processes are killed.
|
// Use KillSessionWithProcesses to ensure all descendant processes are killed.
|
||||||
if b.IsSessionAlive() {
|
if b.IsSessionAlive() {
|
||||||
_ = b.tmux.KillSessionWithProcesses(SessionName)
|
_ = b.tmux.KillSessionWithProcesses(session.BootSessionName())
|
||||||
}
|
}
|
||||||
|
|
||||||
// Ensure boot directory exists (it should have CLAUDE.md with Boot context)
|
// Ensure boot directory exists (it should have CLAUDE.md with Boot context)
|
||||||
@@ -171,22 +165,26 @@ func (b *Boot) spawnTmux(agentOverride string) error {
|
|||||||
return fmt.Errorf("ensuring boot dir: %w", err)
|
return fmt.Errorf("ensuring boot dir: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Build startup command with optional agent override
|
initialPrompt := session.BuildStartupPrompt(session.BeaconConfig{
|
||||||
// The "gt boot triage" prompt tells Boot to immediately start triage (GUPP principle)
|
Recipient: "boot",
|
||||||
|
Sender: "daemon",
|
||||||
|
Topic: "triage",
|
||||||
|
}, "Run `gt boot triage` now.")
|
||||||
|
|
||||||
var startCmd string
|
var startCmd string
|
||||||
if agentOverride != "" {
|
if agentOverride != "" {
|
||||||
var err error
|
var err error
|
||||||
startCmd, err = config.BuildAgentStartupCommandWithAgentOverride("boot", "", b.townRoot, "", "gt boot triage", agentOverride)
|
startCmd, err = config.BuildAgentStartupCommandWithAgentOverride("boot", "", b.townRoot, "", initialPrompt, agentOverride)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("building startup command with agent override: %w", err)
|
return fmt.Errorf("building startup command with agent override: %w", err)
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
startCmd = config.BuildAgentStartupCommand("boot", "", b.townRoot, "", "gt boot triage")
|
startCmd = config.BuildAgentStartupCommand("boot", "", b.townRoot, "", initialPrompt)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create session with command directly to avoid send-keys race condition.
|
// Create session with command directly to avoid send-keys race condition.
|
||||||
// See: https://github.com/anthropics/gastown/issues/280
|
// See: https://github.com/anthropics/gastown/issues/280
|
||||||
if err := b.tmux.NewSessionWithCommand(SessionName, b.bootDir, startCmd); err != nil {
|
if err := b.tmux.NewSessionWithCommand(session.BootSessionName(), b.bootDir, startCmd); err != nil {
|
||||||
return fmt.Errorf("creating boot session: %w", err)
|
return fmt.Errorf("creating boot session: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -196,7 +194,7 @@ func (b *Boot) spawnTmux(agentOverride string) error {
|
|||||||
TownRoot: b.townRoot,
|
TownRoot: b.townRoot,
|
||||||
})
|
})
|
||||||
for k, v := range envVars {
|
for k, v := range envVars {
|
||||||
_ = b.tmux.SetEnvironment(SessionName, k, v)
|
_ = b.tmux.SetEnvironment(session.BootSessionName(), k, v)
|
||||||
}
|
}
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import (
|
|||||||
"github.com/spf13/cobra"
|
"github.com/spf13/cobra"
|
||||||
"github.com/steveyegge/gastown/internal/boot"
|
"github.com/steveyegge/gastown/internal/boot"
|
||||||
"github.com/steveyegge/gastown/internal/deacon"
|
"github.com/steveyegge/gastown/internal/deacon"
|
||||||
|
"github.com/steveyegge/gastown/internal/session"
|
||||||
"github.com/steveyegge/gastown/internal/style"
|
"github.com/steveyegge/gastown/internal/style"
|
||||||
"github.com/steveyegge/gastown/internal/workspace"
|
"github.com/steveyegge/gastown/internal/workspace"
|
||||||
)
|
)
|
||||||
@@ -141,7 +142,7 @@ func runBootStatus(cmd *cobra.Command, args []string) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if sessionAlive {
|
if sessionAlive {
|
||||||
fmt.Printf(" Session: %s (alive)\n", boot.SessionName)
|
fmt.Printf(" Session: %s (alive)\n", session.BootSessionName())
|
||||||
} else {
|
} else {
|
||||||
fmt.Printf(" Session: %s\n", style.Dim.Render("not running"))
|
fmt.Printf(" Session: %s\n", style.Dim.Render("not running"))
|
||||||
}
|
}
|
||||||
@@ -219,7 +220,7 @@ func runBootSpawn(cmd *cobra.Command, args []string) error {
|
|||||||
if b.IsDegraded() {
|
if b.IsDegraded() {
|
||||||
fmt.Println("Boot spawned in degraded mode (subprocess)")
|
fmt.Println("Boot spawned in degraded mode (subprocess)")
|
||||||
} else {
|
} else {
|
||||||
fmt.Printf("Boot spawned in session: %s\n", boot.SessionName)
|
fmt.Printf("Boot spawned in session: %s\n", session.BootSessionName())
|
||||||
}
|
}
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
|
|||||||
@@ -193,10 +193,10 @@ func runCrewAt(cmd *cobra.Command, args []string) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Build startup beacon for predecessor discovery via /resume
|
// Build startup beacon for predecessor discovery via /resume
|
||||||
// Use FormatStartupNudge instead of bare "gt prime" which confuses agents
|
// Use FormatStartupBeacon instead of bare "gt prime" which confuses agents
|
||||||
// The SessionStart hook handles context injection (gt prime --hook)
|
// The SessionStart hook handles context injection (gt prime --hook)
|
||||||
address := fmt.Sprintf("%s/crew/%s", r.Name, name)
|
address := fmt.Sprintf("%s/crew/%s", r.Name, name)
|
||||||
beacon := session.FormatStartupNudge(session.StartupNudgeConfig{
|
beacon := session.FormatStartupBeacon(session.BeaconConfig{
|
||||||
Recipient: address,
|
Recipient: address,
|
||||||
Sender: "human",
|
Sender: "human",
|
||||||
Topic: "start",
|
Topic: "start",
|
||||||
@@ -242,9 +242,9 @@ func runCrewAt(cmd *cobra.Command, args []string) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Build startup beacon for predecessor discovery via /resume
|
// Build startup beacon for predecessor discovery via /resume
|
||||||
// Use FormatStartupNudge instead of bare "gt prime" which confuses agents
|
// Use FormatStartupBeacon instead of bare "gt prime" which confuses agents
|
||||||
address := fmt.Sprintf("%s/crew/%s", r.Name, name)
|
address := fmt.Sprintf("%s/crew/%s", r.Name, name)
|
||||||
beacon := session.FormatStartupNudge(session.StartupNudgeConfig{
|
beacon := session.FormatStartupBeacon(session.BeaconConfig{
|
||||||
Recipient: address,
|
Recipient: address,
|
||||||
Sender: "human",
|
Sender: "human",
|
||||||
Topic: "restart",
|
Topic: "restart",
|
||||||
@@ -301,7 +301,7 @@ func runCrewAt(cmd *cobra.Command, args []string) error {
|
|||||||
// We're in the session at a shell prompt - start the agent
|
// We're in the session at a shell prompt - start the agent
|
||||||
// Build startup beacon for predecessor discovery via /resume
|
// Build startup beacon for predecessor discovery via /resume
|
||||||
address := fmt.Sprintf("%s/crew/%s", r.Name, name)
|
address := fmt.Sprintf("%s/crew/%s", r.Name, name)
|
||||||
beacon := session.FormatStartupNudge(session.StartupNudgeConfig{
|
beacon := session.FormatStartupBeacon(session.BeaconConfig{
|
||||||
Recipient: address,
|
Recipient: address,
|
||||||
Sender: "human",
|
Sender: "human",
|
||||||
Topic: "start",
|
Topic: "start",
|
||||||
|
|||||||
@@ -413,9 +413,12 @@ func startDeaconSession(t *tmux.Tmux, sessionName, agentOverride string) error {
|
|||||||
return fmt.Errorf("creating deacon settings: %w", err)
|
return fmt.Errorf("creating deacon settings: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Build startup command first
|
initialPrompt := session.BuildStartupPrompt(session.BeaconConfig{
|
||||||
// Export GT_ROLE and BD_ACTOR in the command since tmux SetEnvironment only affects new panes
|
Recipient: "deacon",
|
||||||
startupCmd, err := config.BuildAgentStartupCommandWithAgentOverride("deacon", "", townRoot, "", "", agentOverride)
|
Sender: "daemon",
|
||||||
|
Topic: "patrol",
|
||||||
|
}, "I am Deacon. Start patrol: check gt hook, if empty create mol-deacon-patrol wisp and execute it.")
|
||||||
|
startupCmd, err := config.BuildAgentStartupCommandWithAgentOverride("deacon", "", townRoot, "", initialPrompt, agentOverride)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("building startup command: %w", err)
|
return fmt.Errorf("building startup command: %w", err)
|
||||||
}
|
}
|
||||||
@@ -451,23 +454,6 @@ func startDeaconSession(t *tmux.Tmux, sessionName, agentOverride string) error {
|
|||||||
runtimeConfig := config.LoadRuntimeConfig("")
|
runtimeConfig := config.LoadRuntimeConfig("")
|
||||||
_ = runtime.RunStartupFallback(t, sessionName, "deacon", runtimeConfig)
|
_ = runtime.RunStartupFallback(t, sessionName, "deacon", runtimeConfig)
|
||||||
|
|
||||||
// Inject startup nudge for predecessor discovery via /resume
|
|
||||||
if err := session.StartupNudge(t, sessionName, session.StartupNudgeConfig{
|
|
||||||
Recipient: "deacon",
|
|
||||||
Sender: "daemon",
|
|
||||||
Topic: "patrol",
|
|
||||||
}); err != nil {
|
|
||||||
style.PrintWarning("failed to send startup nudge: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// GUPP: Gas Town Universal Propulsion Principle
|
|
||||||
// Send the propulsion nudge to trigger autonomous patrol execution.
|
|
||||||
// Wait for beacon to be fully processed (needs to be separate prompt)
|
|
||||||
time.Sleep(2 * time.Second)
|
|
||||||
if err := t.NudgeSession(sessionName, session.PropulsionNudgeForRole("deacon", deaconDir)); err != nil {
|
|
||||||
return fmt.Errorf("sending propulsion nudge: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -391,9 +391,9 @@ func buildRestartCommand(sessionName string) (string, error) {
|
|||||||
gtRole := identity.GTRole()
|
gtRole := identity.GTRole()
|
||||||
|
|
||||||
// Build startup beacon for predecessor discovery via /resume
|
// Build startup beacon for predecessor discovery via /resume
|
||||||
// Use FormatStartupNudge instead of bare "gt prime" which confuses agents
|
// Use FormatStartupBeacon instead of bare "gt prime" which confuses agents
|
||||||
// The SessionStart hook handles context injection (gt prime --hook)
|
// The SessionStart hook handles context injection (gt prime --hook)
|
||||||
beacon := session.FormatStartupNudge(session.StartupNudgeConfig{
|
beacon := session.FormatStartupBeacon(session.BeaconConfig{
|
||||||
Recipient: identity.Address(),
|
Recipient: identity.Address(),
|
||||||
Sender: "self",
|
Sender: "self",
|
||||||
Topic: "handoff",
|
Topic: "handoff",
|
||||||
|
|||||||
@@ -188,7 +188,7 @@ func runMayorAttach(cmd *cobra.Command, args []string) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Build startup beacon for context (like gt handoff does)
|
// Build startup beacon for context (like gt handoff does)
|
||||||
beacon := session.FormatStartupNudge(session.StartupNudgeConfig{
|
beacon := session.FormatStartupBeacon(session.BeaconConfig{
|
||||||
Recipient: "mayor",
|
Recipient: "mayor",
|
||||||
Sender: "human",
|
Sender: "human",
|
||||||
Topic: "attach",
|
Topic: "attach",
|
||||||
|
|||||||
@@ -404,7 +404,7 @@ func startOrRestartCrewMember(t *tmux.Tmux, r *rig.Rig, crewName, townRoot strin
|
|||||||
// Agent has exited, restart it
|
// Agent has exited, restart it
|
||||||
// Build startup beacon for predecessor discovery via /resume
|
// Build startup beacon for predecessor discovery via /resume
|
||||||
address := fmt.Sprintf("%s/crew/%s", r.Name, crewName)
|
address := fmt.Sprintf("%s/crew/%s", r.Name, crewName)
|
||||||
beacon := session.FormatStartupNudge(session.StartupNudgeConfig{
|
beacon := session.FormatStartupBeacon(session.BeaconConfig{
|
||||||
Recipient: address,
|
Recipient: address,
|
||||||
Sender: "human",
|
Sender: "human",
|
||||||
Topic: "restart",
|
Topic: "restart",
|
||||||
|
|||||||
@@ -491,7 +491,7 @@ func (m *Manager) Start(name string, opts StartOptions) error {
|
|||||||
if topic == "" {
|
if topic == "" {
|
||||||
topic = "start"
|
topic = "start"
|
||||||
}
|
}
|
||||||
beacon := session.FormatStartupNudge(session.StartupNudgeConfig{
|
beacon := session.FormatStartupBeacon(session.BeaconConfig{
|
||||||
Recipient: address,
|
Recipient: address,
|
||||||
Sender: "human",
|
Sender: "human",
|
||||||
Topic: topic,
|
Topic: topic,
|
||||||
|
|||||||
@@ -395,20 +395,6 @@ func (d *Daemon) restartSession(sessionName, identity string) error {
|
|||||||
_ = d.tmux.AcceptBypassPermissionsWarning(sessionName)
|
_ = d.tmux.AcceptBypassPermissionsWarning(sessionName)
|
||||||
time.Sleep(constants.ShutdownNotifyDelay)
|
time.Sleep(constants.ShutdownNotifyDelay)
|
||||||
|
|
||||||
// GUPP: Gas Town Universal Propulsion Principle
|
|
||||||
// Send startup nudge for predecessor discovery via /resume
|
|
||||||
recipient := identityToBDActor(identity)
|
|
||||||
_ = session.StartupNudge(d.tmux, sessionName, session.StartupNudgeConfig{
|
|
||||||
Recipient: recipient,
|
|
||||||
Sender: "deacon",
|
|
||||||
Topic: "lifecycle-restart",
|
|
||||||
}) // Non-fatal
|
|
||||||
|
|
||||||
// Send propulsion nudge to trigger autonomous execution.
|
|
||||||
// Wait for beacon to be fully processed (needs to be separate prompt)
|
|
||||||
time.Sleep(2 * time.Second)
|
|
||||||
_ = d.tmux.NudgeSession(sessionName, session.PropulsionNudgeForRole(parsed.RoleType, workDir)) // Non-fatal
|
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -464,6 +450,7 @@ func (d *Daemon) getNeedsPreSync(config *beads.RoleConfig, parsed *ParsedIdentit
|
|||||||
|
|
||||||
// getStartCommand determines the startup command for an agent.
|
// getStartCommand determines the startup command for an agent.
|
||||||
// Uses role config if available, then role-based agent selection, then hardcoded defaults.
|
// Uses role config if available, then role-based agent selection, then hardcoded defaults.
|
||||||
|
// Includes beacon + role-specific instructions in the CLI prompt.
|
||||||
func (d *Daemon) getStartCommand(roleConfig *beads.RoleConfig, parsed *ParsedIdentity) string {
|
func (d *Daemon) getStartCommand(roleConfig *beads.RoleConfig, parsed *ParsedIdentity) string {
|
||||||
// If role config is available, use it
|
// If role config is available, use it
|
||||||
if roleConfig != nil && roleConfig.StartCommand != "" {
|
if roleConfig != nil && roleConfig.StartCommand != "" {
|
||||||
@@ -479,8 +466,22 @@ func (d *Daemon) getStartCommand(roleConfig *beads.RoleConfig, parsed *ParsedIde
|
|||||||
// Use role-based agent resolution for per-role model selection
|
// Use role-based agent resolution for per-role model selection
|
||||||
runtimeConfig := config.ResolveRoleAgentConfig(parsed.RoleType, d.config.TownRoot, rigPath)
|
runtimeConfig := config.ResolveRoleAgentConfig(parsed.RoleType, d.config.TownRoot, rigPath)
|
||||||
|
|
||||||
|
// Build recipient for beacon
|
||||||
|
recipient := identityToBDActor(parsed.RigName + "/" + parsed.RoleType)
|
||||||
|
if parsed.AgentName != "" {
|
||||||
|
recipient = identityToBDActor(parsed.RigName + "/" + parsed.RoleType + "/" + parsed.AgentName)
|
||||||
|
}
|
||||||
|
if parsed.RoleType == "deacon" || parsed.RoleType == "mayor" {
|
||||||
|
recipient = parsed.RoleType
|
||||||
|
}
|
||||||
|
prompt := session.BuildStartupPrompt(session.BeaconConfig{
|
||||||
|
Recipient: recipient,
|
||||||
|
Sender: "daemon",
|
||||||
|
Topic: "lifecycle-restart",
|
||||||
|
}, "Check your hook and begin work.")
|
||||||
|
|
||||||
// Build default command using the role-resolved runtime config
|
// Build default command using the role-resolved runtime config
|
||||||
defaultCmd := "exec " + runtimeConfig.BuildCommand()
|
defaultCmd := "exec " + runtimeConfig.BuildCommandWithPrompt(prompt)
|
||||||
if runtimeConfig.Session != nil && runtimeConfig.Session.SessionIDEnv != "" {
|
if runtimeConfig.Session != nil && runtimeConfig.Session.SessionIDEnv != "" {
|
||||||
defaultCmd = config.PrependEnv(defaultCmd, map[string]string{"GT_SESSION_ID_ENV": runtimeConfig.Session.SessionIDEnv})
|
defaultCmd = config.PrependEnv(defaultCmd, map[string]string{"GT_SESSION_ID_ENV": runtimeConfig.Session.SessionIDEnv})
|
||||||
}
|
}
|
||||||
@@ -498,7 +499,7 @@ func (d *Daemon) getStartCommand(roleConfig *beads.RoleConfig, parsed *ParsedIde
|
|||||||
TownRoot: d.config.TownRoot,
|
TownRoot: d.config.TownRoot,
|
||||||
SessionIDEnv: sessionIDEnv,
|
SessionIDEnv: sessionIDEnv,
|
||||||
})
|
})
|
||||||
return config.PrependEnv("exec "+runtimeConfig.BuildCommand(), envVars)
|
return config.PrependEnv("exec "+runtimeConfig.BuildCommandWithPrompt(prompt), envVars)
|
||||||
}
|
}
|
||||||
|
|
||||||
if parsed.RoleType == "crew" {
|
if parsed.RoleType == "crew" {
|
||||||
@@ -513,7 +514,7 @@ func (d *Daemon) getStartCommand(roleConfig *beads.RoleConfig, parsed *ParsedIde
|
|||||||
TownRoot: d.config.TownRoot,
|
TownRoot: d.config.TownRoot,
|
||||||
SessionIDEnv: sessionIDEnv,
|
SessionIDEnv: sessionIDEnv,
|
||||||
})
|
})
|
||||||
return config.PrependEnv("exec "+runtimeConfig.BuildCommand(), envVars)
|
return config.PrependEnv("exec "+runtimeConfig.BuildCommandWithPrompt(prompt), envVars)
|
||||||
}
|
}
|
||||||
|
|
||||||
return defaultCmd
|
return defaultCmd
|
||||||
|
|||||||
@@ -80,10 +80,11 @@ func (m *Manager) Start(agentOverride string) error {
|
|||||||
return fmt.Errorf("ensuring Claude settings: %w", err)
|
return fmt.Errorf("ensuring Claude settings: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Build startup command with initial prompt for autonomous patrol.
|
initialPrompt := session.BuildStartupPrompt(session.BeaconConfig{
|
||||||
// The prompt triggers GUPP: deacon starts patrol immediately without waiting for input.
|
Recipient: "deacon",
|
||||||
// This prevents the agent from sitting idle at the prompt after SessionStart hooks run.
|
Sender: "daemon",
|
||||||
initialPrompt := "I am Deacon. Start patrol: check gt hook, if empty create mol-deacon-patrol wisp and execute it."
|
Topic: "patrol",
|
||||||
|
}, "I am Deacon. Start patrol: check gt hook, if empty create mol-deacon-patrol wisp and execute it.")
|
||||||
startupCmd, err := config.BuildAgentStartupCommandWithAgentOverride("deacon", "", m.townRoot, "", initialPrompt, agentOverride)
|
startupCmd, err := config.BuildAgentStartupCommandWithAgentOverride("deacon", "", m.townRoot, "", initialPrompt, agentOverride)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("building startup command: %w", err)
|
return fmt.Errorf("building startup command: %w", err)
|
||||||
@@ -121,19 +122,6 @@ func (m *Manager) Start(agentOverride string) error {
|
|||||||
|
|
||||||
time.Sleep(constants.ShutdownNotifyDelay)
|
time.Sleep(constants.ShutdownNotifyDelay)
|
||||||
|
|
||||||
// Inject startup nudge for predecessor discovery via /resume
|
|
||||||
_ = session.StartupNudge(t, sessionID, session.StartupNudgeConfig{
|
|
||||||
Recipient: "deacon",
|
|
||||||
Sender: "daemon",
|
|
||||||
Topic: "patrol",
|
|
||||||
}) // Non-fatal
|
|
||||||
|
|
||||||
// GUPP: Gas Town Universal Propulsion Principle
|
|
||||||
// Send the propulsion nudge to trigger autonomous patrol execution.
|
|
||||||
// Wait for beacon to be fully processed (needs to be separate prompt)
|
|
||||||
time.Sleep(2 * time.Second)
|
|
||||||
_ = t.NudgeSession(sessionID, session.PropulsionNudgeForRole("deacon", deaconDir)) // Non-fatal
|
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/steveyegge/gastown/internal/boot"
|
"github.com/steveyegge/gastown/internal/boot"
|
||||||
|
"github.com/steveyegge/gastown/internal/session"
|
||||||
)
|
)
|
||||||
|
|
||||||
// BootHealthCheck verifies Boot watchdog health.
|
// BootHealthCheck verifies Boot watchdog health.
|
||||||
@@ -63,9 +64,9 @@ func (c *BootHealthCheck) Run(ctx *CheckContext) *CheckResult {
|
|||||||
// Check 2: Session alive
|
// Check 2: Session alive
|
||||||
sessionAlive := b.IsSessionAlive()
|
sessionAlive := b.IsSessionAlive()
|
||||||
if sessionAlive {
|
if sessionAlive {
|
||||||
details = append(details, fmt.Sprintf("Session: %s (alive)", boot.SessionName))
|
details = append(details, fmt.Sprintf("Session: %s (alive)", session.BootSessionName()))
|
||||||
} else {
|
} else {
|
||||||
details = append(details, fmt.Sprintf("Session: %s (not running)", boot.SessionName))
|
details = append(details, fmt.Sprintf("Session: %s (not running)", session.BootSessionName()))
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check 3: Last execution status
|
// Check 3: Last execution status
|
||||||
|
|||||||
@@ -80,7 +80,7 @@ func (m *Manager) Start(agentOverride string) error {
|
|||||||
|
|
||||||
// Build startup beacon with explicit instructions (matches gt handoff behavior)
|
// Build startup beacon with explicit instructions (matches gt handoff behavior)
|
||||||
// This ensures the agent has clear context immediately, not after nudges arrive
|
// This ensures the agent has clear context immediately, not after nudges arrive
|
||||||
beacon := session.FormatStartupNudge(session.StartupNudgeConfig{
|
beacon := session.FormatStartupBeacon(session.BeaconConfig{
|
||||||
Recipient: "mayor",
|
Recipient: "mayor",
|
||||||
Sender: "human",
|
Sender: "human",
|
||||||
Topic: "cold-start",
|
Topic: "cold-start",
|
||||||
|
|||||||
@@ -180,10 +180,19 @@ func (m *SessionManager) Start(polecat string, opts SessionStartOptions) error {
|
|||||||
return fmt.Errorf("ensuring runtime settings: %w", err)
|
return fmt.Errorf("ensuring runtime settings: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Build startup command first
|
// Build startup command with beacon for predecessor discovery.
|
||||||
|
// Topic "assigned" already includes instructions in FormatStartupBeacon.
|
||||||
|
address := fmt.Sprintf("%s/polecats/%s", m.rig.Name, polecat)
|
||||||
|
beacon := session.FormatStartupBeacon(session.BeaconConfig{
|
||||||
|
Recipient: address,
|
||||||
|
Sender: "witness",
|
||||||
|
Topic: "assigned",
|
||||||
|
MolID: opts.Issue,
|
||||||
|
})
|
||||||
|
|
||||||
command := opts.Command
|
command := opts.Command
|
||||||
if command == "" {
|
if command == "" {
|
||||||
command = config.BuildPolecatStartupCommand(m.rig.Name, polecat, m.rig.Path, "")
|
command = config.BuildPolecatStartupCommand(m.rig.Name, polecat, m.rig.Path, beacon)
|
||||||
}
|
}
|
||||||
// Prepend runtime config dir env if needed
|
// Prepend runtime config dir env if needed
|
||||||
if runtimeConfig.Session != nil && runtimeConfig.Session.ConfigDirEnv != "" && opts.RuntimeConfigDir != "" {
|
if runtimeConfig.Session != nil && runtimeConfig.Session.ConfigDirEnv != "" && opts.RuntimeConfigDir != "" {
|
||||||
@@ -237,19 +246,6 @@ func (m *SessionManager) Start(polecat string, opts SessionStartOptions) error {
|
|||||||
runtime.SleepForReadyDelay(runtimeConfig)
|
runtime.SleepForReadyDelay(runtimeConfig)
|
||||||
_ = runtime.RunStartupFallback(m.tmux, sessionID, "polecat", runtimeConfig)
|
_ = runtime.RunStartupFallback(m.tmux, sessionID, "polecat", runtimeConfig)
|
||||||
|
|
||||||
// Inject startup nudge for predecessor discovery via /resume
|
|
||||||
address := fmt.Sprintf("%s/polecats/%s", m.rig.Name, polecat)
|
|
||||||
debugSession("StartupNudge", session.StartupNudge(m.tmux, sessionID, session.StartupNudgeConfig{
|
|
||||||
Recipient: address,
|
|
||||||
Sender: "witness",
|
|
||||||
Topic: "assigned",
|
|
||||||
MolID: opts.Issue,
|
|
||||||
}))
|
|
||||||
|
|
||||||
// GUPP: Send propulsion nudge to trigger autonomous work execution
|
|
||||||
time.Sleep(2 * time.Second)
|
|
||||||
debugSession("NudgeSession PropulsionNudge", m.tmux.NudgeSession(sessionID, session.PropulsionNudge()))
|
|
||||||
|
|
||||||
// Verify session survived startup - if the command crashed, the session may have died.
|
// Verify session survived startup - if the command crashed, the session may have died.
|
||||||
// Without this check, Start() would return success even if the pane died during initialization.
|
// Without this check, Start() would return success even if the pane died during initialization.
|
||||||
running, err = m.tmux.HasSession(sessionID)
|
running, err = m.tmux.HasSession(sessionID)
|
||||||
|
|||||||
@@ -134,16 +134,21 @@ func (m *Manager) Start(foreground bool, agentOverride string) error {
|
|||||||
return fmt.Errorf("ensuring runtime settings: %w", err)
|
return fmt.Errorf("ensuring runtime settings: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Build startup command first
|
initialPrompt := session.BuildStartupPrompt(session.BeaconConfig{
|
||||||
|
Recipient: fmt.Sprintf("%s/refinery", m.rig.Name),
|
||||||
|
Sender: "deacon",
|
||||||
|
Topic: "patrol",
|
||||||
|
}, "Check your hook and begin patrol.")
|
||||||
|
|
||||||
var command string
|
var command string
|
||||||
if agentOverride != "" {
|
if agentOverride != "" {
|
||||||
var err error
|
var err error
|
||||||
command, err = config.BuildAgentStartupCommandWithAgentOverride("refinery", m.rig.Name, townRoot, m.rig.Path, "", agentOverride)
|
command, err = config.BuildAgentStartupCommandWithAgentOverride("refinery", m.rig.Name, townRoot, m.rig.Path, initialPrompt, agentOverride)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("building startup command with agent override: %w", err)
|
return fmt.Errorf("building startup command with agent override: %w", err)
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
command = config.BuildAgentStartupCommand("refinery", m.rig.Name, townRoot, m.rig.Path, "")
|
command = config.BuildAgentStartupCommand("refinery", m.rig.Name, townRoot, m.rig.Path, initialPrompt)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create session with command directly to avoid send-keys race condition.
|
// Create session with command directly to avoid send-keys race condition.
|
||||||
@@ -189,20 +194,6 @@ func (m *Manager) Start(foreground bool, agentOverride string) error {
|
|||||||
runtime.SleepForReadyDelay(runtimeConfig)
|
runtime.SleepForReadyDelay(runtimeConfig)
|
||||||
_ = runtime.RunStartupFallback(t, sessionID, "refinery", runtimeConfig)
|
_ = runtime.RunStartupFallback(t, sessionID, "refinery", runtimeConfig)
|
||||||
|
|
||||||
// Inject startup nudge for predecessor discovery via /resume
|
|
||||||
address := fmt.Sprintf("%s/refinery", m.rig.Name)
|
|
||||||
_ = session.StartupNudge(t, sessionID, session.StartupNudgeConfig{
|
|
||||||
Recipient: address,
|
|
||||||
Sender: "deacon",
|
|
||||||
Topic: "patrol",
|
|
||||||
}) // Non-fatal
|
|
||||||
|
|
||||||
// GUPP: Gas Town Universal Propulsion Principle
|
|
||||||
// Send the propulsion nudge to trigger autonomous patrol execution.
|
|
||||||
// Wait for beacon to be fully processed (needs to be separate prompt)
|
|
||||||
time.Sleep(2 * time.Second)
|
|
||||||
_ = t.NudgeSession(sessionID, session.PropulsionNudgeForRole("refinery", refineryRigDir)) // Non-fatal
|
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -3,9 +3,6 @@ package session
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
"os"
|
|
||||||
"path/filepath"
|
|
||||||
"strings"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
// Prefix is the common prefix for rig-level Gas Town tmux sessions.
|
// Prefix is the common prefix for rig-level Gas Town tmux sessions.
|
||||||
@@ -46,57 +43,12 @@ func PolecatSessionName(rig, name string) string {
|
|||||||
return fmt.Sprintf("%s%s-%s", Prefix, rig, name)
|
return fmt.Sprintf("%s%s-%s", Prefix, rig, name)
|
||||||
}
|
}
|
||||||
|
|
||||||
// PropulsionNudge generates the GUPP (Gas Town Universal Propulsion Principle) nudge.
|
// BootSessionName returns the session name for the Boot watchdog.
|
||||||
// This is sent after the beacon to trigger autonomous work execution.
|
// Note: We use "gt-boot" instead of "hq-deacon-boot" to avoid tmux prefix
|
||||||
// The agent receives this as user input, triggering the propulsion principle:
|
// matching collisions. Tmux matches session names by prefix, so "hq-deacon-boot"
|
||||||
// "If work is on your hook, YOU RUN IT."
|
// would match when checking for "hq-deacon", causing HasSession("hq-deacon")
|
||||||
func PropulsionNudge() string {
|
// to return true when only Boot is running.
|
||||||
return "Run `gt hook` to check your hook and begin work."
|
func BootSessionName() string {
|
||||||
|
return Prefix + "boot"
|
||||||
}
|
}
|
||||||
|
|
||||||
// PropulsionNudgeForRole generates a role-specific GUPP nudge.
|
|
||||||
// Different roles have different startup flows:
|
|
||||||
// - polecat/crew: Check hook for slung work
|
|
||||||
// - witness/refinery: Start patrol cycle
|
|
||||||
// - deacon: Start heartbeat patrol
|
|
||||||
// - mayor: Check mail for coordination work
|
|
||||||
//
|
|
||||||
// The workDir parameter is used to locate .runtime/session_id for including
|
|
||||||
// session ID in the message (for Claude Code /resume picker discovery).
|
|
||||||
func PropulsionNudgeForRole(role, workDir string) string {
|
|
||||||
var msg string
|
|
||||||
switch role {
|
|
||||||
case "polecat", "crew":
|
|
||||||
msg = PropulsionNudge()
|
|
||||||
case "witness":
|
|
||||||
msg = "Run `gt prime` to check patrol status and begin work."
|
|
||||||
case "refinery":
|
|
||||||
msg = "Run `gt prime` to check MQ status and begin patrol."
|
|
||||||
case "deacon":
|
|
||||||
msg = "Run `gt prime` to check patrol status and begin heartbeat cycle."
|
|
||||||
case "mayor":
|
|
||||||
msg = "Run `gt prime` to check mail and begin coordination."
|
|
||||||
default:
|
|
||||||
msg = PropulsionNudge()
|
|
||||||
}
|
|
||||||
|
|
||||||
// Append session ID if available (for /resume picker visibility)
|
|
||||||
if sessionID := readSessionID(workDir); sessionID != "" {
|
|
||||||
msg = fmt.Sprintf("%s [session:%s]", msg, sessionID)
|
|
||||||
}
|
|
||||||
return msg
|
|
||||||
}
|
|
||||||
|
|
||||||
// readSessionID reads the session ID from .runtime/session_id if it exists.
|
|
||||||
// Returns empty string if the file doesn't exist or can't be read.
|
|
||||||
func readSessionID(workDir string) string {
|
|
||||||
if workDir == "" {
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
sessionPath := filepath.Join(workDir, ".runtime", "session_id")
|
|
||||||
data, err := os.ReadFile(sessionPath)
|
|
||||||
if err != nil {
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
return strings.TrimSpace(string(data))
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,9 +1,6 @@
|
|||||||
package session
|
package session
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"os"
|
|
||||||
"path/filepath"
|
|
||||||
"strings"
|
|
||||||
"testing"
|
"testing"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -109,63 +106,3 @@ func TestPrefix(t *testing.T) {
|
|||||||
t.Errorf("Prefix = %q, want %q", Prefix, want)
|
t.Errorf("Prefix = %q, want %q", Prefix, want)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestPropulsionNudgeForRole_WithSessionID(t *testing.T) {
|
|
||||||
// Create temp directory with session_id file
|
|
||||||
tmpDir := t.TempDir()
|
|
||||||
runtimeDir := filepath.Join(tmpDir, ".runtime")
|
|
||||||
if err := os.MkdirAll(runtimeDir, 0755); err != nil {
|
|
||||||
t.Fatalf("creating runtime dir: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
sessionID := "test-session-abc123"
|
|
||||||
if err := os.WriteFile(filepath.Join(runtimeDir, "session_id"), []byte(sessionID), 0644); err != nil {
|
|
||||||
t.Fatalf("writing session_id: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Test that session ID is appended
|
|
||||||
msg := PropulsionNudgeForRole("mayor", tmpDir)
|
|
||||||
if !strings.Contains(msg, "[session:test-session-abc123]") {
|
|
||||||
t.Errorf("PropulsionNudgeForRole(mayor, tmpDir) = %q, should contain [session:test-session-abc123]", msg)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestPropulsionNudgeForRole_WithoutSessionID(t *testing.T) {
|
|
||||||
// Use nonexistent directory
|
|
||||||
msg := PropulsionNudgeForRole("mayor", "/nonexistent-dir-12345")
|
|
||||||
if strings.Contains(msg, "[session:") {
|
|
||||||
t.Errorf("PropulsionNudgeForRole(mayor, /nonexistent) = %q, should NOT contain session ID", msg)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestPropulsionNudgeForRole_EmptyWorkDir(t *testing.T) {
|
|
||||||
// Empty workDir should not crash and should not include session ID
|
|
||||||
msg := PropulsionNudgeForRole("mayor", "")
|
|
||||||
if strings.Contains(msg, "[session:") {
|
|
||||||
t.Errorf("PropulsionNudgeForRole(mayor, \"\") = %q, should NOT contain session ID", msg)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestPropulsionNudgeForRole_AllRoles(t *testing.T) {
|
|
||||||
tests := []struct {
|
|
||||||
role string
|
|
||||||
contains string
|
|
||||||
}{
|
|
||||||
{"polecat", "gt hook"},
|
|
||||||
{"crew", "gt hook"},
|
|
||||||
{"witness", "gt prime"},
|
|
||||||
{"refinery", "gt prime"},
|
|
||||||
{"deacon", "gt prime"},
|
|
||||||
{"mayor", "gt prime"},
|
|
||||||
{"unknown", "gt hook"},
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, tt := range tests {
|
|
||||||
t.Run(tt.role, func(t *testing.T) {
|
|
||||||
msg := PropulsionNudgeForRole(tt.role, "")
|
|
||||||
if !strings.Contains(msg, tt.contains) {
|
|
||||||
t.Errorf("PropulsionNudgeForRole(%q, \"\") = %q, should contain %q", tt.role, msg, tt.contains)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -4,12 +4,11 @@ package session
|
|||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/steveyegge/gastown/internal/tmux"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
// StartupNudgeConfig configures a startup nudge message.
|
// BeaconConfig configures a startup beacon message.
|
||||||
type StartupNudgeConfig struct {
|
// The beacon is injected into the CLI prompt to identify sessions in /resume picker.
|
||||||
|
type BeaconConfig struct {
|
||||||
// Recipient is the address of the agent being nudged.
|
// Recipient is the address of the agent being nudged.
|
||||||
// Examples: "gastown/crew/gus", "deacon", "gastown/witness"
|
// Examples: "gastown/crew/gus", "deacon", "gastown/witness"
|
||||||
Recipient string
|
Recipient string
|
||||||
@@ -27,27 +26,17 @@ type StartupNudgeConfig struct {
|
|||||||
MolID string
|
MolID string
|
||||||
}
|
}
|
||||||
|
|
||||||
// StartupNudge sends a formatted startup message to a Claude Code session.
|
// FormatStartupBeacon builds the formatted startup beacon message.
|
||||||
// The message becomes the session title in Claude Code's /resume picker,
|
// The beacon is injected into the CLI prompt, making sessions identifiable
|
||||||
// enabling workers to find predecessor sessions.
|
// in Claude Code's /resume picker for predecessor discovery.
|
||||||
//
|
//
|
||||||
// Format: [GAS TOWN] <recipient> <- <sender> • <timestamp> • <topic[:mol-id]>
|
// Format: [GAS TOWN] <recipient> <- <sender> • <timestamp> • <topic[:mol-id]>
|
||||||
//
|
//
|
||||||
// Examples:
|
// Examples:
|
||||||
// - [GAS TOWN] gastown/crew/gus <- deacon • 2025-12-30T15:42 • assigned:gt-abc12
|
// - [GAS TOWN] gastown/crew/gus <- deacon • 2025-12-30T15:42 • assigned:gt-abc12
|
||||||
// - [GAS TOWN] deacon <- mayor • 2025-12-30T08:00 • cold-start
|
// - [GAS TOWN] deacon <- daemon • 2025-12-30T08:00 • patrol
|
||||||
// - [GAS TOWN] gastown/witness <- self • 2025-12-30T14:00 • handoff
|
// - [GAS TOWN] gastown/witness <- deacon • 2025-12-30T14:00 • patrol
|
||||||
//
|
func FormatStartupBeacon(cfg BeaconConfig) string {
|
||||||
// The message content doesn't trigger GUPP - CLAUDE.md and hooks handle that.
|
|
||||||
// The metadata makes sessions identifiable in /resume.
|
|
||||||
func StartupNudge(t *tmux.Tmux, session string, cfg StartupNudgeConfig) error {
|
|
||||||
message := FormatStartupNudge(cfg)
|
|
||||||
return t.NudgeSession(session, message)
|
|
||||||
}
|
|
||||||
|
|
||||||
// FormatStartupNudge builds the formatted startup nudge message.
|
|
||||||
// Separated from StartupNudge for testing and reuse.
|
|
||||||
func FormatStartupNudge(cfg StartupNudgeConfig) string {
|
|
||||||
// Use local time in compact format
|
// Use local time in compact format
|
||||||
timestamp := time.Now().Format("2006-01-02T15:04")
|
timestamp := time.Now().Format("2006-01-02T15:04")
|
||||||
|
|
||||||
@@ -91,3 +80,18 @@ func FormatStartupNudge(cfg StartupNudgeConfig) string {
|
|||||||
|
|
||||||
return beacon
|
return beacon
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// BuildStartupPrompt creates the CLI prompt for agent startup.
|
||||||
|
//
|
||||||
|
// GUPP (Gas Town Universal Propulsion Principle) implementation:
|
||||||
|
// - Beacon identifies session for /resume predecessor discovery
|
||||||
|
// - Instructions tell agent to start working immediately
|
||||||
|
// - SessionStart hook runs `gt prime` which injects full context including
|
||||||
|
// "AUTONOMOUS WORK MODE" instructions when work is hooked
|
||||||
|
//
|
||||||
|
// This replaces the old two-step StartupNudge + PropulsionNudge pattern.
|
||||||
|
// The beacon is processed in Claude's first turn along with gt prime context,
|
||||||
|
// so no separate propulsion nudge is needed.
|
||||||
|
func BuildStartupPrompt(cfg BeaconConfig, instructions string) string {
|
||||||
|
return FormatStartupBeacon(cfg) + "\n\n" + instructions
|
||||||
|
}
|
||||||
|
|||||||
@@ -5,16 +5,16 @@ import (
|
|||||||
"testing"
|
"testing"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestFormatStartupNudge(t *testing.T) {
|
func TestFormatStartupBeacon(t *testing.T) {
|
||||||
tests := []struct {
|
tests := []struct {
|
||||||
name string
|
name string
|
||||||
cfg StartupNudgeConfig
|
cfg BeaconConfig
|
||||||
wantSub []string // substrings that must appear
|
wantSub []string // substrings that must appear
|
||||||
wantNot []string // substrings that must NOT appear
|
wantNot []string // substrings that must NOT appear
|
||||||
}{
|
}{
|
||||||
{
|
{
|
||||||
name: "assigned with mol-id",
|
name: "assigned with mol-id",
|
||||||
cfg: StartupNudgeConfig{
|
cfg: BeaconConfig{
|
||||||
Recipient: "gastown/crew/gus",
|
Recipient: "gastown/crew/gus",
|
||||||
Sender: "deacon",
|
Sender: "deacon",
|
||||||
Topic: "assigned",
|
Topic: "assigned",
|
||||||
@@ -31,7 +31,7 @@ func TestFormatStartupNudge(t *testing.T) {
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "cold-start no mol-id",
|
name: "cold-start no mol-id",
|
||||||
cfg: StartupNudgeConfig{
|
cfg: BeaconConfig{
|
||||||
Recipient: "deacon",
|
Recipient: "deacon",
|
||||||
Sender: "mayor",
|
Sender: "mayor",
|
||||||
Topic: "cold-start",
|
Topic: "cold-start",
|
||||||
@@ -49,7 +49,7 @@ func TestFormatStartupNudge(t *testing.T) {
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "handoff self",
|
name: "handoff self",
|
||||||
cfg: StartupNudgeConfig{
|
cfg: BeaconConfig{
|
||||||
Recipient: "gastown/witness",
|
Recipient: "gastown/witness",
|
||||||
Sender: "self",
|
Sender: "self",
|
||||||
Topic: "handoff",
|
Topic: "handoff",
|
||||||
@@ -66,7 +66,7 @@ func TestFormatStartupNudge(t *testing.T) {
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "mol-id only",
|
name: "mol-id only",
|
||||||
cfg: StartupNudgeConfig{
|
cfg: BeaconConfig{
|
||||||
Recipient: "gastown/polecats/Toast",
|
Recipient: "gastown/polecats/Toast",
|
||||||
Sender: "witness",
|
Sender: "witness",
|
||||||
MolID: "gt-xyz99",
|
MolID: "gt-xyz99",
|
||||||
@@ -80,7 +80,7 @@ func TestFormatStartupNudge(t *testing.T) {
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "empty topic defaults to ready",
|
name: "empty topic defaults to ready",
|
||||||
cfg: StartupNudgeConfig{
|
cfg: BeaconConfig{
|
||||||
Recipient: "deacon",
|
Recipient: "deacon",
|
||||||
Sender: "mayor",
|
Sender: "mayor",
|
||||||
},
|
},
|
||||||
@@ -91,7 +91,7 @@ func TestFormatStartupNudge(t *testing.T) {
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "start includes fallback instructions",
|
name: "start includes fallback instructions",
|
||||||
cfg: StartupNudgeConfig{
|
cfg: BeaconConfig{
|
||||||
Recipient: "beads/crew/fang",
|
Recipient: "beads/crew/fang",
|
||||||
Sender: "human",
|
Sender: "human",
|
||||||
Topic: "start",
|
Topic: "start",
|
||||||
@@ -106,7 +106,7 @@ func TestFormatStartupNudge(t *testing.T) {
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "restart includes fallback instructions",
|
name: "restart includes fallback instructions",
|
||||||
cfg: StartupNudgeConfig{
|
cfg: BeaconConfig{
|
||||||
Recipient: "gastown/crew/george",
|
Recipient: "gastown/crew/george",
|
||||||
Sender: "human",
|
Sender: "human",
|
||||||
Topic: "restart",
|
Topic: "restart",
|
||||||
@@ -122,19 +122,55 @@ func TestFormatStartupNudge(t *testing.T) {
|
|||||||
|
|
||||||
for _, tt := range tests {
|
for _, tt := range tests {
|
||||||
t.Run(tt.name, func(t *testing.T) {
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
got := FormatStartupNudge(tt.cfg)
|
got := FormatStartupBeacon(tt.cfg)
|
||||||
|
|
||||||
for _, sub := range tt.wantSub {
|
for _, sub := range tt.wantSub {
|
||||||
if !strings.Contains(got, sub) {
|
if !strings.Contains(got, sub) {
|
||||||
t.Errorf("FormatStartupNudge() = %q, want to contain %q", got, sub)
|
t.Errorf("FormatStartupBeacon() = %q, want to contain %q", got, sub)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
for _, sub := range tt.wantNot {
|
for _, sub := range tt.wantNot {
|
||||||
if strings.Contains(got, sub) {
|
if strings.Contains(got, sub) {
|
||||||
t.Errorf("FormatStartupNudge() = %q, should NOT contain %q", got, sub)
|
t.Errorf("FormatStartupBeacon() = %q, should NOT contain %q", got, sub)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestBuildStartupPrompt(t *testing.T) {
|
||||||
|
// BuildStartupPrompt combines beacon + instructions
|
||||||
|
cfg := BeaconConfig{
|
||||||
|
Recipient: "deacon",
|
||||||
|
Sender: "daemon",
|
||||||
|
Topic: "patrol",
|
||||||
|
}
|
||||||
|
instructions := "Start patrol immediately."
|
||||||
|
|
||||||
|
got := BuildStartupPrompt(cfg, instructions)
|
||||||
|
|
||||||
|
// Should contain beacon parts
|
||||||
|
if !strings.Contains(got, "[GAS TOWN]") {
|
||||||
|
t.Errorf("BuildStartupPrompt() missing beacon header")
|
||||||
|
}
|
||||||
|
if !strings.Contains(got, "deacon") {
|
||||||
|
t.Errorf("BuildStartupPrompt() missing recipient")
|
||||||
|
}
|
||||||
|
if !strings.Contains(got, "<- daemon") {
|
||||||
|
t.Errorf("BuildStartupPrompt() missing sender")
|
||||||
|
}
|
||||||
|
if !strings.Contains(got, "patrol") {
|
||||||
|
t.Errorf("BuildStartupPrompt() missing topic")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Should contain instructions after beacon
|
||||||
|
if !strings.Contains(got, instructions) {
|
||||||
|
t.Errorf("BuildStartupPrompt() missing instructions")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Should have blank line between beacon and instructions
|
||||||
|
if !strings.Contains(got, "\n\n"+instructions) {
|
||||||
|
t.Errorf("BuildStartupPrompt() missing blank line before instructions")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -5,7 +5,6 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/steveyegge/gastown/internal/boot"
|
|
||||||
"github.com/steveyegge/gastown/internal/events"
|
"github.com/steveyegge/gastown/internal/events"
|
||||||
"github.com/steveyegge/gastown/internal/tmux"
|
"github.com/steveyegge/gastown/internal/tmux"
|
||||||
)
|
)
|
||||||
@@ -22,7 +21,7 @@ type TownSession struct {
|
|||||||
func TownSessions() []TownSession {
|
func TownSessions() []TownSession {
|
||||||
return []TownSession{
|
return []TownSession{
|
||||||
{"Mayor", MayorSessionName()},
|
{"Mayor", MayorSessionName()},
|
||||||
{"Boot", boot.SessionName},
|
{"Boot", BootSessionName()},
|
||||||
{"Deacon", DeaconSessionName()},
|
{"Deacon", DeaconSessionName()},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -182,20 +182,6 @@ func (m *Manager) Start(foreground bool, agentOverride string, envOverrides []st
|
|||||||
|
|
||||||
time.Sleep(constants.ShutdownNotifyDelay)
|
time.Sleep(constants.ShutdownNotifyDelay)
|
||||||
|
|
||||||
// Inject startup nudge for predecessor discovery via /resume
|
|
||||||
address := fmt.Sprintf("%s/witness", m.rig.Name)
|
|
||||||
_ = session.StartupNudge(t, sessionID, session.StartupNudgeConfig{
|
|
||||||
Recipient: address,
|
|
||||||
Sender: "deacon",
|
|
||||||
Topic: "patrol",
|
|
||||||
}) // Non-fatal
|
|
||||||
|
|
||||||
// GUPP: Gas Town Universal Propulsion Principle
|
|
||||||
// Send the propulsion nudge to trigger autonomous patrol execution.
|
|
||||||
// Wait for beacon to be fully processed (needs to be separate prompt)
|
|
||||||
time.Sleep(2 * time.Second)
|
|
||||||
_ = t.NudgeSession(sessionID, session.PropulsionNudgeForRole("witness", witnessDir)) // Non-fatal
|
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -236,9 +222,11 @@ func buildWitnessStartCommand(rigPath, rigName, townRoot, agentOverride string,
|
|||||||
if roleConfig != nil && roleConfig.StartCommand != "" {
|
if roleConfig != nil && roleConfig.StartCommand != "" {
|
||||||
return beads.ExpandRolePattern(roleConfig.StartCommand, townRoot, rigName, "", "witness"), nil
|
return beads.ExpandRolePattern(roleConfig.StartCommand, townRoot, rigName, "", "witness"), nil
|
||||||
}
|
}
|
||||||
// Add initial prompt for autonomous patrol startup.
|
initialPrompt := session.BuildStartupPrompt(session.BeaconConfig{
|
||||||
// The prompt triggers GUPP: witness starts patrol immediately without waiting for input.
|
Recipient: fmt.Sprintf("%s/witness", rigName),
|
||||||
initialPrompt := "I am Witness for " + rigName + ". Start patrol: check gt hook, if empty create mol-witness-patrol wisp and execute it."
|
Sender: "deacon",
|
||||||
|
Topic: "patrol",
|
||||||
|
}, "I am Witness for "+rigName+". Start patrol: check gt hook, if empty create mol-witness-patrol wisp and execute it.")
|
||||||
command, err := config.BuildAgentStartupCommandWithAgentOverride("witness", rigName, townRoot, rigPath, initialPrompt, agentOverride)
|
command, err := config.BuildAgentStartupCommandWithAgentOverride("witness", rigName, townRoot, rigPath, initialPrompt, agentOverride)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", fmt.Errorf("building startup command: %w", err)
|
return "", fmt.Errorf("building startup command: %w", err)
|
||||||
|
|||||||
Reference in New Issue
Block a user