From 63471f83a0dfbaff617d97b84cbcf1b76df155dc Mon Sep 17 00:00:00 2001 From: Julian <62840881+JulianSarkinovic@users.noreply.github.com> Date: Fri, 12 Jun 2026 11:47:27 +0200 Subject: [PATCH 1/3] Add vacation mode --- .toad.yaml.example | 4 +++ SETUP.md | 20 +++++++++++ cmd/handlers.go | 31 +++++++++++++++++ cmd/handlers_test.go | 30 +++++++++++++++++ cmd/root.go | 10 ++++-- internal/config/config.go | 4 +++ internal/config/config_test.go | 33 ++++++++++++++++++ internal/state/db.go | 61 ++++++++++++++++++++++++++++++++++ internal/state/db_test.go | 37 +++++++++++++++++++++ internal/state/state.go | 9 +++++ internal/state/state_test.go | 15 +++++++++ 11 files changed, 252 insertions(+), 2 deletions(-) create mode 100644 cmd/handlers_test.go diff --git a/.toad.yaml.example b/.toad.yaml.example index b51d731..225172c 100644 --- a/.toad.yaml.example +++ b/.toad.yaml.example @@ -72,3 +72,7 @@ issue_tracker: log: level: "info" # debug, info, warn, error # file: "~/.toad/toad.log" + +# Vacation mode: disable all autonomous behavior (passive monitoring, Toad King, +# PR watching). Direct interactions get a polite decline and are saved. +# vacation_mode: false diff --git a/SETUP.md b/SETUP.md index f13bfd7..a3bfd33 100644 --- a/SETUP.md +++ b/SETUP.md @@ -24,6 +24,7 @@ The complete guide to installing, configuring, and running toad — from zero to - [log](#log) - [mcp](#mcp) - [personality](#personality) + - [vacation_mode](#vacation_mode) - [Environment Variables](#environment-variables) - [CLI Commands](#cli-commands) - [Interacting with Toad](#interacting-with-toad) @@ -636,6 +637,20 @@ personality: The radar chart in `toad status` and the kiosk view visualize current trait values. +### `vacation_mode` + +Turn off all autonomous behavior without uninstalling toad. + +```yaml +vacation_mode: true +``` + +| Option | Type | Default | Description | +|--------|------|---------|-------------| +| `vacation_mode` | bool | `false` | Disable passive monitoring, digest (Toad King), and PR watching | + +When enabled, toad stays connected to Slack but does nothing on its own: no message triage, no digest analysis, no PR review or CI fix tadpoles. Anyone who directly addresses toad (an @mention, keyword or emoji trigger, or button click) gets a polite decline, and their message is saved to the `vacation_messages` table in the state database for review when toad returns. + --- ## Environment Variables @@ -648,6 +663,7 @@ Environment variables override config file values. | `TOAD_SLACK_BOT_TOKEN` | `slack.bot_token` | Slack bot OAuth token (`xoxb-...`) | | `TOAD_LINEAR_API_TOKEN` | `issue_tracker.api_token` | Linear API token for issue tracking | | `TOAD_GITLAB_HOST` | `vcs.host` | Self-hosted GitLab hostname | +| `TOAD_VACATION_MODE` | `vacation_mode` | Set to `1` or `true` to enable vacation mode | | `SUPERVISED` | — | Set to `1` when running under a process supervisor (see [Running Under a Process Supervisor](#running-under-a-process-supervisor)) | You can also use `${ENV_VAR}` syntax in YAML values to reference environment variables: @@ -1178,4 +1194,8 @@ personality: enabled: false # opt-in adaptive personality learning_enabled: true # allow traits to adapt file_path: ~/.toad/personality.yaml # personality state file + +# Vacation mode: disable all autonomous behavior. Direct interactions get a +# polite decline and the message is saved for later review. +vacation_mode: false ``` diff --git a/cmd/handlers.go b/cmd/handlers.go index 7966dfc..39baefa 100644 --- a/cmd/handlers.go +++ b/cmd/handlers.go @@ -37,6 +37,11 @@ func handleMessage( // Resolve channel name for context channelName := slackClient.ResolveChannelName(msg.Channel) + if cfg.VacationMode { + handleVacationMode(msg, slackClient, stateManager, channelName) + return + } + // TADPOLE REQUEST: :frog: reaction on a toad reply // Must be checked BEFORE the bot filter — tadpole requests are reactions on // toad's own (bot) messages, so the fetched message will have IsBot=true. @@ -586,3 +591,29 @@ func handleTadpoleRequest( // Don't unclaim on defer. claimed = false } + +const vacationReply = ":frog: Sorry I am not allowed to do things anymore, because I'm an idiot :(. But I have saved your message! If there is something else you want me to save, e.g. ideas on how to improve me, let me know." + +const vacationReplySaveFailed = ":frog: Sorry I am not allowed to do things anymore, because I'm an idiot :(. I tried to save your message but even that failed — please tell a human." + +func handleVacationMode(msg *islack.IncomingMessage, slackClient *islack.Client, stateManager *state.Manager, channelName string) { + if !isDirectInteraction(msg) { + return + } + + slog.Info("vacation mode: declining interaction", "channel", channelName, "user", msg.User) + + reply := vacationReply + if err := stateManager.SaveVacationMessage(msg.Channel, channelName, msg.User, msg.Text, msg.ThreadTS()); err != nil { + slog.Warn("vacation mode: failed to save message", "error", err) + reply = vacationReplySaveFailed + } + slackClient.ReplyInThread(msg.Channel, msg.ThreadTS(), reply) +} + +func isDirectInteraction(msg *islack.IncomingMessage) bool { + if msg.IsTadpoleRequest { + return true + } + return (msg.IsMention || msg.IsTriggered) && !msg.IsBot +} diff --git a/cmd/handlers_test.go b/cmd/handlers_test.go new file mode 100644 index 0000000..c565a3f --- /dev/null +++ b/cmd/handlers_test.go @@ -0,0 +1,30 @@ +package cmd + +import ( + "testing" + + islack "github.com/scaler-tech/toad/internal/slack" +) + +func TestIsDirectInteraction(t *testing.T) { + tests := []struct { + name string + msg *islack.IncomingMessage + want bool + }{ + {"mention", &islack.IncomingMessage{IsMention: true, IsTriggered: true}, true}, + {"keyword trigger", &islack.IncomingMessage{IsTriggered: true}, true}, + {"tadpole request on bot message", &islack.IncomingMessage{IsTadpoleRequest: true, IsTriggered: true, IsBot: true}, true}, + {"bot mention", &islack.IncomingMessage{IsMention: true, IsTriggered: true, IsBot: true}, false}, + {"plain message", &islack.IncomingMessage{}, false}, + {"bot message", &islack.IncomingMessage{IsBot: true}, false}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := isDirectInteraction(tt.msg); got != tt.want { + t.Errorf("isDirectInteraction() = %v, want %v", got, tt.want) + } + }) + } +} diff --git a/cmd/root.go b/cmd/root.go index 12feb1e..3b09c37 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -246,7 +246,7 @@ func runDaemon(cmd *cobra.Command, args []string) error { // Initialize digest engine (Toad King) if enabled var digestEngine *digest.Engine - if cfg.Digest.Enabled { + if cfg.Digest.Enabled && !cfg.VacationMode { digestEngine = digest.New(&cfg.Digest, digest.EngineOpts{ AgentProvider: agentProvider, TriageModel: cfg.Triage.Model, @@ -425,6 +425,10 @@ func runDaemon(cmd *cobra.Command, args []string) error { "triggers", fmt.Sprintf("emoji=%s keywords=%v", cfg.Slack.Triggers.Emoji, cfg.Slack.Triggers.Keywords), ) + if cfg.VacationMode { + slog.Warn("vacation mode enabled — passive monitoring, digest, and PR watching are disabled; direct interactions get a decline reply") + } + // Start MCP server if enabled if mcpSrv != nil { mcpSrv.Health().Version = Version @@ -436,7 +440,9 @@ func runDaemon(cmd *cobra.Command, args []string) error { } // Start PR review watcher - go prWatcher.Run(ctx) + if !cfg.VacationMode { + go prWatcher.Run(ctx) + } // Start periodic repo sync if enabled if cfg.Repos.SyncMinutes > 0 { diff --git a/internal/config/config.go b/internal/config/config.go index 8b5e5a1..58d9976 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -24,6 +24,7 @@ type Config struct { Log LogConfig `yaml:"log"` MCP MCPConfig `yaml:"mcp"` Personality PersonalityConfig `yaml:"personality"` + VacationMode bool `yaml:"vacation_mode"` } type SlackConfig struct { @@ -284,6 +285,9 @@ func applyEnv(cfg *Config) { if v := os.Getenv("TOAD_LOG_LEVEL"); v != "" { cfg.Log.Level = v } + if v := os.Getenv("TOAD_VACATION_MODE"); v == "1" || strings.EqualFold(v, "true") { + cfg.VacationMode = true + } } // Validate checks that required configuration is present. diff --git a/internal/config/config_test.go b/internal/config/config_test.go index 568065a..7e7fb9a 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -2,6 +2,7 @@ package config import ( "os" + "path/filepath" "testing" ) @@ -359,3 +360,35 @@ func TestResolvedVCS_FullOverride(t *testing.T) { t.Errorf("expected [renovate], got %v", got.BotUsernames) } } + +func TestApplyEnv_VacationMode(t *testing.T) { + cfg := defaults() + + os.Setenv("TOAD_VACATION_MODE", "true") + defer os.Unsetenv("TOAD_VACATION_MODE") + + applyEnv(cfg) + + if !cfg.VacationMode { + t.Error("expected vacation mode from env") + } +} + +func TestVacationModeYAML(t *testing.T) { + cfg := defaults() + + path := filepath.Join(t.TempDir(), "config.yaml") + if err := os.WriteFile(path, []byte("vacation_mode: true\n"), 0o644); err != nil { + t.Fatalf("writing config: %v", err) + } + if err := loadFile(cfg, path); err != nil { + t.Fatalf("loading config: %v", err) + } + + if !cfg.VacationMode { + t.Error("expected vacation_mode true from yaml") + } + if cfg.Slack.Triggers.Emoji != "frog" { + t.Errorf("defaults should be preserved, got emoji %q", cfg.Slack.Triggers.Emoji) + } +} diff --git a/internal/state/db.go b/internal/state/db.go index 7b718e2..656e0a8 100644 --- a/internal/state/db.go +++ b/internal/state/db.go @@ -184,6 +184,19 @@ func migrate(db *sql.DB) error { return fmt.Errorf("creating github_slack_mappings table: %w", err) } + _, err = db.Exec(`CREATE TABLE IF NOT EXISTS vacation_messages ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + channel TEXT NOT NULL, + channel_name TEXT DEFAULT '', + user TEXT DEFAULT '', + text TEXT NOT NULL, + thread_ts TEXT DEFAULT '', + created_at DATETIME NOT NULL + )`) + if err != nil { + return fmt.Errorf("creating vacation_messages table: %w", err) + } + _, err = db.Exec(`CREATE TABLE IF NOT EXISTS personality_adjustments ( id INTEGER PRIMARY KEY, trait TEXT NOT NULL, @@ -933,6 +946,43 @@ func (d *DB) SetSetting(key, value string) error { return err } +// SaveVacationMessage stores a message received while in vacation mode. +func (d *DB) SaveVacationMessage(channel, channelName, user, text, threadTS string) error { + return dbRetry(func() error { + ctx, cancel := dbCtx() + defer cancel() + _, err := d.db.ExecContext(ctx, ` + INSERT INTO vacation_messages (channel, channel_name, user, text, thread_ts, created_at) + VALUES (?, ?, ?, ?, ?, ?)`, + channel, channelName, user, text, threadTS, time.Now(), + ) + return err + }) +} + +// VacationMessages returns the most recent messages saved during vacation mode. +func (d *DB) VacationMessages(limit int) ([]*VacationMessage, error) { + ctx, cancel := dbCtx() + defer cancel() + rows, err := d.db.QueryContext(ctx, ` + SELECT id, channel, channel_name, user, text, thread_ts, created_at + FROM vacation_messages ORDER BY id DESC LIMIT ?`, limit) + if err != nil { + return nil, err + } + defer rows.Close() + + var msgs []*VacationMessage + for rows.Next() { + m := &VacationMessage{} + if err := rows.Scan(&m.ID, &m.Channel, &m.ChannelName, &m.User, &m.Text, &m.ThreadTS, &m.CreatedAt); err != nil { + return nil, err + } + msgs = append(msgs, m) + } + return msgs, rows.Err() +} + // ClearDaemonStats removes daemon stats (called on clean shutdown). func (d *DB) ClearDaemonStats() error { ctx, cancel := dbCtx() @@ -1136,6 +1186,17 @@ type ThreadMemory struct { // ThreadMemoryTTL is how long thread memories are kept. const ThreadMemoryTTL = 24 * time.Hour +// VacationMessage is a message saved while toad was in vacation mode. +type VacationMessage struct { + ID int64 + Channel string + ChannelName string + User string + Text string + ThreadTS string + CreatedAt time.Time +} + func scanRun(row *sql.Row) (*Run, error) { var run Run var resultJSON sql.NullString diff --git a/internal/state/db_test.go b/internal/state/db_test.go index a06401a..7929098 100644 --- a/internal/state/db_test.go +++ b/internal/state/db_test.go @@ -1184,3 +1184,40 @@ func TestDB_MergeStats(t *testing.T) { t.Errorf("expected merge rate ~50%%, got %.1f%%", rate) } } + +func TestDB_VacationMessages(t *testing.T) { + db := openTestDB(t) + + if err := db.SaveVacationMessage("C123", "general", "U1", "first message", "111.222"); err != nil { + t.Fatalf("save: %v", err) + } + if err := db.SaveVacationMessage("C456", "dev", "U2", "second message", ""); err != nil { + t.Fatalf("save: %v", err) + } + + msgs, err := db.VacationMessages(10) + if err != nil { + t.Fatalf("list: %v", err) + } + if len(msgs) != 2 { + t.Fatalf("got %d messages, want 2", len(msgs)) + } + if msgs[0].Text != "second message" { + t.Errorf("expected newest first, got %q", msgs[0].Text) + } + got := msgs[1] + if got.Channel != "C123" || got.ChannelName != "general" || got.User != "U1" || got.ThreadTS != "111.222" { + t.Errorf("unexpected fields: %+v", got) + } + if got.CreatedAt.IsZero() { + t.Error("expected created_at to be set") + } + + msgs, err = db.VacationMessages(1) + if err != nil { + t.Fatalf("list with limit: %v", err) + } + if len(msgs) != 1 { + t.Fatalf("got %d messages, want 1", len(msgs)) + } +} diff --git a/internal/state/state.go b/internal/state/state.go index 0478a3f..d6b98a0 100644 --- a/internal/state/state.go +++ b/internal/state/state.go @@ -2,6 +2,7 @@ package state import ( + "errors" "log/slog" "sync" "time" @@ -94,6 +95,14 @@ func (m *Manager) DB() *DB { return m.db } +// SaveVacationMessage persists a message received while in vacation mode. +func (m *Manager) SaveVacationMessage(channel, channelName, user, text, threadTS string) error { + if m.db == nil { + return errors.New("no persistent state") + } + return m.db.SaveVacationMessage(channel, channelName, user, text, threadTS) +} + // ClaimScoped atomically checks if a thread+scope is already tracked and registers it if not. // Scope "" is exclusive: fails if ANY claim exists, and blocks all other claims. // Non-empty scopes coexist with each other but not with exclusive claims. diff --git a/internal/state/state_test.go b/internal/state/state_test.go index a7a853b..afd609a 100644 --- a/internal/state/state_test.go +++ b/internal/state/state_test.go @@ -362,3 +362,18 @@ func TestGetByThread_ReturnsMultiple(t *testing.T) { t.Errorf("expected run-1 and run-2, got %v", ids) } } + +func TestManager_SaveVacationMessage(t *testing.T) { + m := NewManager() + if err := m.SaveVacationMessage("C1", "general", "U1", "hello", ""); err == nil { + t.Error("expected error from in-memory manager") + } + + pm, err := NewPersistentManager(openTestDB(t), 0) + if err != nil { + t.Fatalf("creating manager: %v", err) + } + if err := pm.SaveVacationMessage("C1", "general", "U1", "hello", ""); err != nil { + t.Errorf("save via persistent manager: %v", err) + } +} From 4a8b72e08e7b359da995d2566270471e4dc71173 Mon Sep 17 00:00:00 2001 From: Julian <62840881+JulianSarkinovic@users.noreply.github.com> Date: Fri, 12 Jun 2026 12:05:15 +0200 Subject: [PATCH 2/3] Add Slack toggle for vacation mode --- .toad.yaml.example | 4 ++- SETUP.md | 19 +++++++--- cmd/handlers.go | 58 +++++++++++++++++++++++++++++- cmd/handlers_test.go | 45 +++++++++++++++++++++++ cmd/root.go | 19 ++++++---- internal/config/config.go | 27 +++++++------- internal/digest/digest.go | 10 ++++++ internal/reviewer/reviewer.go | 9 +++++ internal/state/db.go | 4 +-- internal/state/state.go | 4 ++- internal/state/vacation.go | 63 +++++++++++++++++++++++++++++++++ internal/state/vacation_test.go | 62 ++++++++++++++++++++++++++++++++ 12 files changed, 295 insertions(+), 29 deletions(-) create mode 100644 internal/state/vacation.go create mode 100644 internal/state/vacation_test.go diff --git a/.toad.yaml.example b/.toad.yaml.example index 225172c..22d53ec 100644 --- a/.toad.yaml.example +++ b/.toad.yaml.example @@ -75,4 +75,6 @@ log: # Vacation mode: disable all autonomous behavior (passive monitoring, Toad King, # PR watching). Direct interactions get a polite decline and are saved. -# vacation_mode: false +# Toggle from Slack: "@toad go on vacation" / "@toad back from vacation". +# vacation_mode: false # force vacation on (locks out the Slack toggle) +# vacation_admins: [] # Slack user IDs allowed to toggle; empty = anyone diff --git a/SETUP.md b/SETUP.md index a3bfd33..4c8fe48 100644 --- a/SETUP.md +++ b/SETUP.md @@ -642,14 +642,23 @@ The radar chart in `toad status` and the kiosk view visualize current trait valu Turn off all autonomous behavior without uninstalling toad. ```yaml -vacation_mode: true +vacation_mode: false +vacation_admins: [] ``` | Option | Type | Default | Description | |--------|------|---------|-------------| -| `vacation_mode` | bool | `false` | Disable passive monitoring, digest (Toad King), and PR watching | +| `vacation_mode` | bool | `false` | Force vacation mode on; it cannot be disabled from Slack while set | +| `vacation_admins` | list | `[]` | Slack user IDs allowed to toggle vacation from Slack; empty = anyone | + +When on vacation, toad stays connected to Slack but does nothing on its own: no message triage, no digest analysis, no PR review or CI fix tadpoles. Anyone who directly addresses toad (an @mention, keyword or emoji trigger, or button click) gets a polite decline, and their message is saved to the `vacation_messages` table in the state database for review when toad returns. + +Vacation can also be toggled at runtime from Slack, no restart needed: + +- A mention containing **"go on vacation"** or **"vacation time"** starts the vacation +- A mention containing **"back from vacation"** or **"vacation is over"** ends it -When enabled, toad stays connected to Slack but does nothing on its own: no message triage, no digest analysis, no PR review or CI fix tadpoles. Anyone who directly addresses toad (an @mention, keyword or emoji trigger, or button click) gets a polite decline, and their message is saved to the `vacation_messages` table in the state database for review when toad returns. +The runtime state is persisted in the state database and survives restarts. The `vacation_mode` config flag forces vacation on; while set, the Slack toggle cannot end it. --- @@ -1196,6 +1205,8 @@ personality: file_path: ~/.toad/personality.yaml # personality state file # Vacation mode: disable all autonomous behavior. Direct interactions get a -# polite decline and the message is saved for later review. +# polite decline and the message is saved for later review. Toggle from Slack +# with "@toad go on vacation" / "@toad back from vacation". vacation_mode: false +vacation_admins: [] # Slack user IDs allowed to toggle; empty = anyone ``` diff --git a/cmd/handlers.go b/cmd/handlers.go index 39baefa..e7e8c96 100644 --- a/cmd/handlers.go +++ b/cmd/handlers.go @@ -4,6 +4,7 @@ import ( "context" "fmt" "log/slog" + "slices" "strings" "time" @@ -27,6 +28,7 @@ func handleMessage( ribbitEngine *ribbit.Engine, slackClient *islack.Client, stateManager *state.Manager, + vacationState *state.VacationState, ribbitSem chan struct{}, tadpolePool *tadpole.Pool, digestEngine *digest.Engine, @@ -37,7 +39,18 @@ func handleMessage( // Resolve channel name for context channelName := slackClient.ResolveChannelName(msg.Channel) - if cfg.VacationMode { + if msg.IsMention && !msg.IsBot && canToggleVacation(cfg.VacationAdmins, msg.User) { + if vacationState.Active() && isVacationEndPhrase(msg.Text) { + handleVacationEnd(msg, slackClient, vacationState, channelName) + return + } + if !vacationState.Active() && isVacationStartPhrase(msg.Text) { + handleVacationStart(msg, slackClient, vacationState, channelName) + return + } + } + + if vacationState.Active() { handleVacationMode(msg, slackClient, stateManager, channelName) return } @@ -596,6 +609,49 @@ const vacationReply = ":frog: Sorry I am not allowed to do things anymore, becau const vacationReplySaveFailed = ":frog: Sorry I am not allowed to do things anymore, because I'm an idiot :(. I tried to save your message but even that failed — please tell a human." +const vacationStartReply = ":frog: :palm_tree: Thanks! I hop away to rest a spell,\nto come back energized and well,\nand when I leap back to my pond again,\nI'll bless you with my findings then! :sparkles:" + +const vacationEndReply = ":frog: I'm back! Rested, energized, and ready to bless you with findings again. :sparkles:" + +const vacationLockedReply = ":frog: My vacation is locked on by config (`vacation_mode: true`), so I can't come back until that changes." + +func handleVacationStart(msg *islack.IncomingMessage, slackClient *islack.Client, vacationState *state.VacationState, channelName string) { + slog.Info("vacation mode enabled via slack", "channel", channelName, "user", msg.User) + if err := vacationState.Set(true); err != nil { + slog.Warn("failed to persist vacation state", "error", err) + } + slackClient.ReplyInThread(msg.Channel, msg.ThreadTS(), vacationStartReply) +} + +func handleVacationEnd(msg *islack.IncomingMessage, slackClient *islack.Client, vacationState *state.VacationState, channelName string) { + if vacationState.Forced() { + slackClient.ReplyInThread(msg.Channel, msg.ThreadTS(), vacationLockedReply) + return + } + slog.Info("vacation mode disabled via slack", "channel", channelName, "user", msg.User) + if err := vacationState.Set(false); err != nil { + slog.Warn("failed to persist vacation state", "error", err) + } + slackClient.ReplyInThread(msg.Channel, msg.ThreadTS(), vacationEndReply) +} + +func isVacationStartPhrase(text string) bool { + t := strings.ToLower(text) + return strings.Contains(t, "go on vacation") || strings.Contains(t, "vacation time") +} + +func isVacationEndPhrase(text string) bool { + t := strings.ToLower(text) + return strings.Contains(t, "back from vacation") || strings.Contains(t, "vacation is over") +} + +func canToggleVacation(admins []string, userID string) bool { + if len(admins) == 0 { + return true + } + return slices.Contains(admins, userID) +} + func handleVacationMode(msg *islack.IncomingMessage, slackClient *islack.Client, stateManager *state.Manager, channelName string) { if !isDirectInteraction(msg) { return diff --git a/cmd/handlers_test.go b/cmd/handlers_test.go index c565a3f..cd41a06 100644 --- a/cmd/handlers_test.go +++ b/cmd/handlers_test.go @@ -28,3 +28,48 @@ func TestIsDirectInteraction(t *testing.T) { }) } } + +func TestVacationPhrases(t *testing.T) { + start := []string{ + "<@U0TOAD> you can now go on vacation", + "Hey toad, VACATION TIME!", + } + for _, text := range start { + if !isVacationStartPhrase(text) { + t.Errorf("expected start phrase match: %q", text) + } + if isVacationEndPhrase(text) { + t.Errorf("start phrase must not match end: %q", text) + } + } + + end := []string{ + "<@U0TOAD> welcome back from vacation!", + "toad your vacation is over", + } + for _, text := range end { + if !isVacationEndPhrase(text) { + t.Errorf("expected end phrase match: %q", text) + } + if isVacationStartPhrase(text) { + t.Errorf("end phrase must not match start: %q", text) + } + } + + neutral := "can you fix the export bug?" + if isVacationStartPhrase(neutral) || isVacationEndPhrase(neutral) { + t.Errorf("neutral text must not match: %q", neutral) + } +} + +func TestCanToggleVacation(t *testing.T) { + if !canToggleVacation(nil, "U1") { + t.Error("empty admin list should allow anyone") + } + if !canToggleVacation([]string{"U1", "U2"}, "U2") { + t.Error("listed admin should be allowed") + } + if canToggleVacation([]string{"U1"}, "U3") { + t.Error("unlisted user should be denied") + } +} diff --git a/cmd/root.go b/cmd/root.go index 3b09c37..d683b54 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -134,6 +134,8 @@ func runDaemon(cmd *cobra.Command, args []string) error { return fmt.Errorf("hydrating state: %w", err) } + vacationState := state.NewVacationState(stateDB, cfg.VacationMode) + // Initialize personality manager var personalityMgr *personality.Manager if cfg.Personality.Enabled { @@ -246,11 +248,15 @@ func runDaemon(cmd *cobra.Command, args []string) error { // Initialize digest engine (Toad King) if enabled var digestEngine *digest.Engine - if cfg.Digest.Enabled && !cfg.VacationMode { + if cfg.Digest.Enabled { digestEngine = digest.New(&cfg.Digest, digest.EngineOpts{ AgentProvider: agentProvider, TriageModel: cfg.Triage.Model, + Paused: vacationState.Active, Spawn: func(ctx context.Context, task tadpole.Task) error { + if vacationState.Active() { + return fmt.Errorf("vacation mode active") + } return tadpolePool.Spawn(ctx, task) }, Notify: func(channel, threadTS, text string) { @@ -379,7 +385,7 @@ func runDaemon(cmd *cobra.Command, args []string) error { messageWg.Add(1) go func() { defer messageWg.Done() - handleMessage(ctx, msg, cfg, agentProvider, triageEngine, ribbitEngine, slackClient, stateManager, ribbitSem, tadpolePool, digestEngine, tracker, resolver, repoPaths) + handleMessage(ctx, msg, cfg, agentProvider, triageEngine, ribbitEngine, slackClient, stateManager, vacationState, ribbitSem, tadpolePool, digestEngine, tracker, resolver, repoPaths) }() }) @@ -425,7 +431,7 @@ func runDaemon(cmd *cobra.Command, args []string) error { "triggers", fmt.Sprintf("emoji=%s keywords=%v", cfg.Slack.Triggers.Emoji, cfg.Slack.Triggers.Keywords), ) - if cfg.VacationMode { + if vacationState.Active() { slog.Warn("vacation mode enabled — passive monitoring, digest, and PR watching are disabled; direct interactions get a decline reply") } @@ -440,9 +446,8 @@ func runDaemon(cmd *cobra.Command, args []string) error { } // Start PR review watcher - if !cfg.VacationMode { - go prWatcher.Run(ctx) - } + prWatcher.SetPaused(vacationState.Active) + go prWatcher.Run(ctx) // Start periodic repo sync if enabled if cfg.Repos.SyncMinutes > 0 { @@ -459,7 +464,7 @@ func runDaemon(cmd *cobra.Command, args []string) error { if digestEngine != nil { go digestEngine.Run(ctx) // Resume any investigations that were interrupted by a previous crash. - if recovery != nil && len(recovery.StaleOpportunities) > 0 { + if recovery != nil && len(recovery.StaleOpportunities) > 0 && !vacationState.Active() { staleOpps := recovery.StaleOpportunities go digestEngine.ResumeInvestigations(ctx, staleOpps) } diff --git a/internal/config/config.go b/internal/config/config.go index 58d9976..2c610d2 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -12,19 +12,20 @@ import ( ) type Config struct { - Slack SlackConfig `yaml:"slack"` - Repos ReposConfig `yaml:"repos"` - Limits LimitsConfig `yaml:"limits"` - Triage TriageConfig `yaml:"triage"` - Claude ClaudeConfig `yaml:"claude"` // Deprecated: use Agent.Model and Agent.AppendSystemPrompt - Digest DigestConfig `yaml:"digest"` - IssueTracker IssueTrackerConfig `yaml:"issue_tracker"` - VCS VCSConfig `yaml:"vcs"` - Agent AgentConfig `yaml:"agent"` - Log LogConfig `yaml:"log"` - MCP MCPConfig `yaml:"mcp"` - Personality PersonalityConfig `yaml:"personality"` - VacationMode bool `yaml:"vacation_mode"` + Slack SlackConfig `yaml:"slack"` + Repos ReposConfig `yaml:"repos"` + Limits LimitsConfig `yaml:"limits"` + Triage TriageConfig `yaml:"triage"` + Claude ClaudeConfig `yaml:"claude"` // Deprecated: use Agent.Model and Agent.AppendSystemPrompt + Digest DigestConfig `yaml:"digest"` + IssueTracker IssueTrackerConfig `yaml:"issue_tracker"` + VCS VCSConfig `yaml:"vcs"` + Agent AgentConfig `yaml:"agent"` + Log LogConfig `yaml:"log"` + MCP MCPConfig `yaml:"mcp"` + Personality PersonalityConfig `yaml:"personality"` + VacationMode bool `yaml:"vacation_mode"` + VacationAdmins []string `yaml:"vacation_admins"` } type SlackConfig struct { diff --git a/internal/digest/digest.go b/internal/digest/digest.go index 8b40faf..d19a739 100644 --- a/internal/digest/digest.go +++ b/internal/digest/digest.go @@ -142,6 +142,7 @@ type Engine struct { respectAssignees bool staleDays int personality *personality.Manager + paused func() bool mu sync.Mutex buffer []Message @@ -183,6 +184,7 @@ type EngineOpts struct { RespectAssignees bool StaleDays int Personality *personality.Manager + Paused func() bool } // New creates a digest engine. @@ -206,6 +208,7 @@ func New(cfg *config.DigestConfig, opts EngineOpts) *Engine { respectAssignees: opts.RespectAssignees, staleDays: opts.StaleDays, personality: opts.Personality, + paused: opts.Paused, spawnHour: time.Now().Hour(), actedIssues: make(map[string]time.Time), } @@ -466,6 +469,13 @@ func (e *Engine) flush(ctx context.Context) { e.buffer = nil e.mu.Unlock() + if e.paused != nil && e.paused() { + if len(msgs) > 0 { + slog.Info("digest paused, dropping buffered messages", "count", len(msgs)) + } + return + } + e.lastFlush.Store(time.Now().Unix()) if len(msgs) == 0 { diff --git a/internal/reviewer/reviewer.go b/internal/reviewer/reviewer.go index 584cd0c..6bf7816 100644 --- a/internal/reviewer/reviewer.go +++ b/internal/reviewer/reviewer.go @@ -40,6 +40,7 @@ type Watcher struct { reviewBots map[string]bool // bot usernames whose comments can trigger fixes pollTick uint64 personalityOutcome OutcomeCallback + paused func() bool } // NewWatcher creates a PR review watcher. @@ -74,6 +75,11 @@ func (w *Watcher) OnPersonalityOutcome(cb OutcomeCallback) { w.personalityOutcome = cb } +// SetPaused registers a callback that suspends polling while it returns true. +func (w *Watcher) SetPaused(paused func() bool) { + w.paused = paused +} + // Run starts the polling loop. Blocks until ctx is canceled. func (w *Watcher) Run(ctx context.Context) { slog.Info("PR review watcher started", "interval", w.interval) @@ -83,6 +89,9 @@ func (w *Watcher) Run(ctx context.Context) { for { select { case <-ticker.C: + if w.paused != nil && w.paused() { + continue + } w.poll(ctx) case <-ctx.Done(): slog.Info("PR review watcher stopped") diff --git a/internal/state/db.go b/internal/state/db.go index 656e0a8..54b4ca4 100644 --- a/internal/state/db.go +++ b/internal/state/db.go @@ -314,7 +314,7 @@ func (d *DB) UpdateStatus(runID, status string) error { // CompleteRun marks a run as done or failed with a result. func (d *DB) CompleteRun(runID string, result *RunResult) error { - status := "done" + status := statusDone if !result.Success { status = "failed" } @@ -589,7 +589,7 @@ func (d *DB) Stats() (*Stats, error) { return nil, fmt.Errorf("scanning run: %w", err) } s.TotalRuns++ - if status == "done" { + if status == statusDone { s.Succeeded++ } else { s.Failed++ diff --git a/internal/state/state.go b/internal/state/state.go index d6b98a0..b25c8da 100644 --- a/internal/state/state.go +++ b/internal/state/state.go @@ -8,6 +8,8 @@ import ( "time" ) +const statusDone = "done" + // Run represents an active or completed tadpole run. type Run struct { ID string @@ -212,7 +214,7 @@ func (m *Manager) Complete(runID string, result *RunResult) { return } if result.Success { - run.Status = "done" + run.Status = statusDone } else { run.Status = "failed" } diff --git a/internal/state/vacation.go b/internal/state/vacation.go new file mode 100644 index 0000000..9346200 --- /dev/null +++ b/internal/state/vacation.go @@ -0,0 +1,63 @@ +package state + +import ( + "errors" + "sync/atomic" +) + +const vacationSettingKey = "vacation_mode" + +// ErrVacationForced is returned when trying to disable vacation mode that is +// forced on by config. +var ErrVacationForced = errors.New("vacation mode is forced on by config") + +// VacationState tracks whether toad is on vacation. The state is persisted +// in the settings table so it survives restarts. When forced via config, +// it cannot be disabled at runtime. +type VacationState struct { + forced bool + active atomic.Bool + db *DB +} + +// NewVacationState loads the persisted vacation state. The forced flag +// (from config) takes precedence over the stored setting. +func NewVacationState(db *DB, forced bool) *VacationState { + v := &VacationState{forced: forced, db: db} + if forced { + v.active.Store(true) + return v + } + if db != nil { + if val, err := db.GetSetting(vacationSettingKey); err == nil && (val == "true" || val == "1") { + v.active.Store(true) + } + } + return v +} + +// Active reports whether vacation mode is currently on. +func (v *VacationState) Active() bool { + return v.active.Load() +} + +// Forced reports whether vacation mode is locked on by config. +func (v *VacationState) Forced() bool { + return v.forced +} + +// Set toggles vacation mode and persists the new state. +func (v *VacationState) Set(on bool) error { + if v.forced && !on { + return ErrVacationForced + } + v.active.Store(on) + if v.db == nil { + return nil + } + val := "false" + if on { + val = "true" + } + return v.db.SetSetting(vacationSettingKey, val) +} diff --git a/internal/state/vacation_test.go b/internal/state/vacation_test.go new file mode 100644 index 0000000..db8b85d --- /dev/null +++ b/internal/state/vacation_test.go @@ -0,0 +1,62 @@ +package state + +import ( + "errors" + "testing" +) + +func TestVacationState_TogglePersists(t *testing.T) { + db := openTestDB(t) + + v := NewVacationState(db, false) + if v.Active() { + t.Error("expected vacation off by default") + } + + if err := v.Set(true); err != nil { + t.Fatalf("set: %v", err) + } + if !v.Active() { + t.Error("expected vacation on after Set(true)") + } + + reloaded := NewVacationState(db, false) + if !reloaded.Active() { + t.Error("expected vacation state to persist across reload") + } + + if err := reloaded.Set(false); err != nil { + t.Fatalf("set: %v", err) + } + if NewVacationState(db, false).Active() { + t.Error("expected vacation off after Set(false)") + } +} + +func TestVacationState_Forced(t *testing.T) { + db := openTestDB(t) + + v := NewVacationState(db, true) + if !v.Active() { + t.Error("expected forced vacation to be active") + } + if !v.Forced() { + t.Error("expected Forced() true") + } + if err := v.Set(false); !errors.Is(err, ErrVacationForced) { + t.Errorf("expected ErrVacationForced, got %v", err) + } + if !v.Active() { + t.Error("forced vacation must stay active") + } +} + +func TestVacationState_NilDB(t *testing.T) { + v := NewVacationState(nil, false) + if err := v.Set(true); err != nil { + t.Fatalf("set without db: %v", err) + } + if !v.Active() { + t.Error("expected in-memory toggle to work without db") + } +} From 535c6531ef86671fe9f4865e22774a8318e64410 Mon Sep 17 00:00:00 2001 From: Julian <62840881+JulianSarkinovic@users.noreply.github.com> Date: Fri, 12 Jun 2026 12:07:04 +0200 Subject: [PATCH 3/3] Wake toad with 'come back' mentions --- .toad.yaml.example | 2 +- SETUP.md | 2 +- cmd/handlers.go | 2 +- cmd/handlers_test.go | 1 + 4 files changed, 4 insertions(+), 3 deletions(-) diff --git a/.toad.yaml.example b/.toad.yaml.example index 22d53ec..79729f3 100644 --- a/.toad.yaml.example +++ b/.toad.yaml.example @@ -75,6 +75,6 @@ log: # Vacation mode: disable all autonomous behavior (passive monitoring, Toad King, # PR watching). Direct interactions get a polite decline and are saved. -# Toggle from Slack: "@toad go on vacation" / "@toad back from vacation". +# Toggle from Slack: "@toad go on vacation" / "@toad time to come back!". # vacation_mode: false # force vacation on (locks out the Slack toggle) # vacation_admins: [] # Slack user IDs allowed to toggle; empty = anyone diff --git a/SETUP.md b/SETUP.md index 4c8fe48..0e96266 100644 --- a/SETUP.md +++ b/SETUP.md @@ -656,7 +656,7 @@ When on vacation, toad stays connected to Slack but does nothing on its own: no Vacation can also be toggled at runtime from Slack, no restart needed: - A mention containing **"go on vacation"** or **"vacation time"** starts the vacation -- A mention containing **"back from vacation"** or **"vacation is over"** ends it +- A mention containing **"come back"** (or "back from vacation", "vacation is over") ends it The runtime state is persisted in the state database and survives restarts. The `vacation_mode` config flag forces vacation on; while set, the Slack toggle cannot end it. diff --git a/cmd/handlers.go b/cmd/handlers.go index e7e8c96..471dda5 100644 --- a/cmd/handlers.go +++ b/cmd/handlers.go @@ -642,7 +642,7 @@ func isVacationStartPhrase(text string) bool { func isVacationEndPhrase(text string) bool { t := strings.ToLower(text) - return strings.Contains(t, "back from vacation") || strings.Contains(t, "vacation is over") + return strings.Contains(t, "come back") || strings.Contains(t, "back from vacation") || strings.Contains(t, "vacation is over") } func canToggleVacation(admins []string, userID string) bool { diff --git a/cmd/handlers_test.go b/cmd/handlers_test.go index cd41a06..a950e28 100644 --- a/cmd/handlers_test.go +++ b/cmd/handlers_test.go @@ -44,6 +44,7 @@ func TestVacationPhrases(t *testing.T) { } end := []string{ + "<@U0TOAD> time to come back!", "<@U0TOAD> welcome back from vacation!", "toad your vacation is over", }