diff --git a/internal/cmd/deacon.go b/internal/cmd/deacon.go index 9910d2ea..6731da19 100644 --- a/internal/cmd/deacon.go +++ b/internal/cmd/deacon.go @@ -205,6 +205,35 @@ Examples: RunE: runDeaconStaleHooks, } +var deaconPauseCmd = &cobra.Command{ + Use: "pause", + Short: "Pause the Deacon to prevent patrol actions", + Long: `Pause the Deacon to prevent it from performing any patrol actions. + +When paused, the Deacon: +- Will not create patrol molecules +- Will not run health checks +- Will not take any autonomous actions +- Will display a PAUSED message on startup + +The pause state persists across session restarts. Use 'gt deacon resume' +to allow the Deacon to work again. + +Examples: + gt deacon pause # Pause with no reason + gt deacon pause --reason="testing" # Pause with a reason`, + RunE: runDeaconPause, +} + +var deaconResumeCmd = &cobra.Command{ + Use: "resume", + Short: "Resume the Deacon to allow patrol actions", + Long: `Resume the Deacon so it can perform patrol actions again. + +This removes the pause file and allows the Deacon to work normally.`, + RunE: runDeaconResume, +} + var ( triggerTimeout time.Duration @@ -220,6 +249,9 @@ var ( // Stale hooks flags staleHooksMaxAge time.Duration staleHooksDryRun bool + + // Pause flags + pauseReason string ) func init() { @@ -234,6 +266,8 @@ func init() { deaconCmd.AddCommand(deaconForceKillCmd) deaconCmd.AddCommand(deaconHealthStateCmd) deaconCmd.AddCommand(deaconStaleHooksCmd) + deaconCmd.AddCommand(deaconPauseCmd) + deaconCmd.AddCommand(deaconResumeCmd) // Flags for trigger-pending deaconTriggerPendingCmd.Flags().DurationVar(&triggerTimeout, "timeout", 2*time.Second, @@ -259,6 +293,10 @@ func init() { deaconStaleHooksCmd.Flags().BoolVar(&staleHooksDryRun, "dry-run", false, "Preview what would be unhooked without making changes") + // Flags for pause + deaconPauseCmd.Flags().StringVar(&pauseReason, "reason", "", + "Reason for pausing the Deacon") + deaconStartCmd.Flags().StringVar(&deaconAgentOverride, "agent", "", "Agent alias to run the Deacon with (overrides town default)") deaconAttachCmd.Flags().StringVar(&deaconAgentOverride, "agent", "", "Agent alias to run the Deacon with (overrides town default)") deaconRestartCmd.Flags().StringVar(&deaconAgentOverride, "agent", "", "Agent alias to run the Deacon with (overrides town default)") @@ -418,6 +456,23 @@ func runDeaconStatus(cmd *cobra.Command, args []string) error { sessionName := getDeaconSessionName() + // Check pause state first (most important) + townRoot, _ := workspace.FindFromCwdOrError() + if townRoot != "" { + paused, state, err := deacon.IsPaused(townRoot) + if err == nil && paused { + fmt.Printf("%s DEACON PAUSED\n", style.Bold.Render("⏸️")) + if state.Reason != "" { + fmt.Printf(" Reason: %s\n", state.Reason) + } + fmt.Printf(" Paused at: %s\n", state.PausedAt.Format(time.RFC3339)) + fmt.Printf(" Paused by: %s\n", state.PausedBy) + fmt.Println() + fmt.Printf("Resume with: %s\n", style.Dim.Render("gt deacon resume")) + fmt.Println() + } + } + running, err := t.HasSession(sessionName) if err != nil { return fmt.Errorf("checking session: %w", err) @@ -487,6 +542,19 @@ func runDeaconHeartbeat(cmd *cobra.Command, args []string) error { return fmt.Errorf("not in a Gas Town workspace: %w", err) } + // Check if Deacon is paused - if so, refuse to update heartbeat + paused, state, err := deacon.IsPaused(townRoot) + if err != nil { + return fmt.Errorf("checking pause state: %w", err) + } + if paused { + fmt.Printf("%s Deacon is paused. Use 'gt deacon resume' to unpause.\n", style.Bold.Render("⏸️")) + if state.Reason != "" { + fmt.Printf(" Reason: %s\n", state.Reason) + } + return errors.New("Deacon is paused") + } + action := "" if len(args) > 0 { action = strings.Join(args, " ") @@ -951,3 +1019,68 @@ func runDeaconStaleHooks(cmd *cobra.Command, args []string) error { return nil } + +// runDeaconPause pauses the Deacon to prevent patrol actions. +func runDeaconPause(cmd *cobra.Command, args []string) error { + townRoot, err := workspace.FindFromCwdOrError() + if err != nil { + return fmt.Errorf("not in a Gas Town workspace: %w", err) + } + + // Check if already paused + paused, state, err := deacon.IsPaused(townRoot) + if err != nil { + return fmt.Errorf("checking pause state: %w", err) + } + if paused { + fmt.Printf("%s Deacon is already paused\n", style.Dim.Render("○")) + fmt.Printf(" Reason: %s\n", state.Reason) + fmt.Printf(" Paused at: %s\n", state.PausedAt.Format(time.RFC3339)) + fmt.Printf(" Paused by: %s\n", state.PausedBy) + return nil + } + + // Pause the Deacon + if err := deacon.Pause(townRoot, pauseReason, "human"); err != nil { + return fmt.Errorf("pausing Deacon: %w", err) + } + + fmt.Printf("%s Deacon paused\n", style.Bold.Render("⏸️")) + if pauseReason != "" { + fmt.Printf(" Reason: %s\n", pauseReason) + } + fmt.Printf(" Pause file: %s\n", deacon.GetPauseFile(townRoot)) + fmt.Println() + fmt.Printf("The Deacon will not perform any patrol actions until resumed.\n") + fmt.Printf("Resume with: %s\n", style.Dim.Render("gt deacon resume")) + + return nil +} + +// runDeaconResume resumes the Deacon to allow patrol actions. +func runDeaconResume(cmd *cobra.Command, args []string) error { + townRoot, err := workspace.FindFromCwdOrError() + if err != nil { + return fmt.Errorf("not in a Gas Town workspace: %w", err) + } + + // Check if paused + paused, _, err := deacon.IsPaused(townRoot) + if err != nil { + return fmt.Errorf("checking pause state: %w", err) + } + if !paused { + fmt.Printf("%s Deacon is not paused\n", style.Dim.Render("○")) + return nil + } + + // Resume the Deacon + if err := deacon.Resume(townRoot); err != nil { + return fmt.Errorf("resuming Deacon: %w", err) + } + + fmt.Printf("%s Deacon resumed\n", style.Bold.Render("▶️")) + fmt.Println("The Deacon can now perform patrol actions.") + + return nil +} diff --git a/internal/cmd/prime.go b/internal/cmd/prime.go index 40105fbf..976b8609 100644 --- a/internal/cmd/prime.go +++ b/internal/cmd/prime.go @@ -17,6 +17,7 @@ import ( "github.com/steveyegge/gastown/internal/beads" "github.com/steveyegge/gastown/internal/checkpoint" "github.com/steveyegge/gastown/internal/constants" + "github.com/steveyegge/gastown/internal/deacon" "github.com/steveyegge/gastown/internal/events" "github.com/steveyegge/gastown/internal/lock" "github.com/steveyegge/gastown/internal/rig" @@ -601,6 +602,11 @@ func outputStartupDirective(ctx RoleContext) { fmt.Println(" - If attachment found → **RUN IT** (no human input needed)") fmt.Println(" - If no attachment → await user instruction") case RoleDeacon: + // Skip startup protocol if paused - the pause message was already shown + paused, _, _ := deacon.IsPaused(ctx.TownRoot) + if paused { + return + } fmt.Println() fmt.Println("---") fmt.Println() @@ -911,6 +917,13 @@ func showMoleculeProgress(b *beads.Beads, rootID string) { // Deacon uses wisps (Wisp:true issues in main .beads/) for patrol cycles. // Deacon is a town-level role, so it uses town root beads (not rig beads). func outputDeaconPatrolContext(ctx RoleContext) { + // Check if Deacon is paused - if so, output PAUSED message and skip patrol context + paused, state, err := deacon.IsPaused(ctx.TownRoot) + if err == nil && paused { + outputDeaconPausedMessage(state) + return + } + cfg := PatrolConfig{ RoleName: "deacon", PatrolMolName: "mol-deacon-patrol", @@ -930,6 +943,32 @@ func outputDeaconPatrolContext(ctx RoleContext) { outputPatrolContext(cfg) } +// outputDeaconPausedMessage outputs a prominent PAUSED message for the Deacon. +// When paused, the Deacon must not perform any patrol actions. +func outputDeaconPausedMessage(state *deacon.PauseState) { + fmt.Println() + fmt.Printf("%s\n\n", style.Bold.Render("## ⏸️ DEACON PAUSED")) + fmt.Println("You are paused and must NOT perform any patrol actions.") + fmt.Println() + if state.Reason != "" { + fmt.Printf("Reason: %s\n", state.Reason) + } + fmt.Printf("Paused at: %s\n", state.PausedAt.Format(time.RFC3339)) + if state.PausedBy != "" { + fmt.Printf("Paused by: %s\n", state.PausedBy) + } + fmt.Println() + fmt.Println("Wait for human to run `gt deacon resume` before working.") + fmt.Println() + fmt.Println("**DO NOT:**") + fmt.Println("- Create patrol molecules") + fmt.Println("- Run heartbeats") + fmt.Println("- Check agent health") + fmt.Println("- Take any autonomous actions") + fmt.Println() + fmt.Println("You may respond to direct human questions.") +} + // outputWitnessPatrolContext shows patrol molecule status for the Witness. // Witness AUTO-BONDS its patrol molecule on startup if one isn't already running. func outputWitnessPatrolContext(ctx RoleContext) { diff --git a/internal/deacon/pause.go b/internal/deacon/pause.go new file mode 100644 index 00000000..34d5f8f1 --- /dev/null +++ b/internal/deacon/pause.go @@ -0,0 +1,88 @@ +// Package deacon provides the Deacon agent infrastructure. +package deacon + +import ( + "encoding/json" + "os" + "path/filepath" + "time" +) + +// PauseState represents the Deacon pause file contents. +// When paused, the Deacon must not perform any patrol actions. +type PauseState struct { + // Paused is true if the Deacon is currently paused. + Paused bool `json:"paused"` + + // Reason explains why the Deacon was paused. + Reason string `json:"reason,omitempty"` + + // PausedAt is when the Deacon was paused. + PausedAt time.Time `json:"paused_at"` + + // PausedBy identifies who paused the Deacon (e.g., "human", "mayor"). + PausedBy string `json:"paused_by,omitempty"` +} + +// GetPauseFile returns the path to the Deacon pause file. +func GetPauseFile(townRoot string) string { + return filepath.Join(townRoot, ".runtime", "deacon", "paused.json") +} + +// IsPaused checks if the Deacon is currently paused. +// Returns (isPaused, pauseState, error). +// If the pause file doesn't exist, returns (false, nil, nil). +func IsPaused(townRoot string) (bool, *PauseState, error) { + pauseFile := GetPauseFile(townRoot) + + data, err := os.ReadFile(pauseFile) //nolint:gosec // G304: path is constructed from trusted townRoot + if err != nil { + if os.IsNotExist(err) { + return false, nil, nil + } + return false, nil, err + } + + var state PauseState + if err := json.Unmarshal(data, &state); err != nil { + return false, nil, err + } + + return state.Paused, &state, nil +} + +// Pause pauses the Deacon by creating the pause file. +func Pause(townRoot, reason, pausedBy string) error { + pauseFile := GetPauseFile(townRoot) + + // Ensure parent directory exists + if err := os.MkdirAll(filepath.Dir(pauseFile), 0755); err != nil { + return err + } + + state := PauseState{ + Paused: true, + Reason: reason, + PausedAt: time.Now().UTC(), + PausedBy: pausedBy, + } + + data, err := json.MarshalIndent(state, "", " ") + if err != nil { + return err + } + + return os.WriteFile(pauseFile, data, 0600) +} + +// Resume resumes the Deacon by removing the pause file. +func Resume(townRoot string) error { + pauseFile := GetPauseFile(townRoot) + + err := os.Remove(pauseFile) + if err != nil && !os.IsNotExist(err) { + return err + } + + return nil +}