Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/watch-stop-signal.md
Original file line number Diff line number Diff line change
@@ -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 <path>` 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.
129 changes: 109 additions & 20 deletions packages/squad-cli/src/cli/commands/watch/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 <path>` stops the run once that file is **removed**.
* - `<squadDir>/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(
Expand Down Expand Up @@ -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...`);

Expand All @@ -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<void> => {
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<void> {
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();
Expand Down Expand Up @@ -995,7 +1094,16 @@ export async function runWatch(dest: string, options: WatchOptions | WatchConfig
await executeRound();

return new Promise<void>((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;
Expand All @@ -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);
});
Expand Down
93 changes: 93 additions & 0 deletions test/watch-stop-signal.test.ts
Original file line number Diff line number Diff line change
@@ -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();
});
});