feat(deacon): implement bulletproof pause mechanism (gt-bpo2c) (#265)

Add multi-layer pause mechanism to prevent Deacon from causing damage:

Layer 1: File-based pause state
- Location: ~/.runtime/deacon/paused.json
- Stores: paused flag, reason, timestamp, paused_by

Layer 2: Commands
- `gt deacon pause [--reason="..."]` - pause with optional reason
- `gt deacon resume` - remove pause file
- `gt deacon status` - shows pause state prominently

Layer 3: Guards
- `gt prime` for deacon: shows PAUSED message, skips patrol context
- `gt deacon heartbeat`: fails when paused

Helper package:
- internal/deacon/pause.go with IsPaused/Pause/Resume functions

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Steve Yegge
2026-01-07 21:56:46 -08:00
committed by GitHub
parent 7f6fe53c6f
commit c4d956ebe7
3 changed files with 260 additions and 0 deletions

View File

@@ -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
}

View File

@@ -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) {