@lnilluv/pi-ralph-loop 0.3.0 → 1.0.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/.github/workflows/release.yml +8 -39
- package/README.md +50 -160
- package/package.json +2 -2
- package/scripts/version-helper.ts +210 -0
- package/src/index.ts +1085 -188
- package/src/ralph-draft-context.ts +618 -0
- package/src/ralph-draft-llm.ts +297 -0
- package/src/ralph-draft.ts +33 -0
- package/src/ralph.ts +917 -102
- package/src/runner-rpc.ts +434 -0
- package/src/runner-state.ts +822 -0
- package/src/runner.ts +957 -0
- package/src/secret-paths.ts +66 -0
- package/src/shims.d.ts +0 -3
- package/tests/fixtures/parity/migrate/OPEN_QUESTIONS.md +3 -0
- package/tests/fixtures/parity/migrate/RALPH.md +27 -0
- package/tests/fixtures/parity/migrate/golden/MIGRATED.md +15 -0
- package/tests/fixtures/parity/migrate/legacy/source.md +6 -0
- package/tests/fixtures/parity/migrate/legacy/source.yaml +3 -0
- package/tests/fixtures/parity/migrate/scripts/show-legacy.sh +10 -0
- package/tests/fixtures/parity/migrate/scripts/verify.sh +15 -0
- package/tests/fixtures/parity/research/OPEN_QUESTIONS.md +3 -0
- package/tests/fixtures/parity/research/RALPH.md +45 -0
- package/tests/fixtures/parity/research/claim-evidence-checklist.md +15 -0
- package/tests/fixtures/parity/research/expected-outputs.md +22 -0
- package/tests/fixtures/parity/research/scripts/show-snapshots.sh +13 -0
- package/tests/fixtures/parity/research/scripts/verify.sh +55 -0
- package/tests/fixtures/parity/research/snapshots/app-factory-ai-cli.md +11 -0
- package/tests/fixtures/parity/research/snapshots/docs-factory-ai-cli-features-missions.md +11 -0
- package/tests/fixtures/parity/research/snapshots/factory-ai-news-missions.md +11 -0
- package/tests/fixtures/parity/research/source-manifest.md +20 -0
- package/tests/index.test.ts +3529 -0
- package/tests/parity/README.md +9 -0
- package/tests/parity/harness.py +526 -0
- package/tests/parity-harness.test.ts +42 -0
- package/tests/parity-research-fixture.test.ts +34 -0
- package/tests/ralph-draft-context.test.ts +672 -0
- package/tests/ralph-draft-llm.test.ts +434 -0
- package/tests/ralph-draft.test.ts +168 -0
- package/tests/ralph.test.ts +1389 -19
- package/tests/runner-event-contract.test.ts +235 -0
- package/tests/runner-rpc.test.ts +358 -0
- package/tests/runner-state.test.ts +553 -0
- package/tests/runner.test.ts +1347 -0
- package/tests/secret-paths.test.ts +55 -0
- package/tests/version-helper.test.ts +75 -0
|
@@ -0,0 +1,553 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
import { tmpdir } from "node:os";
|
|
5
|
+
import test from "node:test";
|
|
6
|
+
|
|
7
|
+
import {
|
|
8
|
+
type ActiveLoopRegistryEntry,
|
|
9
|
+
type IterationRecord,
|
|
10
|
+
type RunnerEvent,
|
|
11
|
+
type RunnerStatusFile,
|
|
12
|
+
appendIterationRecord,
|
|
13
|
+
appendRunnerEvent,
|
|
14
|
+
checkStopSignal,
|
|
15
|
+
clearRunnerDir,
|
|
16
|
+
clearStopSignal,
|
|
17
|
+
createStopSignal,
|
|
18
|
+
ensureRunnerDir,
|
|
19
|
+
listActiveLoopRegistryEntries,
|
|
20
|
+
readActiveLoopRegistry,
|
|
21
|
+
readIterationRecords,
|
|
22
|
+
readRunnerEvents,
|
|
23
|
+
readStatusFile,
|
|
24
|
+
recordActiveLoopStopObservation,
|
|
25
|
+
recordActiveLoopStopRequest,
|
|
26
|
+
writeActiveLoopRegistryEntry,
|
|
27
|
+
writeIterationTranscript,
|
|
28
|
+
writeStatusFile,
|
|
29
|
+
} from "../src/runner-state.ts";
|
|
30
|
+
|
|
31
|
+
function createTempDir(): string {
|
|
32
|
+
return mkdtempSync(join(tmpdir(), "pi-ralph-runner-state-"));
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function makeStatusFile(overrides: Partial<RunnerStatusFile> = {}): RunnerStatusFile {
|
|
36
|
+
return {
|
|
37
|
+
loopToken: "test-token",
|
|
38
|
+
ralphPath: "/test/RALPH.md",
|
|
39
|
+
taskDir: "/test",
|
|
40
|
+
cwd: "/test",
|
|
41
|
+
status: "running",
|
|
42
|
+
currentIteration: 1,
|
|
43
|
+
maxIterations: 10,
|
|
44
|
+
timeout: 300,
|
|
45
|
+
startedAt: new Date().toISOString(),
|
|
46
|
+
guardrails: { blockCommands: ["git\\s+push"], protectedFiles: [] },
|
|
47
|
+
...overrides,
|
|
48
|
+
};
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function makeIterationRecord(overrides: Partial<IterationRecord> = {}): IterationRecord {
|
|
52
|
+
return {
|
|
53
|
+
iteration: 1,
|
|
54
|
+
status: "complete",
|
|
55
|
+
startedAt: new Date().toISOString(),
|
|
56
|
+
completedAt: new Date().toISOString(),
|
|
57
|
+
durationMs: 5000,
|
|
58
|
+
progress: true,
|
|
59
|
+
changedFiles: ["notes.md"],
|
|
60
|
+
noProgressStreak: 0,
|
|
61
|
+
...overrides,
|
|
62
|
+
};
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function makeCompletionRecord(overrides: Record<string, unknown> = {}) {
|
|
66
|
+
return {
|
|
67
|
+
promiseSeen: true,
|
|
68
|
+
durableProgressObserved: true,
|
|
69
|
+
gateChecked: true,
|
|
70
|
+
gatePassed: true,
|
|
71
|
+
gateBlocked: false,
|
|
72
|
+
blockingReasons: [],
|
|
73
|
+
...overrides,
|
|
74
|
+
};
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// --- ensureRunnerDir ---
|
|
78
|
+
|
|
79
|
+
test("ensureRunnerDir creates .ralph-runner directory", () => {
|
|
80
|
+
const taskDir = createTempDir();
|
|
81
|
+
try {
|
|
82
|
+
const runnerDir = ensureRunnerDir(taskDir);
|
|
83
|
+
assert.ok(existsSync(runnerDir));
|
|
84
|
+
assert.ok(runnerDir.endsWith(".ralph-runner"));
|
|
85
|
+
} finally {
|
|
86
|
+
rmSync(taskDir, { recursive: true, force: true });
|
|
87
|
+
}
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
test("ensureRunnerDir is idempotent", () => {
|
|
91
|
+
const taskDir = createTempDir();
|
|
92
|
+
try {
|
|
93
|
+
const runnerDir1 = ensureRunnerDir(taskDir);
|
|
94
|
+
const runnerDir2 = ensureRunnerDir(taskDir);
|
|
95
|
+
assert.equal(runnerDir1, runnerDir2);
|
|
96
|
+
assert.ok(existsSync(runnerDir1));
|
|
97
|
+
} finally {
|
|
98
|
+
rmSync(taskDir, { recursive: true, force: true });
|
|
99
|
+
}
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
// --- writeStatusFile / readStatusFile ---
|
|
103
|
+
|
|
104
|
+
test("writeStatusFile and readStatusFile round-trip", () => {
|
|
105
|
+
const taskDir = createTempDir();
|
|
106
|
+
try {
|
|
107
|
+
ensureRunnerDir(taskDir);
|
|
108
|
+
const status: RunnerStatusFile = makeStatusFile({ taskDir });
|
|
109
|
+
writeStatusFile(taskDir, status);
|
|
110
|
+
const read = readStatusFile(taskDir);
|
|
111
|
+
assert.deepEqual(read, status);
|
|
112
|
+
} finally {
|
|
113
|
+
rmSync(taskDir, { recursive: true, force: true });
|
|
114
|
+
}
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
test("readStatusFile returns undefined when no status file exists", () => {
|
|
118
|
+
const taskDir = createTempDir();
|
|
119
|
+
try {
|
|
120
|
+
const result = readStatusFile(taskDir);
|
|
121
|
+
assert.equal(result, undefined);
|
|
122
|
+
} finally {
|
|
123
|
+
rmSync(taskDir, { recursive: true, force: true });
|
|
124
|
+
}
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
test("writeStatusFile overwrites previous status", () => {
|
|
128
|
+
const taskDir = createTempDir();
|
|
129
|
+
try {
|
|
130
|
+
ensureRunnerDir(taskDir);
|
|
131
|
+
const status1 = makeStatusFile({ taskDir, status: "running", currentIteration: 1 });
|
|
132
|
+
writeStatusFile(taskDir, status1);
|
|
133
|
+
const status2 = makeStatusFile({ taskDir, status: "complete", currentIteration: 3 });
|
|
134
|
+
writeStatusFile(taskDir, status2);
|
|
135
|
+
const read = readStatusFile(taskDir);
|
|
136
|
+
assert.equal(read?.status, "complete");
|
|
137
|
+
assert.equal(read?.currentIteration, 3);
|
|
138
|
+
} finally {
|
|
139
|
+
rmSync(taskDir, { recursive: true, force: true });
|
|
140
|
+
}
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
test("writeStatusFile preserves completionPromise and guardrails", () => {
|
|
144
|
+
const taskDir = createTempDir();
|
|
145
|
+
try {
|
|
146
|
+
ensureRunnerDir(taskDir);
|
|
147
|
+
const status: RunnerStatusFile = makeStatusFile({
|
|
148
|
+
taskDir,
|
|
149
|
+
completionPromise: "DONE",
|
|
150
|
+
guardrails: { blockCommands: ["git\\s+push", "rm\\s+-rf"], protectedFiles: ["secret.pem"] },
|
|
151
|
+
});
|
|
152
|
+
writeStatusFile(taskDir, status);
|
|
153
|
+
const read = readStatusFile(taskDir);
|
|
154
|
+
assert.equal(read?.completionPromise, "DONE");
|
|
155
|
+
assert.deepEqual(read?.guardrails.blockCommands, ["git\\s+push", "rm\\s+-rf"]);
|
|
156
|
+
assert.deepEqual(read?.guardrails.protectedFiles, ["secret.pem"]);
|
|
157
|
+
} finally {
|
|
158
|
+
rmSync(taskDir, { recursive: true, force: true });
|
|
159
|
+
}
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
// --- appendIterationRecord / readIterationRecords ---
|
|
163
|
+
|
|
164
|
+
test("appendIterationRecord and readIterationRecords round-trip", () => {
|
|
165
|
+
const taskDir = createTempDir();
|
|
166
|
+
try {
|
|
167
|
+
ensureRunnerDir(taskDir);
|
|
168
|
+
const record1 = makeIterationRecord({ iteration: 1, progress: true, changedFiles: ["a.md"] });
|
|
169
|
+
const record2 = makeIterationRecord({ iteration: 2, progress: false, changedFiles: [], noProgressStreak: 1 });
|
|
170
|
+
appendIterationRecord(taskDir, record1);
|
|
171
|
+
appendIterationRecord(taskDir, record2);
|
|
172
|
+
const records = readIterationRecords(taskDir);
|
|
173
|
+
assert.equal(records.length, 2);
|
|
174
|
+
assert.equal(records[0].iteration, 1);
|
|
175
|
+
assert.equal(records[0].progress, true);
|
|
176
|
+
assert.deepEqual(records[0].changedFiles, ["a.md"]);
|
|
177
|
+
assert.equal(records[1].iteration, 2);
|
|
178
|
+
assert.equal(records[1].progress, false);
|
|
179
|
+
assert.equal(records[1].noProgressStreak, 1);
|
|
180
|
+
} finally {
|
|
181
|
+
rmSync(taskDir, { recursive: true, force: true });
|
|
182
|
+
}
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
test("readIterationRecords returns empty array when no file exists", () => {
|
|
186
|
+
const taskDir = createTempDir();
|
|
187
|
+
try {
|
|
188
|
+
const records = readIterationRecords(taskDir);
|
|
189
|
+
assert.deepEqual(records, []);
|
|
190
|
+
} finally {
|
|
191
|
+
rmSync(taskDir, { recursive: true, force: true });
|
|
192
|
+
}
|
|
193
|
+
});
|
|
194
|
+
|
|
195
|
+
test("readIterationRecords skips corrupted JSONL lines without discarding valid entries", () => {
|
|
196
|
+
const taskDir = createTempDir();
|
|
197
|
+
try {
|
|
198
|
+
ensureRunnerDir(taskDir);
|
|
199
|
+
writeFileSync(
|
|
200
|
+
join(taskDir, ".ralph-runner", "iterations.jsonl"),
|
|
201
|
+
[
|
|
202
|
+
JSON.stringify(makeIterationRecord({ iteration: 1, changedFiles: ["one.md"] })),
|
|
203
|
+
"{not json",
|
|
204
|
+
JSON.stringify(makeIterationRecord({ iteration: 2, progress: false, changedFiles: [], noProgressStreak: 1 })),
|
|
205
|
+
].join("\n") + "\n",
|
|
206
|
+
"utf8",
|
|
207
|
+
);
|
|
208
|
+
|
|
209
|
+
const records = readIterationRecords(taskDir);
|
|
210
|
+
assert.equal(records.length, 2);
|
|
211
|
+
assert.equal(records[0].iteration, 1);
|
|
212
|
+
assert.equal(records[1].iteration, 2);
|
|
213
|
+
} finally {
|
|
214
|
+
rmSync(taskDir, { recursive: true, force: true });
|
|
215
|
+
}
|
|
216
|
+
});
|
|
217
|
+
|
|
218
|
+
test("appendIterationRecord creates iterations.jsonl if missing", () => {
|
|
219
|
+
const taskDir = createTempDir();
|
|
220
|
+
try {
|
|
221
|
+
ensureRunnerDir(taskDir);
|
|
222
|
+
const record = makeIterationRecord({ iteration: 1 });
|
|
223
|
+
appendIterationRecord(taskDir, record);
|
|
224
|
+
const records = readIterationRecords(taskDir);
|
|
225
|
+
assert.equal(records.length, 1);
|
|
226
|
+
} finally {
|
|
227
|
+
rmSync(taskDir, { recursive: true, force: true });
|
|
228
|
+
}
|
|
229
|
+
});
|
|
230
|
+
|
|
231
|
+
test("appendRunnerEvent and readRunnerEvents round-trip", () => {
|
|
232
|
+
const taskDir = createTempDir();
|
|
233
|
+
try {
|
|
234
|
+
ensureRunnerDir(taskDir);
|
|
235
|
+
const event = {
|
|
236
|
+
type: "completion_gate_blocked",
|
|
237
|
+
timestamp: new Date().toISOString(),
|
|
238
|
+
iteration: 2,
|
|
239
|
+
loopToken: "test-loop-token",
|
|
240
|
+
ready: false,
|
|
241
|
+
reasons: ["Missing required output: ARCHITECTURE.md"],
|
|
242
|
+
} satisfies Extract<RunnerEvent, { type: "completion_gate_blocked" }>;
|
|
243
|
+
|
|
244
|
+
appendRunnerEvent(taskDir, event);
|
|
245
|
+
|
|
246
|
+
const events = readRunnerEvents(taskDir);
|
|
247
|
+
assert.equal(events.length, 1);
|
|
248
|
+
assert.deepEqual(events[0], event);
|
|
249
|
+
assert.ok(existsSync(join(taskDir, ".ralph-runner", "events.jsonl")));
|
|
250
|
+
} finally {
|
|
251
|
+
rmSync(taskDir, { recursive: true, force: true });
|
|
252
|
+
}
|
|
253
|
+
});
|
|
254
|
+
|
|
255
|
+
// --- Stop signal ---
|
|
256
|
+
|
|
257
|
+
test("createStopSignal and checkStopSignal", () => {
|
|
258
|
+
const taskDir = createTempDir();
|
|
259
|
+
try {
|
|
260
|
+
ensureRunnerDir(taskDir);
|
|
261
|
+
assert.equal(checkStopSignal(taskDir), false);
|
|
262
|
+
createStopSignal(taskDir);
|
|
263
|
+
assert.equal(checkStopSignal(taskDir), true);
|
|
264
|
+
clearStopSignal(taskDir);
|
|
265
|
+
assert.equal(checkStopSignal(taskDir), false);
|
|
266
|
+
} finally {
|
|
267
|
+
rmSync(taskDir, { recursive: true, force: true });
|
|
268
|
+
}
|
|
269
|
+
});
|
|
270
|
+
|
|
271
|
+
test("checkStopSignal returns false without runner dir", () => {
|
|
272
|
+
const taskDir = createTempDir();
|
|
273
|
+
try {
|
|
274
|
+
assert.equal(checkStopSignal(taskDir), false);
|
|
275
|
+
} finally {
|
|
276
|
+
rmSync(taskDir, { recursive: true, force: true });
|
|
277
|
+
}
|
|
278
|
+
});
|
|
279
|
+
|
|
280
|
+
test("clearStopSignal is idempotent when no signal exists", () => {
|
|
281
|
+
const taskDir = createTempDir();
|
|
282
|
+
try {
|
|
283
|
+
clearStopSignal(taskDir);
|
|
284
|
+
clearStopSignal(taskDir);
|
|
285
|
+
assert.equal(checkStopSignal(taskDir), false);
|
|
286
|
+
} finally {
|
|
287
|
+
rmSync(taskDir, { recursive: true, force: true });
|
|
288
|
+
}
|
|
289
|
+
});
|
|
290
|
+
|
|
291
|
+
// --- clearRunnerDir ---
|
|
292
|
+
|
|
293
|
+
test("clearRunnerDir removes .ralph-runner directory", () => {
|
|
294
|
+
const taskDir = createTempDir();
|
|
295
|
+
try {
|
|
296
|
+
const runnerDir = ensureRunnerDir(taskDir);
|
|
297
|
+
writeFileSync(join(runnerDir, "status.json"), "{}", "utf8");
|
|
298
|
+
assert.ok(existsSync(runnerDir));
|
|
299
|
+
clearRunnerDir(taskDir);
|
|
300
|
+
assert.ok(!existsSync(runnerDir));
|
|
301
|
+
} finally {
|
|
302
|
+
rmSync(taskDir, { recursive: true, force: true });
|
|
303
|
+
}
|
|
304
|
+
});
|
|
305
|
+
|
|
306
|
+
test("clearRunnerDir is safe when no runner dir exists", () => {
|
|
307
|
+
const taskDir = createTempDir();
|
|
308
|
+
try {
|
|
309
|
+
clearRunnerDir(taskDir);
|
|
310
|
+
assert.ok(!existsSync(join(taskDir, ".ralph-runner")));
|
|
311
|
+
} finally {
|
|
312
|
+
rmSync(taskDir, { recursive: true, force: true });
|
|
313
|
+
}
|
|
314
|
+
});
|
|
315
|
+
|
|
316
|
+
// --- Iteration record with all fields ---
|
|
317
|
+
|
|
318
|
+
test("iteration record captures all status fields", () => {
|
|
319
|
+
const taskDir = createTempDir();
|
|
320
|
+
try {
|
|
321
|
+
ensureRunnerDir(taskDir);
|
|
322
|
+
const record: IterationRecord = {
|
|
323
|
+
iteration: 3,
|
|
324
|
+
status: "complete",
|
|
325
|
+
startedAt: "2026-04-13T10:00:00.000Z",
|
|
326
|
+
completedAt: "2026-04-13T10:05:00.000Z",
|
|
327
|
+
durationMs: 300000,
|
|
328
|
+
progress: true,
|
|
329
|
+
changedFiles: ["notes/findings.md", "src/index.ts"],
|
|
330
|
+
noProgressStreak: 0,
|
|
331
|
+
completionPromiseMatched: true,
|
|
332
|
+
completionGate: { ready: false, reasons: ["Missing required output: ARCHITECTURE.md"] },
|
|
333
|
+
completion: makeCompletionRecord({
|
|
334
|
+
promiseSeen: true,
|
|
335
|
+
durableProgressObserved: true,
|
|
336
|
+
gateChecked: true,
|
|
337
|
+
gatePassed: false,
|
|
338
|
+
gateBlocked: true,
|
|
339
|
+
blockingReasons: ["Missing required output: ARCHITECTURE.md"],
|
|
340
|
+
}),
|
|
341
|
+
snapshotTruncated: false,
|
|
342
|
+
snapshotErrorCount: 0,
|
|
343
|
+
};
|
|
344
|
+
appendIterationRecord(taskDir, record);
|
|
345
|
+
const records = readIterationRecords(taskDir);
|
|
346
|
+
assert.equal(records.length, 1);
|
|
347
|
+
assert.deepEqual(records[0], record);
|
|
348
|
+
} finally {
|
|
349
|
+
rmSync(taskDir, { recursive: true, force: true });
|
|
350
|
+
}
|
|
351
|
+
});
|
|
352
|
+
|
|
353
|
+
// --- Runner status progression ---
|
|
354
|
+
|
|
355
|
+
test("runner status follows expected lifecycle", () => {
|
|
356
|
+
const taskDir = createTempDir();
|
|
357
|
+
try {
|
|
358
|
+
ensureRunnerDir(taskDir);
|
|
359
|
+
const token = "lifecycle-test";
|
|
360
|
+
|
|
361
|
+
// initializing
|
|
362
|
+
writeStatusFile(taskDir, makeStatusFile({ taskDir, status: "initializing", loopToken: token, currentIteration: 0 }));
|
|
363
|
+
assert.equal(readStatusFile(taskDir)?.status, "initializing");
|
|
364
|
+
|
|
365
|
+
// running iteration 1
|
|
366
|
+
writeStatusFile(taskDir, makeStatusFile({ taskDir, status: "running", loopToken: token, currentIteration: 1 }));
|
|
367
|
+
assert.equal(readStatusFile(taskDir)?.status, "running");
|
|
368
|
+
|
|
369
|
+
// complete
|
|
370
|
+
writeStatusFile(taskDir, makeStatusFile({ taskDir, status: "complete", loopToken: token, currentIteration: 3 }));
|
|
371
|
+
assert.equal(readStatusFile(taskDir)?.status, "complete");
|
|
372
|
+
assert.equal(readStatusFile(taskDir)?.currentIteration, 3);
|
|
373
|
+
} finally {
|
|
374
|
+
rmSync(taskDir, { recursive: true, force: true });
|
|
375
|
+
}
|
|
376
|
+
});
|
|
377
|
+
|
|
378
|
+
test("writeIterationTranscript writes a human-reviewable markdown transcript", () => {
|
|
379
|
+
const taskDir = createTempDir();
|
|
380
|
+
try {
|
|
381
|
+
const transcriptPath = writeIterationTranscript(taskDir, {
|
|
382
|
+
record: makeIterationRecord({
|
|
383
|
+
iteration: 2,
|
|
384
|
+
status: "complete",
|
|
385
|
+
progress: true,
|
|
386
|
+
changedFiles: ["notes/findings.md", "src/index.ts"],
|
|
387
|
+
noProgressStreak: 0,
|
|
388
|
+
completionPromiseMatched: true,
|
|
389
|
+
completionGate: { ready: false, reasons: ["Missing required output: ARCHITECTURE.md"] },
|
|
390
|
+
completion: makeCompletionRecord({
|
|
391
|
+
promiseSeen: true,
|
|
392
|
+
durableProgressObserved: true,
|
|
393
|
+
gateChecked: true,
|
|
394
|
+
gatePassed: false,
|
|
395
|
+
gateBlocked: true,
|
|
396
|
+
blockingReasons: ["Missing required output: ARCHITECTURE.md"],
|
|
397
|
+
}),
|
|
398
|
+
}),
|
|
399
|
+
prompt: "Rendered prompt for iteration 2",
|
|
400
|
+
commandOutputs: [{ name: "tests", output: "all green" }],
|
|
401
|
+
assistantText: "Finished the task.",
|
|
402
|
+
});
|
|
403
|
+
|
|
404
|
+
assert.ok(transcriptPath.includes(".ralph-runner/transcripts"));
|
|
405
|
+
const raw = readFileSync(transcriptPath, "utf8");
|
|
406
|
+
assert.ok(raw.includes("Iteration 2"));
|
|
407
|
+
assert.ok(raw.includes("Status: complete"));
|
|
408
|
+
assert.ok(raw.includes("Rendered prompt for iteration 2"));
|
|
409
|
+
assert.ok(raw.includes("tests"));
|
|
410
|
+
assert.ok(raw.includes("all green"));
|
|
411
|
+
assert.ok(raw.includes("Finished the task."));
|
|
412
|
+
assert.ok(raw.includes("Completion promise seen: yes"));
|
|
413
|
+
assert.ok(raw.includes("Durable progress observed: yes"));
|
|
414
|
+
assert.ok(raw.includes("Completion gate checked: yes"));
|
|
415
|
+
assert.ok(raw.includes("Completion gate: blocked"));
|
|
416
|
+
assert.ok(raw.includes("Missing required output: ARCHITECTURE.md"));
|
|
417
|
+
assert.ok(raw.includes("notes/findings.md"));
|
|
418
|
+
assert.ok(raw.includes("src/index.ts"));
|
|
419
|
+
} finally {
|
|
420
|
+
rmSync(taskDir, { recursive: true, force: true });
|
|
421
|
+
}
|
|
422
|
+
});
|
|
423
|
+
|
|
424
|
+
test("active loop registry prunes stale entries and preserves fresh ones", () => {
|
|
425
|
+
const cwd = createTempDir();
|
|
426
|
+
try {
|
|
427
|
+
const taskDir = join(cwd, "fresh-task");
|
|
428
|
+
const staleTaskDir = join(cwd, "stale-task");
|
|
429
|
+
mkdirSync(taskDir, { recursive: true });
|
|
430
|
+
mkdirSync(staleTaskDir, { recursive: true });
|
|
431
|
+
|
|
432
|
+
const freshEntry: ActiveLoopRegistryEntry = {
|
|
433
|
+
taskDir,
|
|
434
|
+
ralphPath: join(taskDir, "RALPH.md"),
|
|
435
|
+
cwd,
|
|
436
|
+
loopToken: "fresh-loop-token",
|
|
437
|
+
status: "running",
|
|
438
|
+
currentIteration: 3,
|
|
439
|
+
maxIterations: 5,
|
|
440
|
+
startedAt: new Date().toISOString(),
|
|
441
|
+
updatedAt: new Date().toISOString(),
|
|
442
|
+
};
|
|
443
|
+
const staleEntry: ActiveLoopRegistryEntry = {
|
|
444
|
+
taskDir: staleTaskDir,
|
|
445
|
+
ralphPath: join(staleTaskDir, "RALPH.md"),
|
|
446
|
+
cwd,
|
|
447
|
+
loopToken: "stale-loop-token",
|
|
448
|
+
status: "running",
|
|
449
|
+
currentIteration: 1,
|
|
450
|
+
maxIterations: 5,
|
|
451
|
+
startedAt: new Date(Date.now() - 3 * 60 * 60 * 1000).toISOString(),
|
|
452
|
+
updatedAt: new Date(Date.now() - 3 * 60 * 60 * 1000).toISOString(),
|
|
453
|
+
};
|
|
454
|
+
|
|
455
|
+
writeActiveLoopRegistryEntry(cwd, freshEntry);
|
|
456
|
+
writeActiveLoopRegistryEntry(cwd, staleEntry);
|
|
457
|
+
|
|
458
|
+
const activeEntries = listActiveLoopRegistryEntries(cwd);
|
|
459
|
+
assert.deepEqual(activeEntries.map((entry) => entry.taskDir), [taskDir]);
|
|
460
|
+
} finally {
|
|
461
|
+
rmSync(cwd, { recursive: true, force: true });
|
|
462
|
+
}
|
|
463
|
+
});
|
|
464
|
+
|
|
465
|
+
test("active loop registry reads legacy active-loops.json files", () => {
|
|
466
|
+
const cwd = createTempDir();
|
|
467
|
+
try {
|
|
468
|
+
const taskDir = join(cwd, "legacy-task");
|
|
469
|
+
mkdirSync(taskDir, { recursive: true });
|
|
470
|
+
ensureRunnerDir(cwd);
|
|
471
|
+
|
|
472
|
+
const entry: ActiveLoopRegistryEntry = {
|
|
473
|
+
taskDir,
|
|
474
|
+
ralphPath: join(taskDir, "RALPH.md"),
|
|
475
|
+
cwd,
|
|
476
|
+
loopToken: "legacy-loop-token",
|
|
477
|
+
status: "running",
|
|
478
|
+
currentIteration: 2,
|
|
479
|
+
maxIterations: 4,
|
|
480
|
+
startedAt: new Date().toISOString(),
|
|
481
|
+
updatedAt: new Date().toISOString(),
|
|
482
|
+
};
|
|
483
|
+
|
|
484
|
+
writeFileSync(join(cwd, ".ralph-runner", "active-loops.json"), JSON.stringify([entry], null, 2), "utf8");
|
|
485
|
+
|
|
486
|
+
assert.deepEqual(readActiveLoopRegistry(cwd), [entry]);
|
|
487
|
+
assert.deepEqual(listActiveLoopRegistryEntries(cwd), [entry]);
|
|
488
|
+
} finally {
|
|
489
|
+
rmSync(cwd, { recursive: true, force: true });
|
|
490
|
+
}
|
|
491
|
+
});
|
|
492
|
+
|
|
493
|
+
test("active loop registry prunes stale legacy active-loops.json entries", () => {
|
|
494
|
+
const cwd = createTempDir();
|
|
495
|
+
try {
|
|
496
|
+
const taskDir = join(cwd, "legacy-stale-task");
|
|
497
|
+
mkdirSync(taskDir, { recursive: true });
|
|
498
|
+
ensureRunnerDir(cwd);
|
|
499
|
+
|
|
500
|
+
const staleEntry: ActiveLoopRegistryEntry = {
|
|
501
|
+
taskDir,
|
|
502
|
+
ralphPath: join(taskDir, "RALPH.md"),
|
|
503
|
+
cwd,
|
|
504
|
+
loopToken: "legacy-stale-loop-token",
|
|
505
|
+
status: "running",
|
|
506
|
+
currentIteration: 2,
|
|
507
|
+
maxIterations: 4,
|
|
508
|
+
startedAt: new Date(Date.now() - 3 * 60 * 60 * 1000).toISOString(),
|
|
509
|
+
updatedAt: new Date(Date.now() - 3 * 60 * 60 * 1000).toISOString(),
|
|
510
|
+
};
|
|
511
|
+
|
|
512
|
+
writeFileSync(join(cwd, ".ralph-runner", "active-loops.json"), JSON.stringify([staleEntry], null, 2), "utf8");
|
|
513
|
+
|
|
514
|
+
assert.deepEqual(readActiveLoopRegistry(cwd), []);
|
|
515
|
+
assert.deepEqual(listActiveLoopRegistryEntries(cwd), []);
|
|
516
|
+
} finally {
|
|
517
|
+
rmSync(cwd, { recursive: true, force: true });
|
|
518
|
+
}
|
|
519
|
+
});
|
|
520
|
+
|
|
521
|
+
test("active loop registry records stop request and observation timestamps", () => {
|
|
522
|
+
const cwd = createTempDir();
|
|
523
|
+
try {
|
|
524
|
+
const taskDir = join(cwd, "registry-task");
|
|
525
|
+
mkdirSync(taskDir, { recursive: true });
|
|
526
|
+
const entry: ActiveLoopRegistryEntry = {
|
|
527
|
+
taskDir,
|
|
528
|
+
ralphPath: join(taskDir, "RALPH.md"),
|
|
529
|
+
cwd,
|
|
530
|
+
loopToken: "registry-loop-token",
|
|
531
|
+
status: "running",
|
|
532
|
+
currentIteration: 4,
|
|
533
|
+
maxIterations: 7,
|
|
534
|
+
startedAt: new Date().toISOString(),
|
|
535
|
+
updatedAt: new Date().toISOString(),
|
|
536
|
+
};
|
|
537
|
+
|
|
538
|
+
writeActiveLoopRegistryEntry(cwd, entry);
|
|
539
|
+
|
|
540
|
+
const requestedAt = new Date().toISOString();
|
|
541
|
+
const requested = recordActiveLoopStopRequest(cwd, taskDir, requestedAt);
|
|
542
|
+
assert.equal(requested?.stopRequestedAt, requestedAt);
|
|
543
|
+
assert.equal(listActiveLoopRegistryEntries(cwd).length, 1);
|
|
544
|
+
|
|
545
|
+
const observedAt = new Date(Date.now() + 1000).toISOString();
|
|
546
|
+
const observed = recordActiveLoopStopObservation(cwd, taskDir, observedAt);
|
|
547
|
+
assert.equal(observed?.stopObservedAt, observedAt);
|
|
548
|
+
assert.equal(observed?.status, "stopped");
|
|
549
|
+
assert.deepEqual(listActiveLoopRegistryEntries(cwd), []);
|
|
550
|
+
} finally {
|
|
551
|
+
rmSync(cwd, { recursive: true, force: true });
|
|
552
|
+
}
|
|
553
|
+
});
|