@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.
Files changed (46) hide show
  1. package/.github/workflows/release.yml +8 -39
  2. package/README.md +50 -160
  3. package/package.json +2 -2
  4. package/scripts/version-helper.ts +210 -0
  5. package/src/index.ts +1085 -188
  6. package/src/ralph-draft-context.ts +618 -0
  7. package/src/ralph-draft-llm.ts +297 -0
  8. package/src/ralph-draft.ts +33 -0
  9. package/src/ralph.ts +917 -102
  10. package/src/runner-rpc.ts +434 -0
  11. package/src/runner-state.ts +822 -0
  12. package/src/runner.ts +957 -0
  13. package/src/secret-paths.ts +66 -0
  14. package/src/shims.d.ts +0 -3
  15. package/tests/fixtures/parity/migrate/OPEN_QUESTIONS.md +3 -0
  16. package/tests/fixtures/parity/migrate/RALPH.md +27 -0
  17. package/tests/fixtures/parity/migrate/golden/MIGRATED.md +15 -0
  18. package/tests/fixtures/parity/migrate/legacy/source.md +6 -0
  19. package/tests/fixtures/parity/migrate/legacy/source.yaml +3 -0
  20. package/tests/fixtures/parity/migrate/scripts/show-legacy.sh +10 -0
  21. package/tests/fixtures/parity/migrate/scripts/verify.sh +15 -0
  22. package/tests/fixtures/parity/research/OPEN_QUESTIONS.md +3 -0
  23. package/tests/fixtures/parity/research/RALPH.md +45 -0
  24. package/tests/fixtures/parity/research/claim-evidence-checklist.md +15 -0
  25. package/tests/fixtures/parity/research/expected-outputs.md +22 -0
  26. package/tests/fixtures/parity/research/scripts/show-snapshots.sh +13 -0
  27. package/tests/fixtures/parity/research/scripts/verify.sh +55 -0
  28. package/tests/fixtures/parity/research/snapshots/app-factory-ai-cli.md +11 -0
  29. package/tests/fixtures/parity/research/snapshots/docs-factory-ai-cli-features-missions.md +11 -0
  30. package/tests/fixtures/parity/research/snapshots/factory-ai-news-missions.md +11 -0
  31. package/tests/fixtures/parity/research/source-manifest.md +20 -0
  32. package/tests/index.test.ts +3529 -0
  33. package/tests/parity/README.md +9 -0
  34. package/tests/parity/harness.py +526 -0
  35. package/tests/parity-harness.test.ts +42 -0
  36. package/tests/parity-research-fixture.test.ts +34 -0
  37. package/tests/ralph-draft-context.test.ts +672 -0
  38. package/tests/ralph-draft-llm.test.ts +434 -0
  39. package/tests/ralph-draft.test.ts +168 -0
  40. package/tests/ralph.test.ts +1389 -19
  41. package/tests/runner-event-contract.test.ts +235 -0
  42. package/tests/runner-rpc.test.ts +358 -0
  43. package/tests/runner-state.test.ts +553 -0
  44. package/tests/runner.test.ts +1347 -0
  45. package/tests/secret-paths.test.ts +55 -0
  46. package/tests/version-helper.test.ts +75 -0
