@lnilluv/pi-ralph-loop 1.0.0 → 1.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -121,6 +121,7 @@ test("parseRalphMarkdown parses frontmatter and normalizes line endings", () =>
121
121
  timeout: 12.5,
122
122
  completionPromise: "done",
123
123
  requiredOutputs: ["docs/ARCHITECTURE.md"],
124
+ stopOnError: true,
124
125
  guardrails: { blockCommands: ["rm .*"], protectedFiles: ["src/**"] },
125
126
  invalidCommandEntries: undefined,
126
127
  });
@@ -136,6 +137,21 @@ test("parseRalphMarkdown parses declared args as runtime parameters", () => {
136
137
  assert.equal(validateFrontmatter(parsed.frontmatter), null);
137
138
  });
138
139
 
140
+ test("parseRalphMarkdown parses stop_on_error from frontmatter", () => {
141
+ const parsed = parseRalphMarkdown("---\nstop_on_error: false\nmax_iterations: 5\ntimeout: 60\ncommands: []\nguardrails: { block_commands: [], protected_files: [] }\n---\nTask\n");
142
+ assert.equal(parsed.frontmatter.stopOnError, false);
143
+ });
144
+
145
+ test("parseRalphMarkdown defaults stop_on_error to true", () => {
146
+ const parsed = parseRalphMarkdown("---\nmax_iterations: 5\ntimeout: 60\ncommands: []\nguardrails: { block_commands: [], protected_files: [] }\n---\nTask\n");
147
+ assert.equal(parsed.frontmatter.stopOnError, true);
148
+ });
149
+
150
+ test("parseRalphMarkdown treats non-false stop_on_error as true (safe default)", () => {
151
+ const parsed = parseRalphMarkdown("---\nstop_on_error: yes\nmax_iterations: 5\ntimeout: 60\ncommands: []\nguardrails: { block_commands: [], protected_files: [] }\n---\nTask\n");
152
+ assert.equal(parsed.frontmatter.stopOnError, true);
153
+ });
154
+
139
155
  test("validateFrontmatter accepts valid input and rejects invalid bounds, names, args, and globs", () => {
140
156
  assert.equal(validateFrontmatter(defaultFrontmatter()), null);
141
157
  assert.equal(
@@ -244,6 +260,13 @@ test("validateFrontmatter accepts valid input and rejects invalid bounds, names,
244
260
  );
245
261
  });
246
262
 
263
+ test("validateFrontmatter accepts stop_on_error true and false", () => {
264
+ const fmTrue = { ...defaultFrontmatter(), stopOnError: true };
265
+ const fmFalse = { ...defaultFrontmatter(), stopOnError: false };
266
+ assert.equal(validateFrontmatter(fmTrue), null);
267
+ assert.equal(validateFrontmatter(fmFalse), null);
268
+ });
269
+
247
270
  test("validateFrontmatter rejects unsafe completion_promise values and Mission Brief fails closed", () => {
248
271
  assert.equal(
249
272
  validateFrontmatter({ ...defaultFrontmatter(), completionPromise: "ready\nnow" }),
@@ -1010,6 +1033,7 @@ test("generated drafts reparse as valid RALPH files", () => {
1010
1033
  timeout: 300,
1011
1034
  completionPromise: undefined,
1012
1035
  requiredOutputs: [],
1036
+ stopOnError: true,
1013
1037
  guardrails: { blockCommands: ["git\\s+push"], protectedFiles: [] },
1014
1038
  invalidCommandEntries: undefined,
1015
1039
  });
@@ -36,7 +36,7 @@ type ExpectedRunnerEvent =
36
36
  timestamp: string;
37
37
  iteration: number;
38
38
  loopToken: string;
39
- status: "complete" | "timeout" | "error";
39
+ status: "complete" | "timeout" | "error" | "cancelled";
40
40
  progress: ProgressState;
41
41
  changedFiles: string[];
42
42
  noProgressStreak: number;
@@ -355,4 +355,92 @@ echo '{"type":"agent_end","messages":[{"role":"assistant","content":[{"type":"te
355
355
  } finally {
356
356
  rmSync(cwd, { recursive: true, force: true });
357
357
  }
358
- });
358
+ });
359
+
360
+
361
+ test("runRpcIteration cancels on AbortSignal and returns cancelled=true", async () => {
362
+ const taskDir = mkdtempSync(join(tmpdir(), "pi-ralph-rpc-"));
363
+ try {
364
+ const scriptPath = join(taskDir, "slow-pi.sh");
365
+ writeFileSync(
366
+ scriptPath,
367
+ `#!/bin/bash
368
+ read line
369
+ echo '{"type":"response","command":"prompt","success":true}'
370
+ sleep 30
371
+ echo '{"type":"agent_end","messages":[{"role":"assistant","content":[{"type":"text","text":"done"}]}]}'
372
+ `,
373
+ { mode: 0o755 },
374
+ );
375
+
376
+ const controller = new AbortController();
377
+ setTimeout(() => controller.abort(), 500);
378
+
379
+ const result = await runRpcIteration({
380
+ prompt: "do something",
381
+ cwd: taskDir,
382
+ timeoutMs: 60_000,
383
+ spawnCommand: "bash",
384
+ spawnArgs: [scriptPath],
385
+ signal: controller.signal,
386
+ });
387
+
388
+ assert.equal(result.cancelled, true);
389
+ assert.equal(result.success, false);
390
+ assert.equal(result.timedOut, false);
391
+ assert.equal(result.error, "cancelled");
392
+ } finally {
393
+ rmSync(taskDir, { recursive: true, force: true });
394
+ }
395
+ });
396
+
397
+ test("runRpcIteration returns immediately if AbortSignal is already aborted", async () => {
398
+ const taskDir = mkdtempSync(join(tmpdir(), "pi-ralph-rpc-"));
399
+ try {
400
+ const controller = new AbortController();
401
+ controller.abort();
402
+
403
+ const result = await runRpcIteration({
404
+ prompt: "do something",
405
+ cwd: taskDir,
406
+ timeoutMs: 5_000,
407
+ spawnCommand: "echo",
408
+ spawnArgs: ["mock"],
409
+ signal: controller.signal,
410
+ });
411
+
412
+ assert.equal(result.cancelled, true);
413
+ assert.equal(result.success, false);
414
+ } finally {
415
+ rmSync(taskDir, { recursive: true, force: true });
416
+ }
417
+ });
418
+
419
+ test("runRpcIteration completes normally without AbortSignal", async () => {
420
+ const taskDir = mkdtempSync(join(tmpdir(), "pi-ralph-rpc-"));
421
+ try {
422
+ const scriptPath = join(taskDir, "fast-pi.sh");
423
+ writeFileSync(
424
+ scriptPath,
425
+ `#!/bin/bash
426
+ read line
427
+ echo '{"type":"response","command":"prompt","success":true}'
428
+ echo '{"type":"agent_end","messages":[{"role":"assistant","content":[{"type":"text","text":"done"}]}]}'
429
+ `,
430
+ { mode: 0o755 },
431
+ );
432
+
433
+ const result = await runRpcIteration({
434
+ prompt: "do something",
435
+ cwd: taskDir,
436
+ timeoutMs: 5_000,
437
+ spawnCommand: "bash",
438
+ spawnArgs: [scriptPath],
439
+ });
440
+
441
+ assert.equal(result.success, true);
442
+ assert.equal(result.cancelled, undefined);
443
+ } finally {
444
+ rmSync(taskDir, { recursive: true, force: true });
445
+ }
446
+ });
@@ -11,9 +11,12 @@ import {
11
11
  type RunnerStatusFile,
12
12
  appendIterationRecord,
13
13
  appendRunnerEvent,
14
+ checkCancelSignal,
14
15
  checkStopSignal,
16
+ clearCancelSignal,
15
17
  clearRunnerDir,
16
18
  clearStopSignal,
19
+ createCancelSignal,
17
20
  createStopSignal,
18
21
  ensureRunnerDir,
19
22
  listActiveLoopRegistryEntries,
@@ -288,6 +291,31 @@ test("clearStopSignal is idempotent when no signal exists", () => {
288
291
  }
289
292
  });
