@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.
- package/README.md +3 -0
- package/package.json +1 -1
- package/src/index.ts +305 -1
- package/src/ralph.ts +8 -1
- package/src/runner-rpc.ts +33 -1
- package/src/runner-state.ts +20 -3
- package/src/runner.ts +109 -24
- package/tests/index.test.ts +275 -3
- package/tests/ralph.test.ts +24 -0
- package/tests/runner-event-contract.test.ts +1 -1
- package/tests/runner-rpc.test.ts +89 -1
- package/tests/runner-state.test.ts +28 -0
- package/tests/runner.test.ts +206 -1
|
@@ -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", () => {
|
package/tests/runner.test.ts
CHANGED
|
@@ -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
|
+
|