Skip to content

Commit c4d956e

Browse files
steveyeggeclaude
andauthored
feat(deacon): implement bulletproof pause mechanism (gt-bpo2c) (gastownhall#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>
1 parent 7f6fe53 commit c4d956e

3 files changed

Lines changed: 260 additions & 0 deletions

File tree

internal/cmd/deacon.go

Lines changed: 133 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -205,6 +205,35 @@ Examples:
205205
RunE: runDeaconStaleHooks,
206206
}
207207

208+
var deaconPauseCmd = &cobra.Command{
209+
Use: "pause",
210+
Short: "Pause the Deacon to prevent patrol actions",
211+
Long: `Pause the Deacon to prevent it from performing any patrol actions.
212+
213+
When paused, the Deacon:
214+
- Will not create patrol molecules
215+
- Will not run health checks
216+
- Will not take any autonomous actions
217+
- Will display a PAUSED message on startup
218+
219+
The pause state persists across session restarts. Use 'gt deacon resume'
220+
to allow the Deacon to work again.
221+
222+
Examples:
223+
gt deacon pause # Pause with no reason
224+
gt deacon pause --reason="testing" # Pause with a reason`,
225+
RunE: runDeaconPause,
226+
}
227+
228+
var deaconResumeCmd = &cobra.Command{
229+
Use: "resume",
230+
Short: "Resume the Deacon to allow patrol actions",
231+
Long: `Resume the Deacon so it can perform patrol actions again.
232+
233+
This removes the pause file and allows the Deacon to work normally.`,
234+
RunE: runDeaconResume,
235+
}
236+
208237
var (
209238
triggerTimeout time.Duration
210239

@@ -220,6 +249,9 @@ var (
220249
// Stale hooks flags
221250
staleHooksMaxAge time.Duration
222251
staleHooksDryRun bool
252+
253+
// Pause flags
254+
pauseReason string
223255
)
224256

225257
func init() {
@@ -234,6 +266,8 @@ func init() {
234266
deaconCmd.AddCommand(deaconForceKillCmd)
235267
deaconCmd.AddCommand(deaconHealthStateCmd)
236268
deaconCmd.AddCommand(deaconStaleHooksCmd)
269+
deaconCmd.AddCommand(deaconPauseCmd)
270+
deaconCmd.AddCommand(deaconResumeCmd)
237271

238272
// Flags for trigger-pending
239273
deaconTriggerPendingCmd.Flags().DurationVar(&triggerTimeout, "timeout", 2*time.Second,
@@ -259,6 +293,10 @@ func init() {
259293
deaconStaleHooksCmd.Flags().BoolVar(&staleHooksDryRun, "dry-run", false,
260294
"Preview what would be unhooked without making changes")
261295

296+
// Flags for pause
297+
deaconPauseCmd.Flags().StringVar(&pauseReason, "reason", "",
298+
"Reason for pausing the Deacon")
299+
262300
deaconStartCmd.Flags().StringVar(&deaconAgentOverride, "agent", "", "Agent alias to run the Deacon with (overrides town default)")
263301
deaconAttachCmd.Flags().StringVar(&deaconAgentOverride, "agent", "", "Agent alias to run the Deacon with (overrides town default)")
264302
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 {
418456

419457
sessionName := getDeaconSessionName()
420458

459+
// Check pause state first (most important)
460+
townRoot, _ := workspace.FindFromCwdOrError()
461+
if townRoot != "" {
462+
paused, state, err := deacon.IsPaused(townRoot)
463+
if err == nil && paused {
464+
fmt.Printf("%s DEACON PAUSED\n", style.Bold.Render("⏸️"))
465+
if state.Reason != "" {
466+
fmt.Printf(" Reason: %s\n", state.Reason)
467+
}
468+
fmt.Printf(" Paused at: %s\n", state.PausedAt.Format(time.RFC3339))
469+
fmt.Printf(" Paused by: %s\n", state.PausedBy)
470+
fmt.Println()
471+
fmt.Printf("Resume with: %s\n", style.Dim.Render("gt deacon resume"))
472+
fmt.Println()
473+
}
474+
}
475+
421476
running, err := t.HasSession(sessionName)
422477
if err != nil {
423478
return fmt.Errorf("checking session: %w", err)
@@ -487,6 +542,19 @@ func runDeaconHeartbeat(cmd *cobra.Command, args []string) error {
487542
return fmt.Errorf("not in a Gas Town workspace: %w", err)
488543
}
489544

545+
// Check if Deacon is paused - if so, refuse to update heartbeat
546+
paused, state, err := deacon.IsPaused(townRoot)
547+
if err != nil {
548+
return fmt.Errorf("checking pause state: %w", err)
549+
}
550+
if paused {
551+
fmt.Printf("%s Deacon is paused. Use 'gt deacon resume' to unpause.\n", style.Bold.Render("⏸️"))
552+
if state.Reason != "" {
553+
fmt.Printf(" Reason: %s\n", state.Reason)
554+
}
555+
return errors.New("Deacon is paused")
556+
}
557+
490558
action := ""
491559
if len(args) > 0 {
492560
action = strings.Join(args, " ")
@@ -951,3 +1019,68 @@ func runDeaconStaleHooks(cmd *cobra.Command, args []string) error {
9511019

9521020
return nil
9531021
}
1022+
1023+
// runDeaconPause pauses the Deacon to prevent patrol actions.
1024+
func runDeaconPause(cmd *cobra.Command, args []string) error {
1025+
townRoot, err := workspace.FindFromCwdOrError()
1026+
if err != nil {
1027+
return fmt.Errorf("not in a Gas Town workspace: %w", err)
1028+
}
1029+
1030+
// Check if already paused
1031+
paused, state, err := deacon.IsPaused(townRoot)
1032+
if err != nil {
1033+
return fmt.Errorf("checking pause state: %w", err)
1034+
}
1035+
if paused {
1036+
fmt.Printf("%s Deacon is already paused\n", style.Dim.Render("○"))
1037+
fmt.Printf(" Reason: %s\n", state.Reason)
1038+
fmt.Printf(" Paused at: %s\n", state.PausedAt.Format(time.RFC3339))
1039+
fmt.Printf(" Paused by: %s\n", state.PausedBy)
1040+
return nil
1041+
}
1042+
1043+
// Pause the Deacon
1044+
if err := deacon.Pause(townRoot, pauseReason, "human"); err != nil {
1045+
return fmt.Errorf("pausing Deacon: %w", err)
1046+
}
1047+
1048+
fmt.Printf("%s Deacon paused\n", style.Bold.Render("⏸️"))
1049+
if pauseReason != "" {
1050+
fmt.Printf(" Reason: %s\n", pauseReason)
1051+
}
1052+
fmt.Printf(" Pause file: %s\n", deacon.GetPauseFile(townRoot))
1053+
fmt.Println()
1054+
fmt.Printf("The Deacon will not perform any patrol actions until resumed.\n")
1055+
fmt.Printf("Resume with: %s\n", style.Dim.Render("gt deacon resume"))
1056+
1057+
return nil
1058+
}
1059+
1060+
// runDeaconResume resumes the Deacon to allow patrol actions.
1061+
func runDeaconResume(cmd *cobra.Command, args []string) error {
1062+
townRoot, err := workspace.FindFromCwdOrError()
1063+
if err != nil {
1064+
return fmt.Errorf("not in a Gas Town workspace: %w", err)
1065+
}
1066+
1067+
// Check if paused
1068+
paused, _, err := deacon.IsPaused(townRoot)
1069+
if err != nil {
1070+
return fmt.Errorf("checking pause state: %w", err)
1071+
}
1072+
if !paused {
1073+
fmt.Printf("%s Deacon is not paused\n", style.Dim.Render("○"))
1074+
return nil
1075+
}
1076+
1077+
// Resume the Deacon
1078+
if err := deacon.Resume(townRoot); err != nil {
1079+
return fmt.Errorf("resuming Deacon: %w", err)
1080+
}
1081+
1082+
fmt.Printf("%s Deacon resumed\n", style.Bold.Render("▶️"))
1083+
fmt.Println("The Deacon can now perform patrol actions.")
1084+
1085+
return nil
1086+
}

internal/cmd/prime.go

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ import (
1717
"github.com/steveyegge/gastown/internal/beads"
1818
"github.com/steveyegge/gastown/internal/checkpoint"
1919
"github.com/steveyegge/gastown/internal/constants"
20+
"github.com/steveyegge/gastown/internal/deacon"
2021
"github.com/steveyegge/gastown/internal/events"
2122
"github.com/steveyegge/gastown/internal/lock"
2223
"github.com/steveyegge/gastown/internal/rig"
@@ -601,6 +602,11 @@ func outputStartupDirective(ctx RoleContext) {
601602
fmt.Println(" - If attachment found → **RUN IT** (no human input needed)")
602603
fmt.Println(" - If no attachment → await user instruction")
603604
case RoleDeacon:
605+
// Skip startup protocol if paused - the pause message was already shown
606+
paused, _, _ := deacon.IsPaused(ctx.TownRoot)
607+
if paused {
608+
return
609+
}
604610
fmt.Println()
605611
fmt.Println("---")
606612
fmt.Println()
@@ -911,6 +917,13 @@ func showMoleculeProgress(b *beads.Beads, rootID string) {
911917
// Deacon uses wisps (Wisp:true issues in main .beads/) for patrol cycles.
912918
// Deacon is a town-level role, so it uses town root beads (not rig beads).
913919
func outputDeaconPatrolContext(ctx RoleContext) {
920+
// Check if Deacon is paused - if so, output PAUSED message and skip patrol context
921+
paused, state, err := deacon.IsPaused(ctx.TownRoot)
922+
if err == nil && paused {
923+
outputDeaconPausedMessage(state)
924+
return
925+
}
926+
914927
cfg := PatrolConfig{
915928
RoleName: "deacon",
916929
PatrolMolName: "mol-deacon-patrol",
@@ -930,6 +943,32 @@ func outputDeaconPatrolContext(ctx RoleContext) {
930943
outputPatrolContext(cfg)
931944
}
932945

946+
// outputDeaconPausedMessage outputs a prominent PAUSED message for the Deacon.
947+
// When paused, the Deacon must not perform any patrol actions.
948+
func outputDeaconPausedMessage(state *deacon.PauseState) {
949+
fmt.Println()
950+
fmt.Printf("%s\n\n", style.Bold.Render("## ⏸️ DEACON PAUSED"))
951+
fmt.Println("You are paused and must NOT perform any patrol actions.")
952+
fmt.Println()
953+
if state.Reason != "" {
954+
fmt.Printf("Reason: %s\n", state.Reason)
955+
}
956+
fmt.Printf("Paused at: %s\n", state.PausedAt.Format(time.RFC3339))
957+
if state.PausedBy != "" {
958+
fmt.Printf("Paused by: %s\n", state.PausedBy)
959+
}
960+
fmt.Println()
961+
fmt.Println("Wait for human to run `gt deacon resume` before working.")
962+
fmt.Println()
963+
fmt.Println("**DO NOT:**")
964+
fmt.Println("- Create patrol molecules")
965+
fmt.Println("- Run heartbeats")
966+
fmt.Println("- Check agent health")
967+
fmt.Println("- Take any autonomous actions")
968+
fmt.Println()
969+
fmt.Println("You may respond to direct human questions.")
970+
}
971+
933972
// outputWitnessPatrolContext shows patrol molecule status for the Witness.
934973
// Witness AUTO-BONDS its patrol molecule on startup if one isn't already running.
935974
func outputWitnessPatrolContext(ctx RoleContext) {

internal/deacon/pause.go

Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,88 @@
1+
// Package deacon provides the Deacon agent infrastructure.
2+
package deacon
3+
4+
import (
5+
"encoding/json"
6+
"os"
7+
"path/filepath"
8+
"time"
9+
)
10+
11+
// PauseState represents the Deacon pause file contents.
12+
// When paused, the Deacon must not perform any patrol actions.
13+
type PauseState struct {
14+
// Paused is true if the Deacon is currently paused.
15+
Paused bool `json:"paused"`
16+
17+
// Reason explains why the Deacon was paused.
18+
Reason string `json:"reason,omitempty"`
19+
20+
// PausedAt is when the Deacon was paused.
21+
PausedAt time.Time `json:"paused_at"`
22+
23+
// PausedBy identifies who paused the Deacon (e.g., "human", "mayor").
24+
PausedBy string `json:"paused_by,omitempty"`
25+
}
26+
27+
// GetPauseFile returns the path to the Deacon pause file.
28+
func GetPauseFile(townRoot string) string {
29+
return filepath.Join(townRoot, ".runtime", "deacon", "paused.json")
30+
}
31+
32+
// IsPaused checks if the Deacon is currently paused.
33+
// Returns (isPaused, pauseState, error).
34+
// If the pause file doesn't exist, returns (false, nil, nil).
35+
func IsPaused(townRoot string) (bool, *PauseState, error) {
36+
pauseFile := GetPauseFile(townRoot)
37+
38+
data, err := os.ReadFile(pauseFile) //nolint:gosec // G304: path is constructed from trusted townRoot
39+
if err != nil {
40+
if os.IsNotExist(err) {
41+
return false, nil, nil
42+
}
43+
return false, nil, err
44+
}
45+
46+
var state PauseState
47+
if err := json.Unmarshal(data, &state); err != nil {
48+
return false, nil, err
49+
}
50+
51+
return state.Paused, &state, nil
52+
}
53+
54+
// Pause pauses the Deacon by creating the pause file.
55+
func Pause(townRoot, reason, pausedBy string) error {
56+
pauseFile := GetPauseFile(townRoot)
57+
58+
// Ensure parent directory exists
59+
if err := os.MkdirAll(filepath.Dir(pauseFile), 0755); err != nil {
60+
return err
61+
}
62+
63+
state := PauseState{
64+
Paused: true,
65+
Reason: reason,
66+
PausedAt: time.Now().UTC(),
67+
PausedBy: pausedBy,
68+
}
69+
70+
data, err := json.MarshalIndent(state, "", " ")
71+
if err != nil {
72+
return err
73+
}
74+
75+
return os.WriteFile(pauseFile, data, 0600)
76+
}
77+
78+
// Resume resumes the Deacon by removing the pause file.
79+
func Resume(townRoot string) error {
80+
pauseFile := GetPauseFile(townRoot)
81+
82+
err := os.Remove(pauseFile)
83+
if err != nil && !os.IsNotExist(err) {
84+
return err
85+
}
86+
87+
return nil
88+
}

0 commit comments

Comments
 (0)