290
293
 
294
+ test("createCancelSignal writes cancel.flag and checkCancelSignal detects it", () => {
295
+ const taskDir = createTempDir();
296
+ try {
297
+ ensureRunnerDir(taskDir);
298
+ assert.equal(checkCancelSignal(taskDir), false);
299
+ createCancelSignal(taskDir);
300
+ assert.equal(checkCancelSignal(taskDir), true);
301
+ clearCancelSignal(taskDir);
302
+ assert.equal(checkCancelSignal(taskDir), false);
303
+ } finally {
304
+ rmSync(taskDir, { recursive: true, force: true });
305
+ }
306
+ });
307
+
308
+ test("clearCancelSignal is safe when cancel.flag does not exist", () => {
309
+ const taskDir = createTempDir();
310
+ try {
311
+ ensureRunnerDir(taskDir);
312
+ clearCancelSignal(taskDir); // should not throw
313
+ assert.equal(checkCancelSignal(taskDir), false);
314
+ } finally {
315
+ rmSync(taskDir, { recursive: true, force: true });
316
+ }
317
+ });
318
+
291
319
  // --- clearRunnerDir ---
292
320
 
293
321
  test("clearRunnerDir removes .ralph-runner directory", () => {
@@ -5,7 +5,7 @@ import { tmpdir } from "node:os";
5
5
  import test from "node:test";
6
6
 
7
7
  import { assessTaskDirectoryProgress, captureTaskDirectorySnapshot, runRalphLoop, validateCompletionReadiness } from "../src/runner.ts";
8
- import { readStatusFile, readIterationRecords, readRunnerEvents, checkStopSignal, createStopSignal as createStopSignalFn, type RunnerEvent } from "../src/runner-state.ts";
8
+ import { readStatusFile, readIterationRecords, readRunnerEvents, checkStopSignal, createCancelSignal, createStopSignal as createStopSignalFn, type RunnerEvent } from "../src/runner-state.ts";
9
9
  import { generateDraft } from "../src/ralph.ts";
10
10
  import type { DraftTarget, CommandOutput, CommandDef } from "../src/ralph.ts";
11
11
 
@@ -339,6 +339,86 @@ echo '{"type":"agent_end","messages":[{"role":"assistant","content":[{"type":"te
339
339
  }
340
340
  });
