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:
@@ -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
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user