package/src/runner.ts ADDED
@@ -0,0 +1,957 @@
1
+ import { existsSync, readFileSync } from "node:fs";
2
+ import { basename, dirname } from "node:path";
3
+ import { randomUUID } from "node:crypto";
4
+ import {
5
+ inspectDraftContent,
6
+ renderRalphBody,
7
+ renderIterationPrompt,
8
+ shouldStopForCompletionPromise,
9
+ validateRuntimeArgs,
10
+ type CommandDef,
11
+ type CommandOutput,
12
+ type RuntimeArgs,
13
+ } from "./ralph.ts";
14
+ import { runCommands } from "./index.ts";
15
+ import {
16
+ type CompletionRecord,
17
+ type IterationRecord,
18
+ type ProgressState,
19
+ type RunnerEvent,
20
+ type RunnerStatus,
21
+ type RunnerStatusFile,
22
+ appendIterationRecord,
23
+ appendRunnerEvent,
24
+ checkStopSignal,
25
+ clearRunnerDir,
26
+ clearStopSignal,
27
+ ensureRunnerDir,
28
+ readActiveLoopRegistry,
29
+ readIterationRecords,
30
+ readStatusFile,
31
+ recordActiveLoopStopObservation,
32
+ writeActiveLoopRegistryEntry,
33
+ writeIterationTranscript,
34
+ writeStatusFile,
35
+ } from "./runner-state.ts";
36
+ import {
37
+ type RpcSubprocessResult,
38
+ runRpcIteration,
39
+ } from "./runner-rpc.ts";
40
+ import { createHash } from "node:crypto";
41
+ import {
42
+ readdirSync,
43
+ readFileSync as readFileSyncForSnapshot,
44
+ statSync,
45
+ } from "node:fs";
46
+ import { join, relative, resolve } from "node:path";
47
+
48
+ // --- Types ---
49
+
50
+ export type RunnerConfig = {
51
+ ralphPath: string;
52
+ cwd: string;
53
+ timeout: number;
54
+ maxIterations: number;
55
+ /** Completion promise string from RALPH.md */
56
+ completionPromise?: string;
57
+ guardrails: { blockCommands: string[]; protectedFiles: string[] };
58
+ /** Override for the RPC spawn command, for testing */
59
+ spawnCommand?: string;
60
+ /** Override for the RPC spawn args, for testing */
61
+ spawnArgs?: string[];
62
+ /** Model pattern for RPC subprocess, e.g. "openai-codex/gpt-5.4-mini" or "anthropic/claude-sonnet-4"
63
+ * Format: "provider/modelId" or "provider/modelId:thinkingLevel"
64
+ * The thinking level suffix (e.g. ":high") is sent via set_thinking_level.
65
+ */
66
+ modelPattern?: string;
67
+ /** Provider for set_model (overrides modelPattern provider) */
68
+ provider?: string;
69
+ /** ModelId for set_model (overrides modelPattern modelId) */
70
+ modelId?: string;
71
+ /** Thinking level for set_thinking_level: "off", "minimal", "low", "medium", "high", "xhigh" */
72
+ thinkingLevel?: string;
73
+ /** Callbacks */
74
+ onIterationStart?: (iteration: number, maxIterations: number) => void;
75
+ onIterationComplete?: (record: IterationRecord) => void;
76
+ onStatusChange?: (status: RunnerStatus) => void;
77
+ onNotify?: (message: string, level: "info" | "warning" | "error") => void;
78
+ /** Extension API for running commands */
79
+ runCommandsFn?: (
80
+ commands: CommandDef[],
81
+ blockPatterns: string[],
82
+ pi: unknown,
83
+ cwd?: string,
84
+ taskDir?: string,
85
+ ) => Promise<CommandOutput[]>;
86
+ /** Extension API reference for running commands */
87
+ pi?: unknown;
88
+ /** Runtime args resolved from RALPH frontmatter */
89
+ runtimeArgs?: RuntimeArgs;
90
+ };
91
+
92
+ export type RunnerResult = {
93
+ status: RunnerStatus;
94
+ iterations: IterationRecord[];
95
+ totalDurationMs: number;
96
+ };
97
+
98
+ // --- Task directory snapshot ---
99
+
100
+ const SNAPSHOT_IGNORED_DIR_NAMES = new Set([
101
+ ".git",
102
+ "node_modules",
103
+ ".next",
104
+ ".turbo",
105
+ ".cache",
106
+ "coverage",
107
+ "dist",
108
+ "build",
109
+ ".ralph-runner",
110
+ ]);
111
+
112
+ const SNAPSHOT_MAX_FILES = 200;
113
+ const SNAPSHOT_MAX_BYTES = 2 * 1024 * 1024;
114
+ const SNAPSHOT_POST_IDLE_POLL_INTERVAL_MS = 20;
115
+ const SNAPSHOT_POST_IDLE_POLL_WINDOW_MS = 100;
116
+ const RALPH_PROGRESS_FILE = "RALPH_PROGRESS.md";
117
+ const RALPH_PROGRESS_MAX_CHARS = 4096;
118
+ const INTER_ITERATION_DELAY_POLL_INTERVAL_MS = 100;
119
+
120
+ export type WorkspaceSnapshot = {
121
+ files: Map<string, string>;
122
+ truncated: boolean;
123
+ errorCount: number;
124
+ };
125
+
126
+ export type ProgressAssessment = {
127
+ progress: ProgressState;
128
+ changedFiles: string[];
129
+ snapshotTruncated: boolean;
130
+ snapshotErrorCount: number;
131
+ };
132
+
133
+ function delay(ms: number): Promise<void> {
134
+ return new Promise((resolve) => setTimeout(resolve, ms));
135
+ }
136
+
137
+ function createCompletionRecord(): CompletionRecord {
138
+ return {
139
+ promiseSeen: false,
140
+ durableProgressObserved: false,
141
+ gateChecked: false,
142
+ gatePassed: false,
143
+ gateBlocked: false,
144
+ blockingReasons: [],
145
+ };
146
+ }
147
+
148
+ function logRunnerEvent(taskDir: string, event: RunnerEvent): void {
149
+ try {
150
+ appendRunnerEvent(taskDir, event);
151
+ } catch {
152
+ // Event logging should not break the runner.
153
+ }
154
+ }
155
+
156
+ function readProgressMemory(taskDir: string): string | undefined {
157
+ const progressPath = join(taskDir, RALPH_PROGRESS_FILE);
158
+ if (!existsSync(progressPath)) return undefined;
159
+ try {
160
+ const raw = readFileSync(progressPath, "utf8");
161
+ if (raw.length <= RALPH_PROGRESS_MAX_CHARS) return raw;
162
+ return `${raw.slice(0, RALPH_PROGRESS_MAX_CHARS)}\n[truncated]`;
163
+ } catch {
164
+ return undefined;
165
+ }
166
+ }
167
+
168
+ function renderProgressMemoryPrompt(progressMemory: string): string {
169
+ return [
170
+ "[RALPH_PROGRESS.md]",
171
+ "Use this file for a short rolling memory. Keep it short and overwrite in place.",
172
+ "",
173
+ progressMemory,
174
+ ].join("\n");
175
+ }
176
+
177
+ async function waitForInterIterationDelay(taskDir: string, cwd: string, delaySeconds: number): Promise<boolean> {
178
+ const delayMs = delaySeconds * 1000;
179
+ if (delayMs <= 0) return false;
180
+
181
+ const pollIntervalMs = Math.min(INTER_ITERATION_DELAY_POLL_INTERVAL_MS, delayMs);
182
+ let remainingMs = delayMs;
183
+
184
+ while (remainingMs > 0) {
185
+ if (checkStopSignal(taskDir)) {
186
+ recordActiveLoopStopObservation(cwd, taskDir, new Date().toISOString());
187
+ clearStopSignal(taskDir);
188
+ return true;
189
+ }
190
+ const sleepMs = Math.min(pollIntervalMs, remainingMs);
191
+ await delay(sleepMs);
192
+ remainingMs -= sleepMs;
193
+ }
194
+
195
+ return false;
196
+ }
197
+
198
+ function normalizeSnapshotPath(filePath: string): string {
199
+ return filePath.split("\\").join("/");
200
+ }
201
+
202
+ export function captureTaskDirectorySnapshot(ralphPath: string): WorkspaceSnapshot {
203
+ const taskDir = dirname(ralphPath);
204
+ const files = new Map<string, string>();
205
+ let truncated = false;
206
+ let bytesRead = 0;
207
+ let errorCount = 0;
208
+
209
+ const progressMemoryPath = join(taskDir, RALPH_PROGRESS_FILE);
210
+
211
+ const walk = (dirPath: string) => {
212
+ let entries;
213
+ try {
214
+ entries = readdirSync(dirPath, { withFileTypes: true }).sort((a, b) =>
215
+ a.name.localeCompare(b.name),
216
+ );
217
+ } catch {
218
+ errorCount += 1;
219
+ return;
220
+ }
221
+
222
+ for (const entry of entries) {
223
+ if (truncated) return;
224
+ const fullPath = join(dirPath, entry.name);
225
+
226
+ if (entry.isDirectory()) {
227
+ if (SNAPSHOT_IGNORED_DIR_NAMES.has(entry.name)) continue;
228
+ walk(fullPath);
229
+ continue;
230
+ }
231
+ if (!entry.isFile() || fullPath === ralphPath || fullPath === progressMemoryPath) continue;
232
+ if (files.size >= SNAPSHOT_MAX_FILES) {
233
+ truncated = true;
234
+ return;
235
+ }
236
+
237
+ const relPath = normalizeSnapshotPath(relative(taskDir, fullPath));
238
+ if (!relPath || relPath.startsWith("..")) continue;
239
+
240
+ let content;
241
+ try {
242
+ content = readFileSyncForSnapshot(fullPath);
243
+ } catch {
244
+ errorCount += 1;
245
+ continue;
246
+ }
247
+ if (bytesRead + content.byteLength > SNAPSHOT_MAX_BYTES) {
248
+ truncated = true;
249
+ return;
250
+ }
251
+
252
+ bytesRead += content.byteLength;
253
+ files.set(relPath, `${content.byteLength}:${createHash("sha1").update(content).digest("hex")}`);
254
+ }
255
+ };
256
+
257
+ if (existsSync(taskDir)) walk(taskDir);
258
+ return { files, truncated, errorCount };
259
+ }
260
+
261
+ function diffTaskDirectorySnapshots(
262
+ before: WorkspaceSnapshot,
263
+ after: WorkspaceSnapshot,
264
+ ): string[] {
265
+ const changed = new Set<string>();
266
+ for (const [filePath, fingerprint] of before.files) {
267
+ if (after.files.get(filePath) !== fingerprint) changed.add(filePath);
268
+ }
269
+ for (const filePath of after.files.keys()) {
270
+ if (!before.files.has(filePath)) changed.add(filePath);
271
+ }
272
+ return [...changed].sort((a, b) => a.localeCompare(b));
273
+ }
274
+
275
+ export async function assessTaskDirectoryProgress(
276
+ ralphPath: string,
277
+ before: WorkspaceSnapshot,
278
+ ): Promise<ProgressAssessment> {
279
+ let after = captureTaskDirectorySnapshot(ralphPath);
280
+ let changedFiles = diffTaskDirectorySnapshots(before, after);
281
+ let snapshotTruncated = before.truncated || after.truncated;
282
+ let snapshotErrorCount = before.errorCount + after.errorCount;
283
+
284
+ if (changedFiles.length > 0) {
285
+ return { progress: true, changedFiles, snapshotTruncated, snapshotErrorCount };
286
+ }
287
+
288
+ for (
289
+ let remainingMs = SNAPSHOT_POST_IDLE_POLL_WINDOW_MS;
290
+ remainingMs > 0;
291
+ remainingMs -= SNAPSHOT_POST_IDLE_POLL_INTERVAL_MS
292
+ ) {
293
+ await delay(Math.min(SNAPSHOT_POST_IDLE_POLL_INTERVAL_MS, remainingMs));
294
+ after = captureTaskDirectorySnapshot(ralphPath);
295
+ changedFiles = diffTaskDirectorySnapshots(before, after);
296
+ snapshotTruncated ||= after.truncated;
297
+ snapshotErrorCount += after.errorCount;
298
+ if (changedFiles.length > 0) {
299
+ return { progress: true, changedFiles, snapshotTruncated, snapshotErrorCount };
300
+ }
301
+ }
302
+
303
+ return {
304
+ progress: snapshotTruncated || snapshotErrorCount > 0 ? "unknown" : false,
305
+ changedFiles,
306
+ snapshotTruncated,
307
+ snapshotErrorCount,
308
+ };
309
+ }
310
+
311
+ export function summarizeChangedFiles(changedFiles: string[]): string {
312
+ if (changedFiles.length === 0) return "none";
313
+ const visible = changedFiles.slice(0, 5);
314
+ if (visible.length === changedFiles.length) return visible.join(", ");
315
+ return `${visible.join(", ")} (+${changedFiles.length - visible.length} more)`;
316
+ }
317
+
318
+ export type CompletionReadiness = {
319
+ ready: boolean;
320
+ reasons: string[];
321
+ };
322
+
323
+ function addReadinessReason(reasons: string[], reason: string): void {
324
+ if (!reasons.includes(reason)) {
325
+ reasons.push(reason);
326
+ }
327
+ }
328
+
329
+ function collectOpenQuestionsBlockingReasons(raw: string): string[] {
330
+ const reasons: string[] = [];
331
+ let currentPriority: "P0" | "P1" | undefined;
332
+ let currentPriorityDepth = 0;
333
+ let sawP0 = false;
334
+ let sawP1 = false;
335
+
336
+ for (const line of raw.split(/\r?\n/)) {
337
+ const priorityHeading = line.match(/^(#{1,6})\s+(P0|P1)\b/i);
338
+ if (priorityHeading) {
339
+ currentPriority = priorityHeading[2].toUpperCase() as "P0" | "P1";
340
+ currentPriorityDepth = priorityHeading[1].length;
341
+ continue;
342
+ }
343
+
344
+ const heading = line.match(/^(#{1,6})\s+/);
345
+ if (heading && currentPriority && heading[1].length <= currentPriorityDepth) {
346
+ currentPriority = undefined;
347
+ currentPriorityDepth = 0;
348
+ continue;
349
+ }
350
+
351
+ if (!currentPriority) continue;
352
+
353
+ const bullet = line.match(/^(?:[-*+]|\d+\.)\s+(.*)$/);
354
+ if (!bullet) continue;
355
+
356
+ const content = bullet[1].trim();
357
+ if (!content || /^\[[xX]\]\s*/.test(content)) continue;
358
+
359
+ if (currentPriority === "P0") sawP0 = true;
360
+ if (currentPriority === "P1") sawP1 = true;
361
+ }
362
+
363
+ if (sawP0) reasons.push("OPEN_QUESTIONS.md still has P0 items");
364
+ if (sawP1) reasons.push("OPEN_QUESTIONS.md still has P1 items");
365
+ return reasons;
366
+ }
367
+
368
+ export function validateCompletionReadiness(taskDir: string, requiredOutputs: string[]): CompletionReadiness {
369
+ const reasons: string[] = [];
370
+
371
+ for (const requiredOutput of requiredOutputs) {
372
+ const filePath = join(taskDir, requiredOutput);
373
+ if (!existsSync(filePath) || !statSync(filePath).isFile()) {
374
+ addReadinessReason(reasons, `Missing required output: ${requiredOutput}`);
375
+ }
376
+ }
377
+
378
+ const openQuestionsPath = join(taskDir, "OPEN_QUESTIONS.md");
379
+ if (!existsSync(openQuestionsPath) || !statSync(openQuestionsPath).isFile()) {
380
+ addReadinessReason(reasons, "Missing OPEN_QUESTIONS.md");
381
+ } else {
382
+ try {
383
+ const raw = readFileSync(openQuestionsPath, "utf8");
384
+ for (const reason of collectOpenQuestionsBlockingReasons(raw)) {
385
+ addReadinessReason(reasons, reason);
386
+ }
387
+ } catch {
388
+ addReadinessReason(reasons, "Missing OPEN_QUESTIONS.md");
389
+ }
390
+ }
391
+
392
+ return { ready: reasons.length === 0, reasons };
393
+ }
394
+
395
+ // --- Core Runner ---
396
+
397
+ export async function runRalphLoop(config: RunnerConfig): Promise<RunnerResult> {
398
+ const {
399
+ ralphPath,
400
+ cwd,
401
+ timeout,
402
+ maxIterations: initialMaxIterations,
403
+ completionPromise: initialCompletionPromise,
404
+ guardrails: initialGuardrails,
405
+ spawnCommand,
406
+ spawnArgs,
407
+ onIterationStart,
408
+ onIterationComplete,
409
+ onStatusChange,
410
+ onNotify,
411
+ runCommandsFn,
412
+ pi,
413
+ runtimeArgs: initialRuntimeArgs = {},
414
+ } = config;
415
+ const runtimeArgs = initialRuntimeArgs;
416
+
417
+ const taskDir = dirname(ralphPath);
418
+ const name = basename(taskDir);
419
+ const loopToken = randomUUID();
420
+ let currentMaxIterations = initialMaxIterations;
421
+ let currentTimeout = timeout;
422
+ let currentCompletionPromise = initialCompletionPromise;
423
+ let currentRequiredOutputs: string[] = [];
424
+ let currentInterIterationDelay = 0;
425
+ let currentGuardrails = initialGuardrails;
426
+ let completionGateFailureReasons: string[] = [];
427
+ let completionGateRejectionReasons: string[] = [];
428
+ let noProgressStreak = 0;
429
+ const iterations: IterationRecord[] = [];
430
+ const startMs = Date.now();
431
+
432
+ // Initialize durable state
433
+ ensureRunnerDir(taskDir);
434
+ const initialStatus: RunnerStatusFile = {
435
+ loopToken,
436
+ ralphPath,
437
+ taskDir,
438
+ cwd,
439
+ status: "initializing",
440
+ currentIteration: 0,
441
+ maxIterations: currentMaxIterations,
442
+ timeout: currentTimeout,
443
+ completionPromise: currentCompletionPromise,
444
+ startedAt: new Date().toISOString(),
445
+ guardrails: { blockCommands: currentGuardrails.blockCommands, protectedFiles: currentGuardrails.protectedFiles },
446
+ };
447
+ let latestRegistryStatus = initialStatus;
448
+ const syncActiveLoopRegistry = (statusFile: RunnerStatusFile): void => {
449
+ latestRegistryStatus = statusFile;
450
+ const existing = readActiveLoopRegistry(cwd).find((entry) => entry.taskDir === taskDir && entry.loopToken === loopToken);
451
+ writeActiveLoopRegistryEntry(cwd, {
452
+ taskDir,
453
+ ralphPath,
454
+ cwd,
455
+ loopToken,
456
+ status: statusFile.status,
457
+ currentIteration: statusFile.currentIteration,
458
+ maxIterations: statusFile.maxIterations,
459
+ startedAt: statusFile.startedAt,
460
+ updatedAt: statusFile.completedAt ?? new Date().toISOString(),
461
+ stopRequestedAt: existing?.stopRequestedAt,
462
+ stopObservedAt: existing?.stopObservedAt,
463
+ });
464
+ };
465
+ const activeLoopHeartbeat = setInterval(() => {
466
+ syncActiveLoopRegistry(latestRegistryStatus);
467
+ }, 60_000);
468
+ activeLoopHeartbeat.unref?.();
469
+ writeStatusFile(taskDir, initialStatus);
470
+ syncActiveLoopRegistry(initialStatus);
471
+ logRunnerEvent(taskDir, {
472
+ type: "runner.started",
473
+ timestamp: initialStatus.startedAt,
474
+ loopToken,
475
+ cwd,
476
+ taskDir,
477
+ status: "initializing",
478
+ maxIterations: currentMaxIterations,
479
+ timeout: currentTimeout,
480
+ completionPromise: currentCompletionPromise,
481
+ guardrails: currentGuardrails,
482
+ });
483
+ onStatusChange?.("initializing");
484
+ onNotify?.(`Ralph runner started: ${name} (max ${currentMaxIterations} iterations)`, "info");
485
+
486
+ let finalStatus: RunnerStatus = "running";
487
+
488
+ try {
489
+ for (let i = 1; i <= currentMaxIterations; i++) {
490
+ // Check stop signal from durable state
491
+ if (checkStopSignal(taskDir)) {
492
+ recordActiveLoopStopObservation(cwd, taskDir, new Date().toISOString());
493
+ finalStatus = "stopped";
494
+ clearStopSignal(taskDir);
495
+ break;
496
+ }
497
+
498
+ // Re-parse RALPH.md every iteration (live editing support)
499
+ if (!existsSync(ralphPath)) {
500
+ onNotify?.(`RALPH.md not found at ${ralphPath}, stopping runner`, "error");
501
+ finalStatus = "error";
502
+ break;
503
+ }
504
+
505
+ const raw = readFileSync(ralphPath, "utf8");
506
+ const inspection = inspectDraftContent(raw);
507
+ if (inspection.error) {
508
+ onNotify?.(`Invalid RALPH.md on iteration ${i}: ${inspection.error}`, "error");
509
+ finalStatus = "error";
510
+ break;
511
+ }
512
+
513
+ const { frontmatter: fm, body: rawBody } = inspection.parsed!;
514
+ const runtimeValidationError = validateRuntimeArgs(fm, rawBody, fm.commands, runtimeArgs);
515
+ if (runtimeValidationError) {
516
+ onNotify?.(`Invalid RALPH.md on iteration ${i}: ${runtimeValidationError}`, "error");
517
+ finalStatus = "error";
518
+ break;
519
+ }
520
+ currentMaxIterations = fm.maxIterations;
521
+ currentTimeout = fm.timeout;
522
+ currentCompletionPromise = fm.completionPromise;
523
+ currentRequiredOutputs = fm.requiredOutputs ?? [];
524
+ currentInterIterationDelay = fm.interIterationDelay;
525
+ currentGuardrails = { blockCommands: fm.guardrails.blockCommands, protectedFiles: fm.guardrails.protectedFiles };
526
+
527
+ // Update status to running
528
+ const runningStatus: RunnerStatusFile = {
529
+ ...initialStatus,
530
+ status: "running",
531
+ currentIteration: i,
532
+ maxIterations: currentMaxIterations,
533
+ timeout: currentTimeout,
534
+ completionPromise: currentCompletionPromise,
535
+ guardrails: { blockCommands: currentGuardrails.blockCommands, protectedFiles: currentGuardrails.protectedFiles },
536
+ };
537
+ writeStatusFile(taskDir, runningStatus);
538
+ syncActiveLoopRegistry(runningStatus);
539
+ onStatusChange?.("running");
540
+ onIterationStart?.(i, currentMaxIterations);
541
+
542
+ const iterStartMs = Date.now();
543
+ const completionRecord = currentCompletionPromise ? createCompletionRecord() : undefined;
544
+ logRunnerEvent(taskDir, {
545
+ type: "iteration.started",
546
+ timestamp: new Date(iterStartMs).toISOString(),
547
+ iteration: i,
548
+ loopToken,
549
+ status: "running",
550
+ maxIterations: currentMaxIterations,
551
+ timeout: currentTimeout,
552
+ completionPromise: currentCompletionPromise,
553
+ });
554
+
555
+ // Run commands
556
+ const commandsOutput: CommandOutput[] = runCommandsFn && pi
557
+ ? await runCommandsFn(fm.commands, currentGuardrails.blockCommands, pi, cwd, taskDir)
558
+ : [];
559
+
560
+ // Before snapshot
561
+ const snapshotBefore = captureTaskDirectorySnapshot(ralphPath);
562
+
563
+ // Render prompt
564
+ const body = renderRalphBody(rawBody, commandsOutput, { iteration: i, name, maxIterations: currentMaxIterations }, runtimeArgs);
565
+ const progressMemory = readProgressMemory(taskDir);
566
+ const promptBody = progressMemory !== undefined ? `${renderProgressMemoryPrompt(progressMemory)}\n\n${body}` : body;
567
+ const prompt = renderIterationPrompt(promptBody, i, currentMaxIterations, currentCompletionPromise ? { completionPromise: currentCompletionPromise, requiredOutputs: currentRequiredOutputs, failureReasons: completionGateFailureReasons, rejectionReasons: completionGateRejectionReasons } : undefined);
568
+ const writeIterationTranscriptSafe = (record: IterationRecord, assistantText?: string, note?: string) => {
569
+ try {
570
+ writeIterationTranscript(taskDir, { record, prompt, commandOutputs: commandsOutput, assistantText, note });
571
+ } catch (err) {
572
+ const message = err instanceof Error ? err.message : String(err);
573
+ onNotify?.(`Failed to write iteration transcript for iteration ${record.iteration}: ${message}`, "warning");
574
+ }
575
+ };
576
+
577
+ // Run RPC iteration
578
+ onNotify?.(`Iteration ${i}/${currentMaxIterations} starting`, "info");
579
+
580
+ const rpcResult = await runRpcIteration({
581
+ prompt,
582
+ cwd,
583
+ timeoutMs: currentTimeout * 1000,
584
+ spawnCommand,
585
+ spawnArgs,
586
+ env: {
587
+ RALPH_RUNNER_TASK_DIR: taskDir,
588
+ RALPH_RUNNER_CWD: cwd,
589
+ RALPH_RUNNER_LOOP_TOKEN: loopToken,
590
+ RALPH_RUNNER_CURRENT_ITERATION: String(i),
591
+ RALPH_RUNNER_MAX_ITERATIONS: String(currentMaxIterations),
592
+ RALPH_RUNNER_NO_PROGRESS_STREAK: String(noProgressStreak),
593
+ RALPH_RUNNER_GUARDRAILS: JSON.stringify(currentGuardrails),
594
+ },
595
+ modelPattern: config.modelPattern,
596
+ provider: config.provider,
597
+ modelId: config.modelId,
598
+ thinkingLevel: config.thinkingLevel,
599
+ });
600
+
601
+ const iterEndMs = Date.now();
602
+
603
+ // Handle RPC failure
604
+ if (!rpcResult.success) {
605
+ const iterRecord: IterationRecord = {
606
+ iteration: i,
607
+ status: rpcResult.timedOut ? "timeout" : "error",
608
+ startedAt: new Date(iterStartMs).toISOString(),
609
+ completedAt: new Date(iterEndMs).toISOString(),
610
+ durationMs: iterEndMs - iterStartMs,
611
+ progress: false,
612
+ changedFiles: [],
613
+ noProgressStreak: noProgressStreak + 1,
614
+ completion: completionRecord,
615
+ loopToken,
616
+ rpcTelemetry: rpcResult.telemetry,
617
+ };
618
+ iterations.push(iterRecord);
619
+ appendIterationRecord(taskDir, iterRecord);
620
+ writeIterationTranscriptSafe(
621
+ iterRecord,
622
+ undefined,
623
+ rpcResult.timedOut
624
+ ? `Timed out after ${currentTimeout}s waiting for the RPC subprocess.`
625
+ : `RPC subprocess error: ${rpcResult.error ?? "unknown"}`,
626
+ );
627
+ logRunnerEvent(taskDir, {
628
+ type: "iteration.completed",
629
+ timestamp: new Date(iterEndMs).toISOString(),
630
+ iteration: i,
631
+ loopToken,
632
+ status: rpcResult.timedOut ? "timeout" : "error",
633
+ progress: iterRecord.progress,
634
+ changedFiles: iterRecord.changedFiles,
635
+ noProgressStreak: iterRecord.noProgressStreak,
636
+ completion: iterRecord.completion,
637
+ reason: rpcResult.timedOut ? "rpc-timeout" : "rpc-error",
638
+ });
639
+
640
+ if (rpcResult.timedOut) {
641
+ onNotify?.(`Iteration ${i} timed out after ${currentTimeout}s`, "warning");
642
+ finalStatus = "timeout";
643
+ } else {
644
+ onNotify?.(`Iteration ${i} error: ${rpcResult.error ?? "unknown"}`, "error");
645
+ finalStatus = "error";
646
+ }
647
+ onIterationComplete?.(iterRecord);
648
+ break;
649
+ }
650
+
651
+ // After snapshot
652
+ const { progress, changedFiles, snapshotTruncated, snapshotErrorCount } =
653
+ await assessTaskDirectoryProgress(ralphPath, snapshotBefore);
654
+
655
+ // Update no-progress streak
656
+ if (progress === true) {
657
+ noProgressStreak = 0;
658
+ completionGateRejectionReasons = [];
659
+ } else if (progress === false) {
660
+ noProgressStreak += 1;
661
+ }
662
+ // "unknown" doesn't increment streak
663
+
664
+ if (completionRecord) {
665
+ completionRecord.durableProgressObserved = progress === true;
666
+ if (progress === true) {
667
+ logRunnerEvent(taskDir, {
668
+ type: "durable.progress.observed",
669
+ timestamp: new Date(iterEndMs).toISOString(),
670
+ iteration: i,
671
+ loopToken,
672
+ progress: true,
673
+ changedFiles,
674
+ snapshotTruncated,
675
+ snapshotErrorCount,
676
+ });
677
+ } else if (progress === false) {
678
+ logRunnerEvent(taskDir, {
679
+ type: "durable.progress.missing",
680
+ timestamp: new Date(iterEndMs).toISOString(),
681
+ iteration: i,
682
+ loopToken,
683
+ progress: false,
684
+ changedFiles,
685
+ snapshotTruncated,
686
+ snapshotErrorCount,
687
+ });
688
+ } else {
689
+ logRunnerEvent(taskDir, {
690
+ type: "durable.progress.unknown",
691
+ timestamp: new Date(iterEndMs).toISOString(),
692
+ iteration: i,
693
+ loopToken,
694
+ progress: "unknown",
695
+ changedFiles,
696
+ snapshotTruncated,
697
+ snapshotErrorCount,
698
+ });
699
+ }
700
+ }
701
+
702
+ // Check completion promise
703
+ let completionPromiseMatched = false;
704
+ if (currentCompletionPromise) {
705
+ for (const msg of rpcResult.agentEndMessages) {
706
+ if (
707
+ typeof msg === "object" &&
708
+ msg !== null &&
709
+ "role" in msg &&
710
+ (msg as Record<string, unknown>).role === "assistant" &&
711
+ "content" in msg
712
+ ) {
713
+ const content = (msg as Record<string, unknown>).content;
714
+ let text = "";
715
+ if (Array.isArray(content)) {
716
+ text = content
717
+ .filter(
718
+ (block: unknown) =>
719
+ typeof block === "object" &&
720
+ block !== null &&
721
+ "type" in block &&
722
+ (block as Record<string, unknown>).type === "text" &&
723
+ "text" in block,
724
+ )
725
+ .map((block: Record<string, unknown>) => String(block.text))
726
+ .join("");
727
+ } else if (typeof content === "string") {
728
+ text = content;
729
+ }
730
+ if (shouldStopForCompletionPromise(text, currentCompletionPromise)) {
731
+ completionPromiseMatched = true;
732
+ if (completionRecord) {
733
+ completionRecord.promiseSeen = true;
734
+ logRunnerEvent(taskDir, {
735
+ type: "completion_promise_seen",
736
+ timestamp: new Date(iterEndMs).toISOString(),
737
+ iteration: i,
738
+ loopToken,
739
+ completionPromise: currentCompletionPromise,
740
+ });
741
+ }
742
+ break;
743
+ }
744
+ }
745
+ }
746
+ }
747
+
748
+ let completionGate: CompletionReadiness | undefined;
749
+ if (completionPromiseMatched && progress !== false) {
750
+ completionGate = validateCompletionReadiness(taskDir, currentRequiredOutputs);
751
+ if (completionRecord) {
752
+ completionRecord.gateChecked = true;
753
+ completionRecord.gatePassed = completionGate.ready;
754
+ completionRecord.gateBlocked = !completionGate.ready;
755
+ completionRecord.blockingReasons = completionGate.reasons;
756
+ logRunnerEvent(taskDir, {
757
+ type: "completion.gate.checked",
758
+ timestamp: new Date(iterEndMs).toISOString(),
759
+ iteration: i,
760
+ loopToken,
761
+ ready: completionGate.ready,
762
+ reasons: completionGate.reasons,
763
+ });
764
+ if (completionGate.ready) {
765
+ logRunnerEvent(taskDir, {
766
+ type: "completion_gate_passed",
767
+ timestamp: new Date(iterEndMs).toISOString(),
768
+ iteration: i,
769
+ loopToken,
770
+ ready: true,
771
+ reasons: completionGate.reasons,
772
+ });
773
+ } else {
774
+ logRunnerEvent(taskDir, {
775
+ type: "completion_gate_blocked",
776
+ timestamp: new Date(iterEndMs).toISOString(),
777
+ iteration: i,
778
+ loopToken,
779
+ ready: false,
780
+ reasons: completionGate.reasons,
781
+ });
782
+ }
783
+ }
784
+ if (!completionGate.ready) {
785
+ completionGateFailureReasons = completionGate.reasons;
786
+ onNotify?.(
787
+ `Completion gate blocked on iteration ${i}: ${completionGate.reasons.join("; ")}`,
788
+ "warning",
789
+ );
790
+ } else {
791
+ completionGateFailureReasons = [];
792
+ }
793
+ }
794
+
795
+ // Build iteration record
796
+ const iterRecord: IterationRecord = {
797
+ iteration: i,
798
+ status: "complete",
799
+ startedAt: new Date(iterStartMs).toISOString(),
800
+ completedAt: new Date(iterEndMs).toISOString(),
801
+ durationMs: iterEndMs - iterStartMs,
802
+ progress,
803
+ changedFiles,
804
+ noProgressStreak,
805
+ loopToken,
806
+ completionPromiseMatched: completionPromiseMatched || undefined,
807
+ completionGate,
808
+ completion: completionRecord,
809
+ snapshotTruncated,
810
+ snapshotErrorCount,
811
+ rpcTelemetry: rpcResult.telemetry,
812
+ };
813
+ iterations.push(iterRecord);
814
+ appendIterationRecord(taskDir, iterRecord);
815
+ writeIterationTranscriptSafe(iterRecord, rpcResult.lastAssistantText);
816
+ logRunnerEvent(taskDir, {
817
+ type: "iteration.completed",
818
+ timestamp: new Date(iterEndMs).toISOString(),
819
+ iteration: i,
820
+ loopToken,
821
+ status: "complete",
822
+ progress: iterRecord.progress,
823
+ changedFiles: iterRecord.changedFiles,
824
+ noProgressStreak: iterRecord.noProgressStreak,
825
+ completionPromiseMatched: iterRecord.completionPromiseMatched,
826
+ completionGate: iterRecord.completionGate,
827
+ completion: iterRecord.completion,
828
+ snapshotTruncated: iterRecord.snapshotTruncated,
829
+ snapshotErrorCount: iterRecord.snapshotErrorCount,
830
+ });
831
+
832
+ // Notify progress
833
+ if (progress === true) {
834
+ onNotify?.(`Iteration ${i} durable progress: ${summarizeChangedFiles(changedFiles)}`, "info");
835
+ } else if (progress === false) {
836
+ onNotify?.(
837
+ `Iteration ${i} made no durable progress. No-progress streak: ${noProgressStreak}.`,
838
+ "warning",
839
+ );
840
+ } else {
841
+ onNotify?.(
842
+ `Iteration ${i} durable progress could not be verified. No-progress streak remains ${noProgressStreak}.`,
843
+ "warning",
844
+ );
845
+ }
846
+
847
+ onIterationComplete?.(iterRecord);
848
+
849
+ // Check completion promise
850
+ if (completionPromiseMatched) {
851
+ if (progress === false) {
852
+ completionGateRejectionReasons = ["durable progress (no durable file changes were observed)"];
853
+ onNotify?.(
854
+ `Completion promise matched on iteration ${i}, but no durable progress was detected. Continuing.`,
855
+ "warning",
856
+ );
857
+ } else if (completionGate && !completionGate.ready) {
858
+ onNotify?.(
859
+ `completion promise matched on iteration ${i}, but the completion gate failed. Continuing.`,
860
+ "warning",
861
+ );
862
+ } else {
863
+ if (progress === "unknown") {
864
+ onNotify?.(
865
+ `Completion promise matched on iteration ${i}, and durable progress could not be verified. Stopping.`,
866
+ "info",
867
+ );
868
+ } else {
869
+ onNotify?.(
870
+ `Completion promise matched after durable progress on iteration ${i}`,
871
+ "info",
872
+ );
873
+ }
874
+ finalStatus = "complete";
875
+ break;
876
+ }
877
+ }
878
+
879
+ onNotify?.(`Iteration ${i} complete (${Math.round((iterEndMs - iterStartMs) / 1000)}s)`, "info");
880
+
881
+ if (i < currentMaxIterations && currentInterIterationDelay > 0) {
882
+ const stoppedDuringDelay = await waitForInterIterationDelay(taskDir, cwd, currentInterIterationDelay);
883
+ if (stoppedDuringDelay) {
884
+ finalStatus = "stopped";
885
+ break;
886
+ }
887
+ }
888
+ }
889
+
890
+ // Determine final status if loop completed without break
891
+ if (finalStatus === "running") {
892
+ const hadConfirmedProgress = iterations.some((r) => r.progress === true);
893
+ finalStatus = hadConfirmedProgress ? "max-iterations" : "no-progress-exhaustion";
894
+ }
895
+ } catch (err) {
896
+ const message = err instanceof Error ? err.message : String(err);
897
+ onNotify?.(`Ralph runner failed: ${message}`, "error");
898
+ finalStatus = "error";
899
+ } finally {
900
+ clearInterval(activeLoopHeartbeat);
901
+ // Write final status
902
+ const completedAt = new Date().toISOString();
903
+ const finalStatusFile: RunnerStatusFile = {
904
+ ...initialStatus,
905
+ status: finalStatus,
906
+ currentIteration: iterations.length > 0 ? iterations[iterations.length - 1].iteration : 0,
907
+ completedAt,
908
+ };
909
+ writeStatusFile(taskDir, finalStatusFile);
910
+ syncActiveLoopRegistry(finalStatusFile);
911
+ logRunnerEvent(taskDir, {
912
+ type: "runner.finished",
913
+ timestamp: completedAt,
914
+ loopToken,
915
+ status: finalStatus,
916
+ iterations: iterations.length,
917
+ totalDurationMs: Date.now() - startMs,
918
+ });
919
+ onStatusChange?.(finalStatus);
920
+
921
+ const totalMs = Date.now() - startMs;
922
+ const totalSec = Math.round(totalMs / 1000);
923
+
924
+ switch (finalStatus) {
925
+ case "complete":
926
+ onNotify?.(`Ralph runner complete: completion promise matched (${totalSec}s total)`, "info");
927
+ break;
928
+ case "max-iterations":
929
+ onNotify?.(`Ralph runner reached max iterations (${totalSec}s total)`, "info");
930
+ break;
931
+ case "no-progress-exhaustion":
932
+ onNotify?.(`Ralph runner exhausted without verified progress (${totalSec}s total)`, "warning");
933
+ break;
934
+ case "stopped":
935
+ onNotify?.(`Ralph runner stopped (${totalSec}s total)`, "info");
936
+ break;
937
+ case "timeout":
938
+ onNotify?.(`Ralph runner timed out (${totalSec}s total)`, "warning");
939
+ break;
940
+ case "error":
941
+ onNotify?.(`Ralph runner errored (${totalSec}s total)`, "error");
942
+ break;
943
+ default:
944
+ // Cancelled or other status
945
+ onNotify?.(`Ralph runner ended: ${finalStatus} (${totalSec}s total)`, "info");
946
+ break;
947
+ }
948
+
949
+ // Don't clear runner dir - keep for diagnostics
950
+ }
951
+
952
+ return {
953
+ status: finalStatus,
954
+ iterations,
955
+ totalDurationMs: Date.now() - startMs,
956
+ };
957
+ }