341
341
 
342
+ test("runRalphLoop cancels mid-iteration when cancel flag is written", async () => {
343
+ const taskDir = createTempDir();
344
+ try {
345
+ const ralphPath = writeRalphMd(taskDir, minimalRalphMd({ max_iterations: 3 }));
346
+
347
+ const scriptPath = join(taskDir, "slow-pi.sh");
348
+ writeFileSync(
349
+ scriptPath,
350
+ `#!/bin/bash
351
+ read line
352
+ echo '{"type":"response","command":"prompt","success":true}'
353
+ sleep 10
354
+ echo '{"type":"agent_end","messages":[{"role":"assistant","content":[{"type":"text","text":"done"}]}]}'
355
+ `,
356
+ { mode: 0o755 },
357
+ );
358
+
359
+ setTimeout(() => createCancelSignal(taskDir), 1000);
360
+
361
+ const result = await runRalphLoop({
362
+ ralphPath,
363
+ cwd: taskDir,
364
+ timeout: 30,
365
+ maxIterations: 3,
366
+ guardrails: { blockCommands: [], protectedFiles: [] },
367
+ spawnCommand: "bash",
368
+ spawnArgs: [scriptPath],
369
+ runCommandsFn: async () => [],
370
+ pi: makeMockPi(),
371
+ });
372
+
373
+ assert.equal(result.status, "cancelled");
374
+ assert.ok(result.iterations.length >= 1);
375
+ assert.equal(result.iterations[result.iterations.length - 1].status, "cancelled");
376
+ } finally {
377
+ rmSync(taskDir, { recursive: true, force: true });
378
+ }
379
+ });
380
+
381
+ test("runRalphLoop checks cancel flag at iteration boundary", async () => {
382
+ const taskDir = createTempDir();
383
+ try {
384
+ const ralphPath = writeRalphMd(taskDir, minimalRalphMd({ max_iterations: 3 }));
385
+
386
+ const scriptPath = join(taskDir, "mock-pi.sh");
387
+ writeFileSync(
388
+ scriptPath,
389
+ `#!/bin/bash
390
+ read line
391
+ echo '{"type":"response","command":"prompt","success":true}'
392
+ echo '{"type":"agent_end","messages":[{"role":"assistant","content":[{"type":"text","text":"done"}]}]}'
393
+ `,
394
+ { mode: 0o755 },
395
+ );
396
+
397
+ let iterationCount = 0;
398
+ const result = await runRalphLoop({
399
+ ralphPath,
400
+ cwd: taskDir,
401
+ timeout: 5,
402
+ maxIterations: 3,
403
+ guardrails: { blockCommands: [], protectedFiles: [] },
404
+ spawnCommand: "bash",
405
+ spawnArgs: [scriptPath],
406
+ onIterationComplete() {
407
+ iterationCount++;
408
+ if (iterationCount >= 1) {
409
+ createCancelSignal(taskDir);
410
+ }
411
+ },
412
+ runCommandsFn: async () => [],
413
+ pi: makeMockPi(),
414
+ });
415
+
416
+ assert.equal(result.status, "cancelled");
417
+ } finally {
418
+ rmSync(taskDir, { recursive: true, force: true });
419
+ }
420
+ });
421
+
342
422
  test("runRalphLoop waits between iterations when inter_iteration_delay is set", async () => {
343
423
  const taskDir = createTempDir();
344
424
  try {
@@ -1345,3 +1425,128 @@ echo '{"type":"agent_end","messages":[{"role":"assistant","content":[{"type":"te
1345
1425
  rmSync(taskDir, { recursive: true, force: true });
1346
1426
  }
1347
1427
  });
