@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.
- package/README.md +3 -0
- package/package.json +1 -1
- package/src/index.ts +336 -1
- package/src/ralph.ts +8 -1
- package/src/runner-rpc.ts +36 -1
- package/src/runner-state.ts +20 -3
- package/src/runner.ts +109 -24
- package/tests/index.test.ts +460 -4
- 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
package/README.md
CHANGED
|
@@ -57,6 +57,9 @@ Stop with <promise>DONE</promise> only when the gate passes.
|
|
|
57
57
|
| `/ralph [path-or-task]` | Run an existing task folder or `RALPH.md`, or draft a new loop from a task description. |
|
|
58
58
|
| `/ralph-draft [path-or-task]` | Draft or edit a loop without starting it. |
|
|
59
59
|
| `/ralph-stop [path-or-task]` | Request a graceful stop after the current iteration. |
|
|
60
|
+
| `/ralph-cancel [path-or-task]` | Cancel the active iteration immediately. |
|
|
61
|
+
| `/ralph-scaffold <name-or-path>` | Create a non-interactive RALPH.md starter template. |
|
|
62
|
+
| `/ralph-logs [--path <task>] [--dest <dir>]` | Export run logs to an external directory. |
|
|
60
63
|
|
|
61
64
|
## Config reference
|
|
62
65
|
| Field | Purpose |
|
package/package.json
CHANGED
package/src/index.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { createHash, randomUUID } from "node:crypto";
|
|
2
|
-
import { existsSync, mkdirSync, readFileSync, readdirSync, writeFileSync } from "node:fs";
|
|
2
|
+
import { copyFileSync, existsSync, lstatSync, mkdirSync, readFileSync, readdirSync, writeFileSync } from "node:fs";
|
|
3
3
|
import { basename, dirname, join, relative, resolve } from "node:path";
|
|
4
4
|
import type { ExtensionAPI, ExtensionCommandContext, SessionEntry, AgentEndEvent as PiAgentEndEvent, ToolResultEvent as PiToolResultEvent } from "@mariozechner/pi-coding-agent";
|
|
5
5
|
import {
|
|
@@ -30,6 +30,8 @@ import { runRalphLoop } from "./runner.ts";
|
|
|
30
30
|
import {
|
|
31
31
|
checkStopSignal,
|
|
32
32
|
createStopSignal,
|
|
33
|
+
createCancelSignal,
|
|
34
|
+
checkCancelSignal,
|
|
33
35
|
listActiveLoopRegistryEntries,
|
|
34
36
|
readActiveLoopRegistry,
|
|
35
37
|
readIterationRecords,
|
|
@@ -119,6 +121,106 @@ type StopTarget = {
|
|
|
119
121
|
source: StopTargetSource;
|
|
120
122
|
};
|
|
121
123
|
|
|
124
|
+
type ResolveRalphTargetResult =
|
|
125
|
+
| { kind: "resolved"; taskDir: string }
|
|
126
|
+
| { kind: "not-found" };
|
|
127
|
+
|
|
128
|
+
function resolveRalphTarget(
|
|
129
|
+
ctx: Pick<CommandContext, "cwd" | "sessionManager" | "ui">,
|
|
130
|
+
options: {
|
|
131
|
+
commandName: string;
|
|
132
|
+
explicitPath?: string;
|
|
133
|
+
checkCrossProcess?: boolean;
|
|
134
|
+
allowCompletedRuns?: boolean;
|
|
135
|
+
},
|
|
136
|
+
): ResolveRalphTargetResult | undefined {
|
|
137
|
+
const { commandName, explicitPath, checkCrossProcess = false, allowCompletedRuns = false } = options;
|
|
138
|
+
const now = new Date().toISOString();
|
|
139
|
+
const activeRegistryEntries = () => listActiveLoopRegistryEntries(ctx.cwd);
|
|
140
|
+
const { target: sessionTarget } = resolveSessionStopTarget(ctx, now);
|
|
141
|
+
const resolvedExplicitPath = explicitPath?.trim();
|
|
142
|
+
|
|
143
|
+
if (resolvedExplicitPath) {
|
|
144
|
+
const inspection = inspectExistingTarget(resolvedExplicitPath, ctx.cwd, true);
|
|
145
|
+
if (inspection.kind === "run") {
|
|
146
|
+
const taskDir = dirname(inspection.ralphPath);
|
|
147
|
+
if (checkCrossProcess) {
|
|
148
|
+
const registryTarget = activeRegistryEntries().find((entry) => entry.taskDir === taskDir);
|
|
149
|
+
if (registryTarget) {
|
|
150
|
+
return { kind: "resolved", taskDir: registryTarget.taskDir };
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
const statusFile = readStatusFile(taskDir);
|
|
154
|
+
if (
|
|
155
|
+
statusFile &&
|
|
156
|
+
(statusFile.status === "running" || statusFile.status === "initializing") &&
|
|
157
|
+
typeof statusFile.cwd === "string" &&
|
|
158
|
+
statusFile.cwd.length > 0
|
|
159
|
+
) {
|
|
160
|
+
const statusRegistryTarget = listActiveLoopRegistryEntries(statusFile.cwd).find(
|
|
161
|
+
(entry) => entry.taskDir === taskDir && entry.loopToken === statusFile.loopToken,
|
|
162
|
+
);
|
|
163
|
+
if (statusRegistryTarget) {
|
|
164
|
+
return { kind: "resolved", taskDir: statusRegistryTarget.taskDir };
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
return { kind: "resolved", taskDir };
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
if (allowCompletedRuns) {
|
|
173
|
+
const taskDir = resolve(ctx.cwd, resolvedExplicitPath);
|
|
174
|
+
if (existsSync(join(taskDir, ".ralph-runner"))) {
|
|
175
|
+
return { kind: "resolved", taskDir };
|
|
176
|
+
}
|
|
177
|
+
ctx.ui.notify(`No ralph run data found at ${displayPath(ctx.cwd, taskDir)}.`, "error");
|
|
178
|
+
return { kind: "not-found" };
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
if (inspection.kind === "invalid-markdown") {
|
|
182
|
+
ctx.ui.notify(`Only task folders or RALPH.md can be stopped directly. ${displayPath(ctx.cwd, inspection.path)} is not stoppable.`, "error");
|
|
183
|
+
return undefined;
|
|
184
|
+
}
|
|
185
|
+
if (inspection.kind === "invalid-target") {
|
|
186
|
+
ctx.ui.notify(`Only task folders or RALPH.md can be stopped directly. ${displayPath(ctx.cwd, inspection.path)} is a file, not a task folder.`, "error");
|
|
187
|
+
return undefined;
|
|
188
|
+
}
|
|
189
|
+
if (inspection.kind === "dir-without-ralph" || inspection.kind === "missing-path") {
|
|
190
|
+
ctx.ui.notify(`No active ralph loop found at ${displayPath(ctx.cwd, inspection.dirPath)}.`, "warning");
|
|
191
|
+
return { kind: "not-found" };
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
ctx.ui.notify(`${commandName} expects a task folder or RALPH.md path.`, "error");
|
|
195
|
+
return undefined;
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
if (sessionTarget) {
|
|
199
|
+
return { kind: "resolved", taskDir: sessionTarget.taskDir };
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
const activeEntries = activeRegistryEntries();
|
|
203
|
+
if (activeEntries.length === 0) {
|
|
204
|
+
ctx.ui.notify(
|
|
205
|
+
allowCompletedRuns
|
|
206
|
+
? `No ralph run data found. Specify a task path with ${commandName} <path>.`
|
|
207
|
+
: "No active ralph loops found.",
|
|
208
|
+
"warning",
|
|
209
|
+
);
|
|
210
|
+
return { kind: "not-found" };
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
if (activeEntries.length > 1) {
|
|
214
|
+
ctx.ui.notify(
|
|
215
|
+
`Multiple active ralph loops found. Use ${commandName} <task folder or RALPH.md> for an explicit target path.`,
|
|
216
|
+
"error",
|
|
217
|
+
);
|
|
218
|
+
return undefined;
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
return { kind: "resolved", taskDir: activeEntries[0].taskDir };
|
|
222
|
+
}
|
|
223
|
+
|
|
122
224
|
type ToolEvent = {
|
|
123
225
|
toolName?: string;
|
|
124
226
|
toolCallId?: string;
|
|
@@ -717,6 +819,81 @@ function displayPath(cwd: string, filePath: string): string {
|
|
|
717
819
|
return rel && !rel.startsWith("..") ? `./${rel}` : filePath;
|
|
718
820
|
}
|
|
719
821
|
|
|
822
|
+
function exportRalphLogs(taskDir: string, destDir: string): { iterations: number; events: number; transcripts: number } {
|
|
823
|
+
const runnerDir = join(taskDir, ".ralph-runner");
|
|
824
|
+
if (!existsSync(runnerDir)) {
|
|
825
|
+
throw new Error(`No .ralph-runner directory found at ${taskDir}`);
|
|
826
|
+
}
|
|
827
|
+
|
|
828
|
+
mkdirSync(destDir, { recursive: true });
|
|
829
|
+
|
|
830
|
+
const filesToCopy = ["status.json", "iterations.jsonl", "events.jsonl"];
|
|
831
|
+
for (const file of filesToCopy) {
|
|
832
|
+
const src = join(runnerDir, file);
|
|
833
|
+
if (existsSync(src)) {
|
|
834
|
+
copyFileSync(src, join(destDir, file));
|
|
835
|
+
}
|
|
836
|
+
}
|
|
837
|
+
|
|
838
|
+
// Copy transcripts directory
|
|
839
|
+
const transcriptsDir = join(runnerDir, "transcripts");
|
|
840
|
+
let transcripts = 0;
|
|
841
|
+
if (existsSync(transcriptsDir)) {
|
|
842
|
+
const destTranscripts = join(destDir, "transcripts");
|
|
843
|
+
mkdirSync(destTranscripts, { recursive: true });
|
|
844
|
+
for (const entry of readdirSync(transcriptsDir)) {
|
|
845
|
+
const srcPath = join(transcriptsDir, entry);
|
|
846
|
+
try {
|
|
847
|
+
const stat = lstatSync(srcPath);
|
|
848
|
+
if (stat.isFile() && !stat.isSymbolicLink()) {
|
|
849
|
+
copyFileSync(srcPath, join(destTranscripts, entry));
|
|
850
|
+
transcripts++;
|
|
851
|
+
}
|
|
852
|
+
} catch {
|
|
853
|
+
// skip unreadable entries
|
|
854
|
+
}
|
|
855
|
+
}
|
|
856
|
+
}
|
|
857
|
+
|
|
858
|
+
// Count iterations and events
|
|
859
|
+
let iterations = 0;
|
|
860
|
+
let events = 0;
|
|
861
|
+
const iterPath = join(destDir, "iterations.jsonl");
|
|
862
|
+
if (existsSync(iterPath)) {
|
|
863
|
+
iterations = readFileSync(iterPath, "utf8").split("\n").filter((l) => l.trim()).length;
|
|
864
|
+
}
|
|
865
|
+
const evPath = join(destDir, "events.jsonl");
|
|
866
|
+
if (existsSync(evPath)) {
|
|
867
|
+
events = readFileSync(evPath, "utf8").split("\n").filter((l) => l.trim()).length;
|
|
868
|
+
}
|
|
869
|
+
|
|
870
|
+
return { iterations, events, transcripts };
|
|
871
|
+
}
|
|
872
|
+
|
|
873
|
+
export function parseLogExportArgs(raw: string): { path?: string; dest?: string; error?: string } {
|
|
874
|
+
const parts = raw.trim().split(/\s+/);
|
|
875
|
+
let path: string | undefined;
|
|
876
|
+
let dest: string | undefined;
|
|
877
|
+
let i = 0;
|
|
878
|
+
while (i < parts.length) {
|
|
879
|
+
if (parts[i] === "--dest" || parts[i] === "-d") {
|
|
880
|
+
if (i + 1 >= parts.length) return { error: "--dest requires a directory path" };
|
|
881
|
+
dest = parts[i + 1];
|
|
882
|
+
i += 2;
|
|
883
|
+
} else if (parts[i] === "--path" || parts[i] === "-p") {
|
|
884
|
+
if (i + 1 >= parts.length) return { error: "--path requires a task path" };
|
|
885
|
+
path = parts[i + 1];
|
|
886
|
+
i += 2;
|
|
887
|
+
} else if (!path && parts[i]) {
|
|
888
|
+
path = parts[i];
|
|
889
|
+
i++;
|
|
890
|
+
} else {
|
|
891
|
+
i++;
|
|
892
|
+
}
|
|
893
|
+
}
|
|
894
|
+
return { path, dest };
|
|
895
|
+
}
|
|
896
|
+
|
|
720
897
|
async function promptForTask(ctx: Pick<CommandContext, "hasUI" | "ui">, title: string, placeholder: string): Promise<string | undefined> {
|
|
721
898
|
if (!ctx.hasUI) return undefined;
|
|
722
899
|
const value = await ctx.ui.input(title, placeholder);
|
|
@@ -955,6 +1132,32 @@ function applyStopTarget(
|
|
|
955
1132
|
let loopState: LoopState = defaultLoopState();
|
|
956
1133
|
const RALPH_EXTENSION_REGISTERED = Symbol.for("pi-ralph-loop.registered");
|
|
957
1134
|
|
|
1135
|
+
function scaffoldRalphTemplate(): string {
|
|
1136
|
+
return `---
|
|
1137
|
+
max_iterations: 10
|
|
1138
|
+
timeout: 120
|
|
1139
|
+
commands: []
|
|
1140
|
+
---
|
|
1141
|
+
# {{ task.name }}
|
|
1142
|
+
|
|
1143
|
+
Describe the task here.
|
|
1144
|
+
|
|
1145
|
+
## Evidence
|
|
1146
|
+
Use {{ commands.* }} outputs as evidence.
|
|
1147
|
+
|
|
1148
|
+
## Completion
|
|
1149
|
+
Stop with <promise>DONE</promise> when finished.
|
|
1150
|
+
`;
|
|
1151
|
+
}
|
|
1152
|
+
|
|
1153
|
+
function slugifyTaskName(text: string): string {
|
|
1154
|
+
return text
|
|
1155
|
+
.toLowerCase()
|
|
1156
|
+
.trim()
|
|
1157
|
+
.replace(/[^a-z0-9]+/g, "-")
|
|
1158
|
+
.replace(/^-+|-+$/g, "");
|
|
1159
|
+
}
|
|
1160
|
+
|
|
958
1161
|
export default function (pi: ExtensionAPI, services: RegisterRalphCommandServices = {}) {
|
|
959
1162
|
const registeredPi = pi as ExtensionAPI & Record<symbol, boolean | undefined>;
|
|
960
1163
|
if (registeredPi[RALPH_EXTENSION_REGISTERED]) return;
|
|
@@ -1032,6 +1235,7 @@ export default function (pi: ExtensionAPI, services: RegisterRalphCommandService
|
|
|
1032
1235
|
|
|
1033
1236
|
async function startRalphLoop(ralphPath: string, ctx: CommandContext, runLoopFn: typeof runRalphLoop = runRalphLoop, runtimeArgs: RuntimeArgs = {}) {
|
|
1034
1237
|
let name: string;
|
|
1238
|
+
let currentStopOnError = true;
|
|
1035
1239
|
try {
|
|
1036
1240
|
const raw = readFileSync(ralphPath, "utf8");
|
|
1037
1241
|
const draftError = validateDraftContent(raw);
|
|
@@ -1042,6 +1246,7 @@ export default function (pi: ExtensionAPI, services: RegisterRalphCommandService
|
|
|
1042
1246
|
const parsed = parseRalphMarkdown(raw);
|
|
1043
1247
|
const { frontmatter } = parsed;
|
|
1044
1248
|
if (!validateFrontmatter(frontmatter, ctx)) return;
|
|
1249
|
+
currentStopOnError = frontmatter.stopOnError;
|
|
1045
1250
|
const runtimeValidationError = validateRuntimeArgs(frontmatter, parsed.body, frontmatter.commands, runtimeArgs);
|
|
1046
1251
|
if (runtimeValidationError) {
|
|
1047
1252
|
ctx.ui.notify(runtimeValidationError, "error");
|
|
@@ -1078,6 +1283,7 @@ export default function (pi: ExtensionAPI, services: RegisterRalphCommandService
|
|
|
1078
1283
|
timeout: loopState.timeout,
|
|
1079
1284
|
maxIterations: loopState.maxIterations,
|
|
1080
1285
|
guardrails: loopState.guardrails,
|
|
1286
|
+
stopOnError: currentStopOnError,
|
|
1081
1287
|
runtimeArgs,
|
|
1082
1288
|
modelPattern: ctx.model ? `${ctx.model.provider}/${ctx.model.id}` : undefined,
|
|
1083
1289
|
thinkingLevel: ctx.model?.reasoning ? "high" : undefined,
|
|
@@ -1480,4 +1686,133 @@ export default function (pi: ExtensionAPI, services: RegisterRalphCommandService
|
|
|
1480
1686
|
applyStopTarget(pi, ctx, materializeRegistryStopTarget(activeEntries[0]), now);
|
|
1481
1687
|
},
|
|
1482
1688
|
});
|
|
1689
|
+
|
|
1690
|
+
pi.registerCommand("ralph-cancel", {
|
|
1691
|
+
description: "Cancel the active ralph iteration immediately",
|
|
1692
|
+
handler: async (args: string, ctx: CommandContext) => {
|
|
1693
|
+
const parsed = parseCommandArgs(args ?? "");
|
|
1694
|
+
if (parsed.error) {
|
|
1695
|
+
ctx.ui.notify(parsed.error, "error");
|
|
1696
|
+
return;
|
|
1697
|
+
}
|
|
1698
|
+
if (parsed.mode === "task") {
|
|
1699
|
+
ctx.ui.notify("/ralph-cancel expects a task folder or RALPH.md path, not task text.", "error");
|
|
1700
|
+
return;
|
|
1701
|
+
}
|
|
1702
|
+
|
|
1703
|
+
const result = resolveRalphTarget(ctx, {
|
|
1704
|
+
commandName: "/ralph-cancel",
|
|
1705
|
+
explicitPath: parsed.value || undefined,
|
|
1706
|
+
checkCrossProcess: true,
|
|
1707
|
+
});
|
|
1708
|
+
if (!result || result.kind === "not-found") return;
|
|
1709
|
+
|
|
1710
|
+
const statusPath = join(result.taskDir, ".ralph-runner", "status.json");
|
|
1711
|
+
if (!existsSync(statusPath)) {
|
|
1712
|
+
ctx.ui.notify(`No active loop found at ${displayPath(ctx.cwd, result.taskDir)}. No run data exists.`, "warning");
|
|
1713
|
+
return;
|
|
1714
|
+
}
|
|
1715
|
+
|
|
1716
|
+
const statusFile = readStatusFile(result.taskDir);
|
|
1717
|
+
const finishedStatuses = new Set([
|
|
1718
|
+
"complete",
|
|
1719
|
+
"max-iterations",
|
|
1720
|
+
"no-progress-exhaustion",
|
|
1721
|
+
"stopped",
|
|
1722
|
+
"timeout",
|
|
1723
|
+
"error",
|
|
1724
|
+
"cancelled",
|
|
1725
|
+
]);
|
|
1726
|
+
if (statusFile?.status && finishedStatuses.has(statusFile.status)) {
|
|
1727
|
+
ctx.ui.notify(
|
|
1728
|
+
`No active loop found at ${displayPath(ctx.cwd, result.taskDir)}. The loop already ended with status: ${statusFile.status}.`,
|
|
1729
|
+
"warning",
|
|
1730
|
+
);
|
|
1731
|
+
return;
|
|
1732
|
+
}
|
|
1733
|
+
|
|
1734
|
+
createCancelSignal(result.taskDir);
|
|
1735
|
+
ctx.ui.notify("Cancel requested. The active iteration will be terminated immediately.", "warning");
|
|
1736
|
+
},
|
|
1737
|
+
});
|
|
1738
|
+
|
|
1739
|
+
pi.registerCommand("ralph-scaffold", {
|
|
1740
|
+
description: "Create a non-interactive RALPH.md starter template",
|
|
1741
|
+
handler: async (args: string, ctx: CommandContext) => {
|
|
1742
|
+
const name = (args ?? "").trim();
|
|
1743
|
+
if (!name) {
|
|
1744
|
+
ctx.ui.notify("/ralph-scaffold expects a task name or path.", "error");
|
|
1745
|
+
return;
|
|
1746
|
+
}
|
|
1747
|
+
|
|
1748
|
+
let taskDir: string;
|
|
1749
|
+
let ralphPath: string;
|
|
1750
|
+
|
|
1751
|
+
if (name.includes("/") || name.startsWith("./")) {
|
|
1752
|
+
ralphPath = resolve(ctx.cwd, name.endsWith("/RALPH.md") ? name : join(name, "RALPH.md"));
|
|
1753
|
+
taskDir = dirname(ralphPath);
|
|
1754
|
+
} else {
|
|
1755
|
+
const slug = slugifyTaskName(name);
|
|
1756
|
+
if (!slug) {
|
|
1757
|
+
ctx.ui.notify(`Cannot slugify "${name}" into a valid directory name.`, "error");
|
|
1758
|
+
return;
|
|
1759
|
+
}
|
|
1760
|
+
taskDir = join(ctx.cwd, slug);
|
|
1761
|
+
ralphPath = join(taskDir, "RALPH.md");
|
|
1762
|
+
}
|
|
1763
|
+
|
|
1764
|
+
const resolvedTaskDir = resolve(taskDir);
|
|
1765
|
+
const resolvedCwd = resolve(ctx.cwd);
|
|
1766
|
+
if (!resolvedTaskDir.startsWith(resolvedCwd + "/") && resolvedTaskDir !== resolvedCwd) {
|
|
1767
|
+
ctx.ui.notify("Task path must be within the current working directory.", "error");
|
|
1768
|
+
return;
|
|
1769
|
+
}
|
|
1770
|
+
|
|
1771
|
+
if (existsSync(ralphPath)) {
|
|
1772
|
+
ctx.ui.notify(`RALPH.md already exists at ${displayPath(ctx.cwd, ralphPath)}. Not overwriting.`, "error");
|
|
1773
|
+
return;
|
|
1774
|
+
}
|
|
1775
|
+
|
|
1776
|
+
if (existsSync(taskDir) && readdirSync(taskDir).length > 0) {
|
|
1777
|
+
ctx.ui.notify(`Directory ${displayPath(ctx.cwd, taskDir)} already exists and is not empty. Not overwriting.`, "error");
|
|
1778
|
+
return;
|
|
1779
|
+
}
|
|
1780
|
+
|
|
1781
|
+
mkdirSync(taskDir, { recursive: true });
|
|
1782
|
+
writeFileSync(ralphPath, scaffoldRalphTemplate(), "utf8");
|
|
1783
|
+
ctx.ui.notify(`Scaffolded ${displayPath(ctx.cwd, ralphPath)}`, "info");
|
|
1784
|
+
},
|
|
1785
|
+
});
|
|
1786
|
+
|
|
1787
|
+
pi.registerCommand("ralph-logs", {
|
|
1788
|
+
description: "Export run logs from a ralph task to an external directory",
|
|
1789
|
+
handler: async (args: string, ctx: CommandContext) => {
|
|
1790
|
+
const parsed = parseLogExportArgs(args ?? "");
|
|
1791
|
+
if (parsed.error) {
|
|
1792
|
+
ctx.ui.notify(parsed.error, "error");
|
|
1793
|
+
return;
|
|
1794
|
+
}
|
|
1795
|
+
|
|
1796
|
+
const resolvedTarget = resolveRalphTarget(ctx, {
|
|
1797
|
+
commandName: "/ralph-logs",
|
|
1798
|
+
explicitPath: parsed.path,
|
|
1799
|
+
allowCompletedRuns: true,
|
|
1800
|
+
});
|
|
1801
|
+
if (!resolvedTarget || resolvedTarget.kind === "not-found") return;
|
|
1802
|
+
const taskDir = resolvedTarget.taskDir;
|
|
1803
|
+
|
|
1804
|
+
// Resolve dest directory
|
|
1805
|
+
const destDir = parsed.dest
|
|
1806
|
+
? resolve(ctx.cwd, parsed.dest)
|
|
1807
|
+
: join(ctx.cwd, `ralph-logs-${new Date().toISOString().replace(/[:.]/g, "-")}`);
|
|
1808
|
+
|
|
1809
|
+
try {
|
|
1810
|
+
const result = exportRalphLogs(taskDir, destDir);
|
|
1811
|
+
ctx.ui.notify(`Exported ${result.iterations} iteration records, ${result.events} events, ${result.transcripts} transcripts to ${displayPath(ctx.cwd, destDir)}`, "info");
|
|
1812
|
+
} catch (err) {
|
|
1813
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
1814
|
+
ctx.ui.notify(`Log export failed: ${message}`, "error");
|
|
1815
|
+
}
|
|
1816
|
+
},
|
|
1817
|
+
});
|
|
1483
1818
|
}
|
package/src/ralph.ts
CHANGED
|
@@ -17,6 +17,7 @@ export type Frontmatter = {
|
|
|
17
17
|
timeout: number;
|
|
18
18
|
completionPromise?: string;
|
|
19
19
|
requiredOutputs?: string[];
|
|
20
|
+
stopOnError: boolean;
|
|
20
21
|
guardrails: { blockCommands: string[]; protectedFiles: string[] };
|
|
21
22
|
invalidCommandEntries?: number[];
|
|
22
23
|
invalidArgEntries?: number[];
|
|
@@ -483,7 +484,7 @@ function escapeHtmlCommentMarkers(text: string): string {
|
|
|
483
484
|
}
|
|
484
485
|
|
|
485
486
|
export function defaultFrontmatter(): Frontmatter {
|
|
486
|
-
return { commands: [], maxIterations: 50, interIterationDelay: 0, timeout: 300, requiredOutputs: [], guardrails: { blockCommands: [], protectedFiles: [] } };
|
|
487
|
+
return { commands: [], maxIterations: 50, interIterationDelay: 0, timeout: 300, requiredOutputs: [], stopOnError: true, guardrails: { blockCommands: [], protectedFiles: [] } };
|
|
487
488
|
}
|
|
488
489
|
|
|
489
490
|
export function parseRalphMarkdown(raw: string): ParsedRalph {
|
|
@@ -514,6 +515,7 @@ export function parseRalphMarkdown(raw: string): ParsedRalph {
|
|
|
514
515
|
completionPromise:
|
|
515
516
|
typeof yaml.completion_promise === "string" && yaml.completion_promise.trim() ? yaml.completion_promise : undefined,
|
|
516
517
|
requiredOutputs: toStringArray(yaml.required_outputs),
|
|
518
|
+
stopOnError: yaml.stop_on_error === false ? false : true,
|
|
517
519
|
guardrails: {
|
|
518
520
|
blockCommands: toStringArray(guardrails.block_commands),
|
|
519
521
|
protectedFiles: toStringArray(guardrails.protected_files),
|
|
@@ -558,6 +560,9 @@ export function validateFrontmatter(fm: Frontmatter): string | null {
|
|
|
558
560
|
}
|
|
559
561
|
seenArgNames.add(arg);
|
|
560
562
|
}
|
|
563
|
+
if (typeof fm.stopOnError !== "boolean") {
|
|
564
|
+
return "Invalid stop_on_error: must be true or false";
|
|
565
|
+
}
|
|
561
566
|
for (const output of fm.requiredOutputs ?? []) {
|
|
562
567
|
const requiredOutputError = validateRequiredOutputEntry(output);
|
|
563
568
|
if (requiredOutputError) {
|
|
@@ -1137,6 +1142,7 @@ function buildDraftFrontmatter(mode: DraftMode, commands: CommandDef[]): Frontma
|
|
|
1137
1142
|
interIterationDelay: 0,
|
|
1138
1143
|
timeout: 300,
|
|
1139
1144
|
requiredOutputs: [],
|
|
1145
|
+
stopOnError: true,
|
|
1140
1146
|
guardrails,
|
|
1141
1147
|
};
|
|
1142
1148
|
}
|
|
@@ -1177,6 +1183,7 @@ function renderDraftPlan(task: string, mode: DraftMode, target: DraftTarget, fro
|
|
|
1177
1183
|
`max_iterations: ${frontmatter.maxIterations}`,
|
|
1178
1184
|
`inter_iteration_delay: ${frontmatter.interIterationDelay}`,
|
|
1179
1185
|
`timeout: ${frontmatter.timeout}`,
|
|
1186
|
+
...(frontmatter.stopOnError === false ? ["stop_on_error: false"] : []),
|
|
1180
1187
|
...(requiredOutputs.length > 0
|
|
1181
1188
|
? ["required_outputs:", ...requiredOutputs.map((output) => ` - ${yamlQuote(output)}`)]
|
|
1182
1189
|
: []),
|
package/src/runner-rpc.ts
CHANGED
|
@@ -34,6 +34,10 @@ export type RpcSubprocessConfig = {
|
|
|
34
34
|
thinkingLevel?: string;
|
|
35
35
|
/** Callback for observing events as they stream */
|
|
36
36
|
onEvent?: (event: RpcEvent) => void;
|
|
37
|
+
/** AbortSignal for cooperative cancellation. On abort, the direct child process is SIGKILLed.
|
|
38
|
+
* Grandchild processes may survive — the caller is responsible for process group cleanup
|
|
39
|
+
* if full-tree termination is required. */
|
|
40
|
+
signal?: AbortSignal;
|
|
37
41
|
};
|
|
38
42
|
|
|
39
43
|
export type RpcTelemetry = {
|
|
@@ -55,6 +59,7 @@ export type RpcSubprocessResult = {
|
|
|
55
59
|
lastAssistantText: string;
|
|
56
60
|
agentEndMessages: unknown[];
|
|
57
61
|
timedOut: boolean;
|
|
62
|
+
cancelled?: boolean;
|
|
58
63
|
error?: string;
|
|
59
64
|
telemetry: RpcTelemetry;
|
|
60
65
|
};
|
|
@@ -126,6 +131,7 @@ export async function runRpcIteration(config: RpcSubprocessConfig): Promise<RpcS
|
|
|
126
131
|
provider: explicitProvider,
|
|
127
132
|
modelId: explicitModelId,
|
|
128
133
|
onEvent,
|
|
134
|
+
signal,
|
|
129
135
|
} = config;
|
|
130
136
|
|
|
131
137
|
// Parse modelPattern ("provider/modelId" or "provider/modelId:thinking") into provider and modelId
|
|
@@ -201,7 +207,35 @@ export async function runRpcIteration(config: RpcSubprocessConfig): Promise<RpcS
|
|
|
201
207
|
telemetry.lastEventType = eventType;
|
|
202
208
|
};
|
|
203
209
|
|
|
204
|
-
|
|
210
|
+
let timeout: ReturnType<typeof setTimeout> | undefined;
|
|
211
|
+
|
|
212
|
+
const onAbort = () => {
|
|
213
|
+
if (settled) return;
|
|
214
|
+
settled = true;
|
|
215
|
+
telemetry.error = "cancelled";
|
|
216
|
+
try {
|
|
217
|
+
childProcess.kill("SIGKILL");
|
|
218
|
+
} catch {
|
|
219
|
+
// process may already be dead
|
|
220
|
+
}
|
|
221
|
+
clearTimeout(timeout);
|
|
222
|
+
resolve(buildResult({
|
|
223
|
+
success: false,
|
|
224
|
+
lastAssistantText,
|
|
225
|
+
agentEndMessages,
|
|
226
|
+
timedOut: false,
|
|
227
|
+
cancelled: true,
|
|
228
|
+
error: "cancelled",
|
|
229
|
+
}));
|
|
230
|
+
};
|
|
231
|
+
|
|
232
|
+
if (signal?.aborted) {
|
|
233
|
+
onAbort();
|
|
234
|
+
return;
|
|
235
|
+
}
|
|
236
|
+
signal?.addEventListener("abort", onAbort, { once: true });
|
|
237
|
+
|
|
238
|
+
timeout = setTimeout(() => {
|
|
205
239
|
if (settled) return;
|
|
206
240
|
settled = true;
|
|
207
241
|
telemetry.timedOutAt = nowIso();
|
|
@@ -230,6 +264,7 @@ export async function runRpcIteration(config: RpcSubprocessConfig): Promise<RpcS
|
|
|
230
264
|
const cleanup = () => {
|
|
231
265
|
clearTimeout(timeout);
|
|
232
266
|
endStdin();
|
|
267
|
+
signal?.removeEventListener("abort", onAbort);
|
|
233
268
|
};
|
|
234
269
|
|
|
235
270
|
const settle = (result: RpcSubprocessResult) => {
|
package/src/runner-state.ts
CHANGED
|
@@ -33,7 +33,7 @@ export type Guardrails = {
|
|
|
33
33
|
|
|
34
34
|
export type IterationRecord = {
|
|
35
35
|
iteration: number;
|
|
36
|
-
status: "running" | "complete" | "timeout" | "error";
|
|
36
|
+
status: "running" | "complete" | "timeout" | "error" | "cancelled";
|
|
37
37
|
startedAt: string;
|
|
38
38
|
completedAt?: string;
|
|
39
39
|
durationMs?: number;
|
|
@@ -146,7 +146,7 @@ export type IterationCompletedEvent = {
|
|
|
146
146
|
timestamp: string;
|
|
147
147
|
iteration: number;
|
|
148
148
|
loopToken: string;
|
|
149
|
-
status: "complete" | "timeout" | "error";
|
|
149
|
+
status: "complete" | "timeout" | "error" | "cancelled";
|
|
150
150
|
progress: ProgressState;
|
|
151
151
|
changedFiles: string[];
|
|
152
152
|
noProgressStreak: number;
|
|
@@ -230,6 +230,7 @@ const STATUS_FILE = "status.json";
|
|
|
230
230
|
const ITERATIONS_FILE = "iterations.jsonl";
|
|
231
231
|
const EVENTS_FILE = "events.jsonl";
|
|
232
232
|
const STOP_FLAG_FILE = "stop.flag";
|
|
233
|
+
const CANCEL_FLAG_FILE = "cancel.flag";
|
|
233
234
|
const ACTIVE_LOOP_REGISTRY_DIR = "active-loops";
|
|
234
235
|
const ACTIVE_LOOP_REGISTRY_LEGACY_FILE = "active-loops.json";
|
|
235
236
|
const ACTIVE_LOOP_REGISTRY_FILE_EXTENSION = ".json";
|
|
@@ -328,7 +329,7 @@ function isCompletionGate(value: unknown): value is { ready: boolean; reasons: s
|
|
|
328
329
|
}
|
|
329
330
|
|
|
330
331
|
function isIterationCompletedStatus(value: unknown): value is IterationRecord["status"] {
|
|
331
|
-
return value === "complete" || value === "timeout" || value === "error";
|
|
332
|
+
return value === "complete" || value === "timeout" || value === "error" || value === "cancelled";
|
|
332
333
|
}
|
|
333
334
|
|
|
334
335
|
function isRunnerEvent(value: unknown): value is RunnerEvent {
|
|
@@ -610,6 +611,22 @@ export function clearStopSignal(taskDir: string): void {
|
|
|
610
611
|
}
|
|
611
612
|
}
|
|
612
613
|
|
|
614
|
+
export function createCancelSignal(taskDir: string): void {
|
|
615
|
+
const dir = ensureRunnerDir(taskDir);
|
|
616
|
+
writeFileSync(join(dir, CANCEL_FLAG_FILE), "", "utf8");
|
|
617
|
+
}
|
|
618
|
+
|
|
619
|
+
export function checkCancelSignal(taskDir: string): boolean {
|
|
620
|
+
return existsSync(join(runnerDir(taskDir), CANCEL_FLAG_FILE));
|
|
621
|
+
}
|
|
622
|
+
|
|
623
|
+
export function clearCancelSignal(taskDir: string): void {
|
|
624
|
+
const filePath = join(runnerDir(taskDir), CANCEL_FLAG_FILE);
|
|
625
|
+
if (existsSync(filePath)) {
|
|
626
|
+
rmSync(filePath, { force: true });
|
|
627
|
+
}
|
|
628
|
+
}
|
|
629
|
+
|
|
613
630
|
export function clearRunnerDir(taskDir: string): void {
|
|
614
631
|
const dir = runnerDir(taskDir);
|
|
615
632
|
if (existsSync(dir)) {
|