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..bfcb51be7 100644 --- a/packages/squad-cli/src/cli/commands/watch/index.ts +++ b/packages/squad-cli/src/cli/commands/watch/index.ts @@ -464,6 +464,54 @@ function saveCBState(squadDir: string, state: CircuitBreakerState): void { ); } +// ── Stop Signal (#1711) ────────────────────────────────────────── + +/** + * 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. */ +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 +893,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 +916,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 +1094,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 +1129,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(); + }); +});