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,
|
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 (
|
var (
|
||||||
triggerTimeout time.Duration
|
triggerTimeout time.Duration
|
||||||
|
|
||||||
@@ -220,6 +249,9 @@ var (
|
|||||||
// Stale hooks flags
|
// Stale hooks flags
|
||||||
staleHooksMaxAge time.Duration
|
staleHooksMaxAge time.Duration
|
||||||
staleHooksDryRun bool
|
staleHooksDryRun bool
|
||||||
|
|
||||||
|
// Pause flags
|
||||||
|
pauseReason string
|
||||||
)
|
)
|
||||||
|
|
||||||
func init() {
|
func init() {
|
||||||
@@ -234,6 +266,8 @@ func init() {
|
|||||||
deaconCmd.AddCommand(deaconForceKillCmd)
|
deaconCmd.AddCommand(deaconForceKillCmd)
|
||||||
deaconCmd.AddCommand(deaconHealthStateCmd)
|
deaconCmd.AddCommand(deaconHealthStateCmd)
|
||||||
deaconCmd.AddCommand(deaconStaleHooksCmd)
|
deaconCmd.AddCommand(deaconStaleHooksCmd)
|
||||||
|
deaconCmd.AddCommand(deaconPauseCmd)
|
||||||
|
deaconCmd.AddCommand(deaconResumeCmd)
|
||||||
|
|
||||||
// Flags for trigger-pending
|
// Flags for trigger-pending
|
||||||
deaconTriggerPendingCmd.Flags().DurationVar(&triggerTimeout, "timeout", 2*time.Second,
|
deaconTriggerPendingCmd.Flags().DurationVar(&triggerTimeout, "timeout", 2*time.Second,
|
||||||
@@ -259,6 +293,10 @@ func init() {
|
|||||||
deaconStaleHooksCmd.Flags().BoolVar(&staleHooksDryRun, "dry-run", false,
|
deaconStaleHooksCmd.Flags().BoolVar(&staleHooksDryRun, "dry-run", false,
|
||||||
"Preview what would be unhooked without making changes")
|
"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)")
|
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)")
|
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)")
|
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()
|
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)
|
running, err := t.HasSession(sessionName)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("checking session: %w", err)
|
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)
|
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 := ""
|
action := ""
|
||||||
if len(args) > 0 {
|
if len(args) > 0 {
|
||||||
action = strings.Join(args, " ")
|
action = strings.Join(args, " ")
|
||||||
@@ -951,3 +1019,68 @@ func runDeaconStaleHooks(cmd *cobra.Command, args []string) error {
|
|||||||
|
|
||||||
return nil
|
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/beads"
|
||||||
"github.com/steveyegge/gastown/internal/checkpoint"
|
"github.com/steveyegge/gastown/internal/checkpoint"
|
||||||
"github.com/steveyegge/gastown/internal/constants"
|
"github.com/steveyegge/gastown/internal/constants"
|
||||||
|
"github.com/steveyegge/gastown/internal/deacon"
|
||||||
"github.com/steveyegge/gastown/internal/events"
|
"github.com/steveyegge/gastown/internal/events"
|
||||||
"github.com/steveyegge/gastown/internal/lock"
|
"github.com/steveyegge/gastown/internal/lock"
|
||||||
"github.com/steveyegge/gastown/internal/rig"
|
"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 attachment found → **RUN IT** (no human input needed)")
|
||||||
fmt.Println(" - If no attachment → await user instruction")
|
fmt.Println(" - If no attachment → await user instruction")
|
||||||
case RoleDeacon:
|
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("---")
|
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 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).
|
// Deacon is a town-level role, so it uses town root beads (not rig beads).
|
||||||
func outputDeaconPatrolContext(ctx RoleContext) {
|
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{
|
cfg := PatrolConfig{
|
||||||
RoleName: "deacon",
|
RoleName: "deacon",
|
||||||
PatrolMolName: "mol-deacon-patrol",
|
PatrolMolName: "mol-deacon-patrol",
|
||||||
@@ -930,6 +943,32 @@ func outputDeaconPatrolContext(ctx RoleContext) {
|
|||||||
outputPatrolContext(cfg)
|
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.
|
// outputWitnessPatrolContext shows patrol molecule status for the Witness.
|
||||||
// Witness AUTO-BONDS its patrol molecule on startup if one isn't already running.
|
// Witness AUTO-BONDS its patrol molecule on startup if one isn't already running.
|
||||||
func outputWitnessPatrolContext(ctx RoleContext) {
|
func outputWitnessPatrolContext(ctx RoleContext) {
|
||||||
|
|||||||
88
internal/deacon/pause.go
Normal file
88
internal/deacon/pause.go
Normal file
@@ -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
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user