@lnilluv/pi-ralph-loop 1.0.0 → 1.1.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.
@@ -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
+