From bd84d033a1640fde19c247db6f9360209ea053ac Mon Sep 17 00:00:00 2001 From: Brian Swiger Date: Fri, 14 Aug 2026 14:45:57 +0100 Subject: [PATCH 1/2] Implement the documented watch stop signals `--sentinel-file` was parsed and threaded into watch config but never read, and `.squad/ralph-stop` appeared only in documentation with no code references. A running watch loop could therefore only be stopped by sending a signal to the process. Add `detectStopSignal()` and check it at the top of every round: - `--sentinel-file ` stops the run once that file is removed, matching the existing doc comment in watch/config.ts. The file is created at startup if absent so the first round does not stop immediately. - `/ralph-stop` stops the run once it exists, matching the documented `touch .squad/ralph-stop` in the README. Both reuse the existing graceful shutdown path, so PID cleanup, circuit breaker persistence and session teardown all still run. The shutdown closure was hoisted out of the interval promise so a stop detected on the very first round can shut down cleanly. Closes #1711. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .changeset/watch-stop-signal.md | 5 + .../squad-cli/src/cli/commands/watch/index.ts | 125 +++++++++++++++--- test/watch-stop-signal.test.ts | 93 +++++++++++++ 3 files changed, 203 insertions(+), 20 deletions(-) create mode 100644 .changeset/watch-stop-signal.md create mode 100644 test/watch-stop-signal.test.ts diff --git a/.changeset/watch-stop-signal.md b/.changeset/watch-stop-signal.md new file mode 100644 index 000000000..569b37823 --- /dev/null +++ b/.changeset/watch-stop-signal.md @@ -0,0 +1,5 @@ +--- +'@bradygaster/squad-cli': patch +--- + +Implement the two documented `squad watch` stop signals, which were previously parsed but never read. `--sentinel-file ` now stops the run gracefully once that file is deleted, and creating `.squad/ralph-stop` stops the run gracefully as documented in the README. Both are checked at the top of each round and reuse the existing graceful shutdown path, so a watch run can now be stopped from outside the process without sending a signal. Closes #1711. diff --git a/packages/squad-cli/src/cli/commands/watch/index.ts b/packages/squad-cli/src/cli/commands/watch/index.ts index 6204fa0b6..cca5c312b 100644 --- a/packages/squad-cli/src/cli/commands/watch/index.ts +++ b/packages/squad-cli/src/cli/commands/watch/index.ts @@ -464,6 +464,50 @@ function saveCBState(squadDir: string, state: CircuitBreakerState): void { ); } +// ── Stop Signal (#1711) ────────────────────────────────────────── + +/** Sentinel file name, relative to the squad directory, that stops a watch run. */ +export const STOP_FILE_NAME = 'ralph-stop'; + +/** Why a watch run is stopping. */ +export interface StopSignal { + /** Human-readable explanation, printed before shutdown. */ + reason: string; +} + +/** + * Decide whether a watch run should stop. + * + * Two independent triggers, both of which are documented but were previously + * never read: + * + * - `--sentinel-file ` stops the run once that file is **removed**. + * - `/ralph-stop` stops the run once that file **exists**. + * + * Pure apart from the injected `exists` probe so it can be unit tested. + */ +export function detectStopSignal(options: { + squadDir: string; + sentinelFile?: string; + exists: (filePath: string) => boolean; +}): StopSignal | null { + const { squadDir, sentinelFile, exists } = options; + + if (sentinelFile) { + const resolved = path.resolve(sentinelFile); + if (!exists(resolved)) { + return { reason: `sentinel file removed (${resolved})` }; + } + } + + const stopFile = path.join(squadDir, STOP_FILE_NAME); + if (exists(stopFile)) { + return { reason: `stop file present (${stopFile})` }; + } + + return null; +} + // ── Capability Phase Runner ────────────────────────────────────── async function runPhase( @@ -845,6 +889,20 @@ export async function runWatch(dest: string, options: WatchOptions | WatchConfig console.log(`${DIM}Log file: ${resolvedLogFile}${RESET}`); } + // Stop-signal setup (#1711). The sentinel stops the run when it is *removed*, + // so create it up front - otherwise the very first round would stop instantly. + if (config.sentinelFile) { + const resolvedSentinel = path.resolve(config.sentinelFile); + if (!storage.existsSync(resolvedSentinel)) { + storage.writeSync( + resolvedSentinel, + 'squad watch sentinel. Delete this file to stop the run gracefully.\n', + ); + } + console.log(`${DIM}Sentinel: ${resolvedSentinel} (delete to stop)${RESET}`); + } + console.log(`${DIM}Stop file: ${path.join(squadDirInfo.path, STOP_FILE_NAME)} (create to stop)${RESET}`); + // Fix 3: Immediate visual feedback after banner console.log(`Running first check now...`); @@ -854,10 +912,47 @@ export async function runWatch(dest: string, options: WatchOptions | WatchConfig let round = 0; let roundInProgress = false; + // Stop-signal plumbing (#1711) + let stopRequested = false; + let isShuttingDown = false; + let intervalId: NodeJS.Timeout | undefined; + let resolveRun: (() => void) | undefined; + + const shutdown = async (): Promise => { + if (isShuttingDown) return; + isShuttingDown = true; + if (intervalId) clearInterval(intervalId); + process.off('SIGINT', shutdown); + process.off('SIGTERM', shutdown); + await eventBus.emit({ + type: 'session:destroyed', sessionId: monitorSessionId, + agentName: 'Ralph', payload: null, timestamp: new Date(), + }); + await monitor.stop(); + saveCBState(squadDirInfo.path, cbState); + console.log(`\n${DIM}🔄 Ralph — Watch stopped${RESET}`); + logStream?.end(); + resolveRun?.(); + }; + async function executeRound(): Promise { const ts = new Date().toLocaleTimeString(); const roundStart = Date.now(); + // Stop-signal gate (#1711). Checked before any work so a stop request takes + // effect within one interval, without needing to signal the process. + const stopSignal = detectStopSignal({ + squadDir: squadDirInfo.path, + sentinelFile: config.sentinelFile, + exists: (filePath) => storage.existsSync(filePath), + }); + if (stopSignal) { + console.log(`\n${YELLOW}🛑${RESET} [${ts}] Stop requested - ${stopSignal.reason}`); + stopRequested = true; + await shutdown(); + return; + } + // Circuit breaker gate if (cbState.status === 'open') { const elapsed = Date.now() - new Date(cbState.openedAt!).getTime(); @@ -995,7 +1090,16 @@ export async function runWatch(dest: string, options: WatchOptions | WatchConfig await executeRound(); return new Promise((resolve) => { - const intervalId = setInterval( + resolveRun = resolve; + + // A stop signal on the first round already ran shutdown, which could not + // resolve the run promise because it did not exist yet. + if (isShuttingDown || stopRequested) { + resolve(); + return; + } + + intervalId = setInterval( async () => { if (roundInProgress) return; roundInProgress = true; @@ -1021,25 +1125,6 @@ export async function runWatch(dest: string, options: WatchOptions | WatchConfig interval * 60 * 1000, ); - // Graceful shutdown - let isShuttingDown = false; - const shutdown = async () => { - if (isShuttingDown) return; - isShuttingDown = true; - clearInterval(intervalId); - process.off('SIGINT', shutdown); - process.off('SIGTERM', shutdown); - await eventBus.emit({ - type: 'session:destroyed', sessionId: monitorSessionId, - agentName: 'Ralph', payload: null, timestamp: new Date(), - }); - await monitor.stop(); - saveCBState(squadDirInfo.path, cbState); - console.log(`\n${DIM}🔄 Ralph — Watch stopped${RESET}`); - logStream?.end(); - resolve(); - }; - process.on('SIGINT', shutdown); process.on('SIGTERM', shutdown); }); diff --git a/test/watch-stop-signal.test.ts b/test/watch-stop-signal.test.ts new file mode 100644 index 000000000..078f21e6d --- /dev/null +++ b/test/watch-stop-signal.test.ts @@ -0,0 +1,93 @@ +import { describe, it, expect } from 'vitest'; +import path from 'node:path'; +import { detectStopSignal, STOP_FILE_NAME } from '../packages/squad-cli/src/cli/commands/watch/index.js'; + +const SQUAD_DIR = path.join('C:', 'repo', '.squad'); +const STOP_FILE = path.join(SQUAD_DIR, STOP_FILE_NAME); + +/** Build an `exists` probe that returns true only for the given paths. */ +function existsFor(...present: string[]): (filePath: string) => boolean { + const set = new Set(present.map(p => path.resolve(p))); + return (filePath: string) => set.has(path.resolve(filePath)); +} + +describe('detectStopSignal (#1711)', () => { + it('returns null when no stop signal is present', () => { + const signal = detectStopSignal({ + squadDir: SQUAD_DIR, + exists: existsFor(), + }); + expect(signal).toBeNull(); + }); + + it('returns null while a configured sentinel file still exists', () => { + const sentinel = path.join('C:', 'tmp', 'watch.sentinel'); + const signal = detectStopSignal({ + squadDir: SQUAD_DIR, + sentinelFile: sentinel, + exists: existsFor(sentinel), + }); + expect(signal).toBeNull(); + }); + + it('stops when a configured sentinel file has been removed', () => { + const sentinel = path.join('C:', 'tmp', 'watch.sentinel'); + const signal = detectStopSignal({ + squadDir: SQUAD_DIR, + sentinelFile: sentinel, + exists: existsFor(), + }); + expect(signal).not.toBeNull(); + expect(signal?.reason).toContain('sentinel file removed'); + expect(signal?.reason).toContain(path.resolve(sentinel)); + }); + + it('stops when the ralph-stop file exists', () => { + const signal = detectStopSignal({ + squadDir: SQUAD_DIR, + exists: existsFor(STOP_FILE), + }); + expect(signal).not.toBeNull(); + expect(signal?.reason).toContain('stop file present'); + expect(signal?.reason).toContain(STOP_FILE); + }); + + it('stops via ralph-stop even when the sentinel file is still present', () => { + const sentinel = path.join('C:', 'tmp', 'watch.sentinel'); + const signal = detectStopSignal({ + squadDir: SQUAD_DIR, + sentinelFile: sentinel, + exists: existsFor(sentinel, STOP_FILE), + }); + expect(signal?.reason).toContain('stop file present'); + }); + + it('prefers the sentinel reason when both triggers fire', () => { + const sentinel = path.join('C:', 'tmp', 'watch.sentinel'); + const signal = detectStopSignal({ + squadDir: SQUAD_DIR, + sentinelFile: sentinel, + exists: existsFor(STOP_FILE), + }); + expect(signal?.reason).toContain('sentinel file removed'); + }); + + it('resolves relative sentinel paths against the process cwd', () => { + const relative = 'watch.sentinel'; + const signal = detectStopSignal({ + squadDir: SQUAD_DIR, + sentinelFile: relative, + exists: existsFor(), + }); + expect(signal?.reason).toContain(path.resolve(relative)); + }); + + it('ignores an unset sentinel file rather than treating it as removed', () => { + const signal = detectStopSignal({ + squadDir: SQUAD_DIR, + sentinelFile: undefined, + exists: existsFor(), + }); + expect(signal).toBeNull(); + }); +}); From 50238bd701b5045b55943593abddbe35be1e6d96 Mon Sep 17 00:00:00 2001 From: brady gaster Date: Tue, 25 Aug 2026 09:35:19 -0700 Subject: [PATCH 2/2] docs(watch): clarify STOP_FILE_NAME vs --sentinel-file in JSDoc The constant names the ralph-stop *stop file*, not the user-configured sentinel file. Calling it a sentinel conflated the two mechanisms, which is confusing now that both exist with opposite trigger semantics. Refs #1711 --- packages/squad-cli/src/cli/commands/watch/index.ts | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/packages/squad-cli/src/cli/commands/watch/index.ts b/packages/squad-cli/src/cli/commands/watch/index.ts index cca5c312b..bfcb51be7 100644 --- a/packages/squad-cli/src/cli/commands/watch/index.ts +++ b/packages/squad-cli/src/cli/commands/watch/index.ts @@ -466,7 +466,11 @@ function saveCBState(squadDir: string, state: CircuitBreakerState): void { // ── Stop Signal (#1711) ────────────────────────────────────────── -/** Sentinel file name, relative to the squad directory, that stops a watch run. */ +/** + * Stop-file name, relative to the squad directory. Creating this file stops a + * watch run. Distinct from the user-configured `--sentinel-file`, which stops a + * run when it is *removed*. + */ export const STOP_FILE_NAME = 'ralph-stop'; /** Why a watch run is stopping. */