1428
+
1429
+ test("runRalphLoop stops on error when stopOnError is true (default)", async () => {
1430
+ const taskDir = createTempDir();
1431
+ try {
1432
+ const ralphPath = writeRalphMd(taskDir, minimalRalphMd({ max_iterations: 3 }));
1433
+
1434
+ const scriptPath = join(taskDir, "failing-pi.sh");
1435
+ writeFileSync(
1436
+ scriptPath,
1437
+ `#!/bin/bash
1438
+ read line
1439
+ echo '{"type":"response","command":"prompt","success":true}'
1440
+ exit 1
1441
+ `,
1442
+ { mode: 0o755 },
1443
+ );
1444
+
1445
+ const result = await runRalphLoop({
1446
+ ralphPath,
1447
+ cwd: taskDir,
1448
+ timeout: 5,
1449
+ maxIterations: 3,
1450
+ stopOnError: true,
1451
+ guardrails: { blockCommands: [], protectedFiles: [] },
1452
+ spawnCommand: "bash",
1453
+ spawnArgs: [scriptPath],
1454
+ runCommandsFn: async () => [],
1455
+ pi: makeMockPi(),
1456
+ });
1457
+
1458
+ assert.equal(result.status, "error");
1459
+ assert.equal(result.iterations.length, 1);
1460
+ } finally {
1461
+ rmSync(taskDir, { recursive: true, force: true });
1462
+ }
1463
+ });
1464
+
1465
+ test("runRalphLoop continues past error when stopOnError is false", async () => {
1466
+ const taskDir = createTempDir();
1467
+ try {
1468
+ const ralphPath = writeRalphMd(taskDir, minimalRalphMd({ max_iterations: 3 }));
1469
+
1470
+ const scriptPath = join(taskDir, "maybe-fail-pi.sh");
1471
+ writeFileSync(
1472
+ scriptPath,
1473
+ `#!/bin/bash
1474
+ read line
1475
+ COUNTER_FILE="${taskDir}/.call-counter"
1476
+ COUNT=0
1477
+ if [ -f "$COUNTER_FILE" ]; then
1478
+ COUNT=$(cat "$COUNTER_FILE")
1479
+ fi
1480
+ COUNT=$((COUNT + 1))
1481
+ echo "$COUNT" > "$COUNTER_FILE"
1482
+ echo '{"type":"response","command":"prompt","success":true}'
1483
+ if [ "$COUNT" -le 1 ]; then
1484
+ exit 1
1485
+ fi
1486
+ echo '{"type":"agent_end","messages":[{"role":"assistant","content":[{"type":"text","text":"done"}]}]}'
1487
+ `,
1488
+ { mode: 0o755 },
1489
+ );
1490
+
1491
+ const result = await runRalphLoop({
1492
+ ralphPath,
1493
+ cwd: taskDir,
1494
+ timeout: 5,
1495
+ maxIterations: 3,
1496
+ stopOnError: false,
1497
+ guardrails: { blockCommands: [], protectedFiles: [] },
1498
+ spawnCommand: "bash",
1499
+ spawnArgs: [scriptPath],
1500
+ runCommandsFn: async () => [],
1501
+ pi: makeMockPi(),
1502
+ });
1503
+
1504
+ assert.ok(result.iterations.length > 1, `Expected >1 iteration, got ${result.iterations.length}`);
1505
+ assert.equal(result.iterations[0].status, "error");
1506
+ } finally {
1507
+ rmSync(taskDir, { recursive: true, force: true });
1508
+ }
1509
+ });
1510
+
1511
+ test("runRalphLoop breaks on structural failure even with stopOnError false", async () => {
1512
+ const taskDir = createTempDir();
1513
+ try {
1514
+ const ralphPath = writeRalphMd(taskDir, minimalRalphMd({ max_iterations: 3, stop_on_error: false }));
1515
+
1516
+ const scriptPath = join(taskDir, "mock-pi.sh");
1517
+ writeFileSync(
1518
+ scriptPath,
1519
+ `#!/bin/bash
1520
+ read line
1521
+ echo '{"type":"response","command":"prompt","success":true}'
1522
+ echo '{"type":"agent_end","messages":[{"role":"assistant","content":[{"type":"text","text":"delete ralph"}]}]}'
1523
+ `,
1524
+ { mode: 0o755 },
1525
+ );
1526
+
1527
+ let iterationCount = 0;
1528
+ const result = await runRalphLoop({
1529
+ ralphPath,
1530
+ cwd: taskDir,
1531
+ timeout: 5,
1532
+ maxIterations: 3,
1533
+ stopOnError: false,
1534
+ guardrails: { blockCommands: [], protectedFiles: [] },
1535
+ spawnCommand: "bash",
1536
+ spawnArgs: [scriptPath],
1537
+ onIterationComplete() {
1538
+ iterationCount++;
1539
+ if (iterationCount >= 1) {
1540
+ rmSync(ralphPath, { force: true });
1541
+ }
1542
+ },
1543
+ runCommandsFn: async () => [],
1544
+ pi: makeMockPi(),
1545
+ });
1546
+
1547
+ assert.equal(result.status, "error");
1548
+ } finally {
1549
+ rmSync(taskDir, { recursive: true, force: true });
1550
+ }
1551
+ });
1552
+