@sweny-ai/core 0.1.164 → 0.1.166
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/dist/agent-env.d.ts +130 -13
- package/dist/agent-env.js +276 -20
- package/dist/cli/config-file.js +2 -0
- package/dist/cli/main.d.ts +4 -1
- package/dist/cli/main.js +72 -3
- package/dist/cli/resume.d.ts +56 -0
- package/dist/cli/resume.js +182 -0
- package/dist/executor.d.ts +7 -0
- package/dist/executor.js +87 -11
- package/dist/harness/acp.d.ts +6 -1
- package/dist/harness/acp.js +35 -17
- package/dist/harness/claude-code.d.ts +12 -0
- package/dist/harness/claude-code.js +37 -14
- package/dist/harness/codex.d.ts +5 -1
- package/dist/harness/codex.js +30 -16
- package/dist/harness/pi.d.ts +17 -1
- package/dist/harness/pi.js +54 -16
- package/dist/harness/policy.js +16 -0
- package/dist/harness/types.d.ts +16 -0
- package/dist/journal.d.ts +307 -0
- package/dist/journal.js +885 -0
- package/dist/schema.d.ts +156 -136
- package/dist/schema.js +21 -0
- package/dist/types.d.ts +15 -3
- package/package.json +1 -1
package/dist/journal.js
ADDED
|
@@ -0,0 +1,885 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Run journal (#363): an append-only log of one workflow run, enough to resume
|
|
3
|
+
* a killed or failed run without re-running the nodes that already finished.
|
|
4
|
+
*
|
|
5
|
+
* File: `.sweny/runs/<run-id>/journal.ndjson`, one JSON record per line. Every
|
|
6
|
+
* record carries the format version (`v`), a sequence number (`seq`) and a
|
|
7
|
+
* checksum (`h`, sha256 of the record without `h`). Each append is written and
|
|
8
|
+
* fsync'd before the executor moves on, so a record on disk is a fact.
|
|
9
|
+
*
|
|
10
|
+
* Records, in run order:
|
|
11
|
+
* run:start workflow / instruction / input / tool hashes, harness id, input (redacted)
|
|
12
|
+
* run:resume one per `sweny workflow resume`
|
|
13
|
+
* node:start a node visit began (node + iteration)
|
|
14
|
+
* node:checkpoint the agent finished a node with outputs, its result and
|
|
15
|
+
* write intents, written before the write stage runs
|
|
16
|
+
* output:intent a write is about to be applied (idempotency key + tool)
|
|
17
|
+
* output:applied the write returned (key + the ids it produced)
|
|
18
|
+
* node:end final result of the visit and the write-stage counters
|
|
19
|
+
* route the edge taken after the visit (null = the run ended)
|
|
20
|
+
* run:end success | failed | crashed
|
|
21
|
+
*
|
|
22
|
+
* Recovery: a torn or corrupt tail (power loss mid-append) is truncated back to
|
|
23
|
+
* the last valid record. Corruption in the middle of the file is refused.
|
|
24
|
+
*
|
|
25
|
+
* Privacy: the journal is local and holds what the run needs to continue (node
|
|
26
|
+
* data, eval verdicts, safe-output intents). It never holds environment values:
|
|
27
|
+
* secret-looking keys, known token shapes, and the values of secret env vars
|
|
28
|
+
* and skill credentials are replaced with `[redacted]` before anything is
|
|
29
|
+
* written. Tool call inputs and outputs are not journaled.
|
|
30
|
+
*
|
|
31
|
+
* Writes: the write stage's skill handlers are wrapped. Each write gets an
|
|
32
|
+
* idempotency key (node, iteration, tool, canonical arguments), an
|
|
33
|
+
* `output:intent` before the call and an `output:applied` after it. On resume a
|
|
34
|
+
* key with a receipt is not re-applied, and a key with an intent but no receipt
|
|
35
|
+
* (the process died between the API call and the receipt) is looked up on the
|
|
36
|
+
* provider by a marker embedded in the body before anything is re-sent.
|
|
37
|
+
*/
|
|
38
|
+
import crypto from "node:crypto";
|
|
39
|
+
import fs from "node:fs";
|
|
40
|
+
import path from "node:path";
|
|
41
|
+
import { resolveNodePermissions } from "./node-policy.js";
|
|
42
|
+
import { CURRENT_SPEC_VERSION } from "./migrations.js";
|
|
43
|
+
export const JOURNAL_SCHEMA_VERSION = 1;
|
|
44
|
+
export const JOURNAL_DIR = path.join(".sweny", "runs");
|
|
45
|
+
export const JOURNAL_FILE = "journal.ndjson";
|
|
46
|
+
const LOCK_FILE = "journal.lock";
|
|
47
|
+
/** Journals kept on disk; the oldest beyond this are deleted when a new run starts. */
|
|
48
|
+
export const JOURNAL_KEEP = 20;
|
|
49
|
+
export const REDACTED = "[redacted]";
|
|
50
|
+
const RUN_ID_RE = /^\d{8}-\d{6}-[0-9a-f]{6}$/;
|
|
51
|
+
// ─── Hashing ──────────────────────────────────────────────────────
|
|
52
|
+
function canonicalize(value) {
|
|
53
|
+
if (Array.isArray(value))
|
|
54
|
+
return value.map(canonicalize);
|
|
55
|
+
if (value && typeof value === "object") {
|
|
56
|
+
const out = {};
|
|
57
|
+
for (const k of Object.keys(value).sort()) {
|
|
58
|
+
const v = value[k];
|
|
59
|
+
if (v !== undefined)
|
|
60
|
+
out[k] = canonicalize(v);
|
|
61
|
+
}
|
|
62
|
+
return out;
|
|
63
|
+
}
|
|
64
|
+
return value;
|
|
65
|
+
}
|
|
66
|
+
/** sha256 (hex) of `value` as canonical JSON: keys sorted, undefined dropped. Same as run history's workflow hash. */
|
|
67
|
+
export function canonicalHash(value) {
|
|
68
|
+
return crypto
|
|
69
|
+
.createHash("sha256")
|
|
70
|
+
.update(JSON.stringify(canonicalize(value ?? null)))
|
|
71
|
+
.digest("hex");
|
|
72
|
+
}
|
|
73
|
+
/**
|
|
74
|
+
* Hash of the workflow a journal is bound to: the canonical workflow plus its
|
|
75
|
+
* effective `spec_version` (#469), so a file that only changes spec version
|
|
76
|
+
* (and therefore migrations) is a different workflow for resume.
|
|
77
|
+
*/
|
|
78
|
+
export function workflowHashOf(workflow) {
|
|
79
|
+
return canonicalHash({ ...workflow, spec_version: workflow.spec_version ?? String(CURRENT_SPEC_VERSION) });
|
|
80
|
+
}
|
|
81
|
+
/** Hash of what the nodes were told: every resolved instruction, rule and context Source. */
|
|
82
|
+
export function instructionHash(sources) {
|
|
83
|
+
return canonicalHash(Object.fromEntries(Object.entries(sources).map(([k, v]) => [k, v?.content ?? ""])));
|
|
84
|
+
}
|
|
85
|
+
/** Hash of the tools the run could call: skill ids, tool names and access, MCP presence, sweny version. */
|
|
86
|
+
export function toolsHash(skills, swenyVersion, harnessId) {
|
|
87
|
+
const shape = [...skills.values()]
|
|
88
|
+
.map((s) => ({
|
|
89
|
+
id: s.id,
|
|
90
|
+
tools: s.tools.map((t) => `${t.name}:${t.access ?? "unclassified"}`).sort(),
|
|
91
|
+
mcp: s.mcp ? (s.mcp.url ?? s.mcp.command ?? "mcp") : null,
|
|
92
|
+
}))
|
|
93
|
+
.sort((a, b) => a.id.localeCompare(b.id));
|
|
94
|
+
return canonicalHash({ skills: shape, sweny: swenyVersion ?? null, harness: harnessId ?? null });
|
|
95
|
+
}
|
|
96
|
+
function recordHash(body) {
|
|
97
|
+
return crypto.createHash("sha256").update(body).digest("hex").slice(0, 16);
|
|
98
|
+
}
|
|
99
|
+
// ─── Redaction ────────────────────────────────────────────────────
|
|
100
|
+
const SECRET_KEY_PARTS = [
|
|
101
|
+
"token",
|
|
102
|
+
"secret",
|
|
103
|
+
"password",
|
|
104
|
+
"passwd",
|
|
105
|
+
"apikey",
|
|
106
|
+
"authorization",
|
|
107
|
+
"credential",
|
|
108
|
+
"privatekey",
|
|
109
|
+
"cookie",
|
|
110
|
+
"sessionid",
|
|
111
|
+
];
|
|
112
|
+
/** True for keys like `GITHUB_TOKEN`, `apiKey`, `client-secret`, `Authorization`. */
|
|
113
|
+
export function isSecretKey(key) {
|
|
114
|
+
const k = key.toLowerCase().replace(/[^a-z0-9]/g, "");
|
|
115
|
+
return SECRET_KEY_PARTS.some((p) => k.includes(p));
|
|
116
|
+
}
|
|
117
|
+
const SECRET_VALUE_PATTERNS = [
|
|
118
|
+
/\bgh[pousr]_[A-Za-z0-9]{20,}/g,
|
|
119
|
+
/\bgithub_pat_[A-Za-z0-9_]{20,}/g,
|
|
120
|
+
/\bsk-[A-Za-z0-9_-]{20,}/g,
|
|
121
|
+
/\bxox[abprs]-[A-Za-z0-9-]{10,}/g,
|
|
122
|
+
/\blin_api_[A-Za-z0-9]{20,}/g,
|
|
123
|
+
/\bAKIA[0-9A-Z]{16}\b/g,
|
|
124
|
+
/-----BEGIN [A-Z ]*PRIVATE KEY-----[\s\S]*?-----END [A-Z ]*PRIVATE KEY-----/g,
|
|
125
|
+
];
|
|
126
|
+
/** Values of secret-named env vars / config entries, longest first (8+ chars, so short words never match). */
|
|
127
|
+
export function collectSecretValues(...maps) {
|
|
128
|
+
const out = new Set();
|
|
129
|
+
for (const m of maps) {
|
|
130
|
+
for (const [k, v] of Object.entries(m ?? {})) {
|
|
131
|
+
if (typeof v === "string" && v.length >= 8 && isSecretKey(k))
|
|
132
|
+
out.add(v);
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
return [...out].sort((a, b) => b.length - a.length);
|
|
136
|
+
}
|
|
137
|
+
/**
|
|
138
|
+
* Deep copy of `value` with secrets replaced by `[redacted]`: string values
|
|
139
|
+
* under secret-looking keys, known token shapes, and any occurrence of a value
|
|
140
|
+
* in `secrets`. Numbers and booleans are kept (`inputTokens` is a count).
|
|
141
|
+
*/
|
|
142
|
+
export function redact(value, secrets = []) {
|
|
143
|
+
let redacted = false;
|
|
144
|
+
const scrub = (s) => {
|
|
145
|
+
let out = s;
|
|
146
|
+
for (const secret of secrets) {
|
|
147
|
+
if (out.includes(secret)) {
|
|
148
|
+
out = out.split(secret).join(REDACTED);
|
|
149
|
+
redacted = true;
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
for (const re of SECRET_VALUE_PATTERNS) {
|
|
153
|
+
re.lastIndex = 0;
|
|
154
|
+
if (re.test(out)) {
|
|
155
|
+
re.lastIndex = 0;
|
|
156
|
+
out = out.replace(re, REDACTED);
|
|
157
|
+
redacted = true;
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
return out;
|
|
161
|
+
};
|
|
162
|
+
const walk = (v, depth) => {
|
|
163
|
+
if (depth > 64)
|
|
164
|
+
return null;
|
|
165
|
+
if (typeof v === "string")
|
|
166
|
+
return scrub(v);
|
|
167
|
+
if (Array.isArray(v))
|
|
168
|
+
return v.map((x) => walk(x, depth + 1));
|
|
169
|
+
if (v && typeof v === "object") {
|
|
170
|
+
const out = {};
|
|
171
|
+
for (const [k, x] of Object.entries(v)) {
|
|
172
|
+
if (typeof x === "string" && isSecretKey(k) && x.length > 0) {
|
|
173
|
+
out[k] = REDACTED;
|
|
174
|
+
redacted = true;
|
|
175
|
+
}
|
|
176
|
+
else if (x !== undefined && typeof x !== "function") {
|
|
177
|
+
out[k] = walk(x, depth + 1);
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
return out;
|
|
181
|
+
}
|
|
182
|
+
return v;
|
|
183
|
+
};
|
|
184
|
+
return { value: walk(value, 0), redacted };
|
|
185
|
+
}
|
|
186
|
+
/** Thrown when the journal was written by a newer sweny. */
|
|
187
|
+
export class JournalVersionError extends Error {
|
|
188
|
+
constructor(version) {
|
|
189
|
+
super(`run journal format v${String(version)} is newer than this sweny understands (v${JOURNAL_SCHEMA_VERSION}); upgrade sweny to resume it`);
|
|
190
|
+
this.name = "JournalVersionError";
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
/** Thrown at resume when what the run depends on changed since it was journaled (and `force` is off). */
|
|
194
|
+
export class JournalMismatchError extends Error {
|
|
195
|
+
what;
|
|
196
|
+
constructor(what) {
|
|
197
|
+
super(`cannot resume: ${what.join(", ")} changed since this run was journaled. ` +
|
|
198
|
+
`Resuming would mix results from two different runs. Start a new run, or pass --force to resume anyway.`);
|
|
199
|
+
this.what = what;
|
|
200
|
+
this.name = "JournalMismatchError";
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
/** Thrown when another live process holds the run's journal. */
|
|
204
|
+
export class JournalLockedError extends Error {
|
|
205
|
+
constructor(pid) {
|
|
206
|
+
super(`run journal is in use by process ${pid}; wait for it to finish (or stop it) before resuming`);
|
|
207
|
+
this.name = "JournalLockedError";
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
function verifyLine(line, expectedSeq) {
|
|
211
|
+
let parsed;
|
|
212
|
+
try {
|
|
213
|
+
parsed = JSON.parse(line);
|
|
214
|
+
}
|
|
215
|
+
catch {
|
|
216
|
+
return undefined;
|
|
217
|
+
}
|
|
218
|
+
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed))
|
|
219
|
+
return undefined;
|
|
220
|
+
const rec = parsed;
|
|
221
|
+
if (typeof rec.v === "number" && rec.v > JOURNAL_SCHEMA_VERSION)
|
|
222
|
+
return "version";
|
|
223
|
+
if (rec.v !== JOURNAL_SCHEMA_VERSION || rec.seq !== expectedSeq || typeof rec.type !== "string")
|
|
224
|
+
return undefined;
|
|
225
|
+
const { h, ...body } = rec;
|
|
226
|
+
if (typeof h !== "string" || h !== recordHash(JSON.stringify(body)))
|
|
227
|
+
return undefined;
|
|
228
|
+
return rec;
|
|
229
|
+
}
|
|
230
|
+
function looksLikeRecord(text) {
|
|
231
|
+
try {
|
|
232
|
+
const p = JSON.parse(text);
|
|
233
|
+
return !!p && typeof p === "object" && typeof p.v === "number" && typeof p.h === "string";
|
|
234
|
+
}
|
|
235
|
+
catch {
|
|
236
|
+
return false;
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
/**
|
|
240
|
+
* Read a journal. With `repair`, a torn or corrupt tail is truncated on disk
|
|
241
|
+
* back to the last valid record. Throws {@link JournalVersionError} for a
|
|
242
|
+
* newer format.
|
|
243
|
+
*/
|
|
244
|
+
export function readJournal(file, opts = {}) {
|
|
245
|
+
const buf = fs.readFileSync(file);
|
|
246
|
+
const records = [];
|
|
247
|
+
let offset = 0;
|
|
248
|
+
let validBytes = 0;
|
|
249
|
+
let line = 0;
|
|
250
|
+
let firstBad;
|
|
251
|
+
let corruptAtLine;
|
|
252
|
+
while (offset < buf.length) {
|
|
253
|
+
const nl = buf.indexOf(0x0a, offset);
|
|
254
|
+
line++;
|
|
255
|
+
if (nl === -1) {
|
|
256
|
+
// A final segment without a newline is a torn append, whatever it parses as.
|
|
257
|
+
firstBad ??= line;
|
|
258
|
+
break;
|
|
259
|
+
}
|
|
260
|
+
const text = buf.subarray(offset, nl).toString("utf-8");
|
|
261
|
+
offset = nl + 1;
|
|
262
|
+
if (firstBad === undefined) {
|
|
263
|
+
const rec = verifyLine(text, records.length + 1);
|
|
264
|
+
if (rec === "version")
|
|
265
|
+
throw new JournalVersionError(JSON.parse(text).v);
|
|
266
|
+
if (rec) {
|
|
267
|
+
records.push(rec);
|
|
268
|
+
validBytes = offset;
|
|
269
|
+
}
|
|
270
|
+
else {
|
|
271
|
+
firstBad = line;
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
else if (corruptAtLine === undefined && looksLikeRecord(text)) {
|
|
275
|
+
// A well-formed record after a bad line: the damage is not a torn tail.
|
|
276
|
+
corruptAtLine = firstBad;
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
const truncatedBytes = buf.length - validBytes;
|
|
280
|
+
if (opts.repair && truncatedBytes > 0 && corruptAtLine === undefined) {
|
|
281
|
+
fs.truncateSync(file, validBytes);
|
|
282
|
+
}
|
|
283
|
+
return { file, records, truncatedBytes, ...(corruptAtLine !== undefined ? { corruptAtLine } : {}) };
|
|
284
|
+
}
|
|
285
|
+
const visitKey = (node, iteration) => `${node}#${iteration}`;
|
|
286
|
+
/** Work out what a resume replays and where it picks up. */
|
|
287
|
+
export function buildResumePlan(records) {
|
|
288
|
+
const start = records.find((r) => r.type === "run:start");
|
|
289
|
+
if (!start)
|
|
290
|
+
throw new Error("run journal has no run:start record; nothing to resume");
|
|
291
|
+
const order = [];
|
|
292
|
+
const byKey = new Map();
|
|
293
|
+
const intents = new Map();
|
|
294
|
+
const receipts = new Map();
|
|
295
|
+
let attempts = 1;
|
|
296
|
+
let lastStatus;
|
|
297
|
+
for (const r of records) {
|
|
298
|
+
switch (r.type) {
|
|
299
|
+
case "run:resume":
|
|
300
|
+
attempts++;
|
|
301
|
+
lastStatus = undefined;
|
|
302
|
+
break;
|
|
303
|
+
case "node:start": {
|
|
304
|
+
const k = visitKey(r.node, r.iteration);
|
|
305
|
+
let v = byKey.get(k);
|
|
306
|
+
if (!v) {
|
|
307
|
+
v = { node: r.node, iteration: r.iteration };
|
|
308
|
+
byKey.set(k, v);
|
|
309
|
+
order.push(v);
|
|
310
|
+
}
|
|
311
|
+
else {
|
|
312
|
+
// Re-run of a visit an earlier attempt did not finish (or finished failed).
|
|
313
|
+
delete v.end;
|
|
314
|
+
delete v.next;
|
|
315
|
+
}
|
|
316
|
+
break;
|
|
317
|
+
}
|
|
318
|
+
case "node:checkpoint": {
|
|
319
|
+
const v = byKey.get(visitKey(r.node, r.iteration));
|
|
320
|
+
if (v)
|
|
321
|
+
v.checkpoint = r;
|
|
322
|
+
break;
|
|
323
|
+
}
|
|
324
|
+
case "node:end": {
|
|
325
|
+
const v = byKey.get(visitKey(r.node, r.iteration));
|
|
326
|
+
if (v)
|
|
327
|
+
v.end = r;
|
|
328
|
+
break;
|
|
329
|
+
}
|
|
330
|
+
case "route": {
|
|
331
|
+
const from = r.from;
|
|
332
|
+
const v = [...order].reverse().find((x) => x.node === from);
|
|
333
|
+
if (v)
|
|
334
|
+
v.next = r.to ?? null;
|
|
335
|
+
break;
|
|
336
|
+
}
|
|
337
|
+
case "output:intent":
|
|
338
|
+
intents.set(r.key, r);
|
|
339
|
+
break;
|
|
340
|
+
case "output:applied":
|
|
341
|
+
receipts.set(r.key, r);
|
|
342
|
+
break;
|
|
343
|
+
case "run:end":
|
|
344
|
+
lastStatus = r.status;
|
|
345
|
+
break;
|
|
346
|
+
}
|
|
347
|
+
}
|
|
348
|
+
// The first unfinished visit is where the resume picks up; everything before
|
|
349
|
+
// it replays. A last visit that ended failed (and was not routed on) re-runs.
|
|
350
|
+
const visits = [];
|
|
351
|
+
let writeState;
|
|
352
|
+
let resumed = false;
|
|
353
|
+
for (let i = 0; i < order.length; i++) {
|
|
354
|
+
const v = order[i];
|
|
355
|
+
const isLast = i === order.length - 1;
|
|
356
|
+
const complete = !!v.end && !(isLast && v.end.result.status === "failed" && typeof v.next !== "string");
|
|
357
|
+
const keys = (rec) => [...rec.values()].filter((o) => o.node === v.node && o.iteration === v.iteration).map((o) => o.key);
|
|
358
|
+
const applied = keys(receipts).length;
|
|
359
|
+
const unconfirmed = keys(intents).filter((k) => !receipts.has(k)).length;
|
|
360
|
+
if (complete && !resumed) {
|
|
361
|
+
visits.push({
|
|
362
|
+
node: v.node,
|
|
363
|
+
iteration: v.iteration,
|
|
364
|
+
action: "replay",
|
|
365
|
+
status: v.end.result.status,
|
|
366
|
+
next: v.next,
|
|
367
|
+
applied,
|
|
368
|
+
unconfirmed,
|
|
369
|
+
});
|
|
370
|
+
writeState = v.end.write_state;
|
|
371
|
+
continue;
|
|
372
|
+
}
|
|
373
|
+
resumed = true;
|
|
374
|
+
visits.push({
|
|
375
|
+
node: v.node,
|
|
376
|
+
iteration: v.iteration,
|
|
377
|
+
action: v.checkpoint ? "write-stage" : "rerun",
|
|
378
|
+
applied,
|
|
379
|
+
unconfirmed,
|
|
380
|
+
});
|
|
381
|
+
break;
|
|
382
|
+
}
|
|
383
|
+
const last = visits[visits.length - 1];
|
|
384
|
+
let freshNode;
|
|
385
|
+
if (!resumed)
|
|
386
|
+
freshNode = last ? (typeof last.next === "string" ? last.next : undefined) : start.workflow_entry;
|
|
387
|
+
const finished = !resumed && !!last && last.next === null;
|
|
388
|
+
const checkpoints = new Map();
|
|
389
|
+
const ends = new Map();
|
|
390
|
+
for (const v of order) {
|
|
391
|
+
if (v.checkpoint)
|
|
392
|
+
checkpoints.set(visitKey(v.node, v.iteration), v.checkpoint);
|
|
393
|
+
if (v.end)
|
|
394
|
+
ends.set(visitKey(v.node, v.iteration), v.end);
|
|
395
|
+
}
|
|
396
|
+
return {
|
|
397
|
+
runId: start.run_id,
|
|
398
|
+
start,
|
|
399
|
+
attempts,
|
|
400
|
+
...(lastStatus ? { lastStatus } : {}),
|
|
401
|
+
visits,
|
|
402
|
+
...(freshNode ? { freshNode } : {}),
|
|
403
|
+
finished,
|
|
404
|
+
...(writeState ? { writeState } : {}),
|
|
405
|
+
checkpoints,
|
|
406
|
+
ends,
|
|
407
|
+
intents,
|
|
408
|
+
receipts,
|
|
409
|
+
};
|
|
410
|
+
}
|
|
411
|
+
/**
|
|
412
|
+
* Nodes a resume would run again that may repeat a write outside safe outputs:
|
|
413
|
+
* the visit had started, was not read-only (no `permissions: read`, no
|
|
414
|
+
* `outputs`, not a dry run), and has no checkpoint. Its agent could have
|
|
415
|
+
* called a write tool, pushed, or edited files before the crash.
|
|
416
|
+
*/
|
|
417
|
+
export function mayRepeatWrites(plan, workflow, input) {
|
|
418
|
+
if (input && typeof input === "object" && input.dryRun === true)
|
|
419
|
+
return [];
|
|
420
|
+
return plan.visits
|
|
421
|
+
.filter((v) => v.action === "rerun")
|
|
422
|
+
.filter((v) => {
|
|
423
|
+
const node = workflow.nodes[v.node];
|
|
424
|
+
return !!node && resolveNodePermissions(node, workflow).access !== "read";
|
|
425
|
+
})
|
|
426
|
+
.map((v) => v.node);
|
|
427
|
+
}
|
|
428
|
+
/** Body field a write tool carries, where the idempotency marker goes. */
|
|
429
|
+
const MARKER_FIELD = {
|
|
430
|
+
github_create_issue: "body",
|
|
431
|
+
github_add_comment: "body",
|
|
432
|
+
linear_create_issue: "description",
|
|
433
|
+
linear_add_comment: "body",
|
|
434
|
+
};
|
|
435
|
+
/** Short, single-token marker the provider's search can find. */
|
|
436
|
+
export function markerToken(key) {
|
|
437
|
+
return `swenyk${key.slice(0, 24)}`;
|
|
438
|
+
}
|
|
439
|
+
function withMarker(toolName, args, key) {
|
|
440
|
+
const field = MARKER_FIELD[toolName];
|
|
441
|
+
if (!field || !args || typeof args !== "object")
|
|
442
|
+
return args;
|
|
443
|
+
const a = args;
|
|
444
|
+
const current = typeof a[field] === "string" ? a[field] : "";
|
|
445
|
+
return { ...a, [field]: `${current}${current ? "\n\n" : ""}<!-- sweny-output ${markerToken(key)} -->` };
|
|
446
|
+
}
|
|
447
|
+
async function callRead(skill, name, args, ctx) {
|
|
448
|
+
const tool = skill?.tools.find((t) => t.name === name && t.access === "read");
|
|
449
|
+
if (!tool)
|
|
450
|
+
throw new Error(`no ${name} tool configured`);
|
|
451
|
+
return tool.handler(args, ctx);
|
|
452
|
+
}
|
|
453
|
+
/**
|
|
454
|
+
* Was this write applied before the crash? Writes that are idempotent by
|
|
455
|
+
* nature (labels, state changes, a PR for the same head branch) are simply
|
|
456
|
+
* re-applied. Creates and comments are searched for by their marker.
|
|
457
|
+
*/
|
|
458
|
+
async function probeProvider(toolName, args, key, skill, ctx) {
|
|
459
|
+
const token = markerToken(key);
|
|
460
|
+
try {
|
|
461
|
+
switch (toolName) {
|
|
462
|
+
case "github_add_labels":
|
|
463
|
+
case "github_set_issue_state":
|
|
464
|
+
case "linear_set_issue_state":
|
|
465
|
+
case "github_create_pr":
|
|
466
|
+
return { state: "reapply" };
|
|
467
|
+
case "github_create_issue":
|
|
468
|
+
case "github_add_comment": {
|
|
469
|
+
const scope = toolName === "github_create_issue" ? "in:body is:issue" : "in:comments";
|
|
470
|
+
const out = (await callRead(skill, "github_search_issues", { query: `${token} ${scope}`, repo: args.repo }, ctx));
|
|
471
|
+
if (!out || !Array.isArray(out.items))
|
|
472
|
+
return { state: "unknown", reason: "search returned no result list" };
|
|
473
|
+
const hit = toolName === "github_create_issue"
|
|
474
|
+
? out.items.find((i) => typeof i.body === "string" && i.body.includes(token))
|
|
475
|
+
: out.items.find((i) => Number(i.number) === Number(args.issue_number));
|
|
476
|
+
if (!hit)
|
|
477
|
+
return { state: "absent" };
|
|
478
|
+
return { state: "applied", output: toolName === "github_create_issue" ? hit : {} };
|
|
479
|
+
}
|
|
480
|
+
case "linear_create_issue": {
|
|
481
|
+
const out = (await callRead(skill, "linear_search_issues", { query: token, limit: 5 }, ctx));
|
|
482
|
+
const nodes = out?.searchIssues?.nodes;
|
|
483
|
+
if (!Array.isArray(nodes))
|
|
484
|
+
return { state: "unknown", reason: "search returned no result list" };
|
|
485
|
+
return nodes.length > 0
|
|
486
|
+
? { state: "applied", output: { issueCreate: { issue: nodes[0] } } }
|
|
487
|
+
: { state: "absent" };
|
|
488
|
+
}
|
|
489
|
+
case "linear_add_comment": {
|
|
490
|
+
const out = (await callRead(skill, "linear_list_comments", { issueId: args.issueId }, ctx));
|
|
491
|
+
const nodes = out?.issue?.comments?.nodes;
|
|
492
|
+
if (!Array.isArray(nodes))
|
|
493
|
+
return { state: "unknown", reason: "comment list unavailable" };
|
|
494
|
+
const hit = nodes.find((c) => typeof c.body === "string" && c.body.includes(token));
|
|
495
|
+
return hit ? { state: "applied", output: { commentCreate: { comment: { id: hit.id } } } } : { state: "absent" };
|
|
496
|
+
}
|
|
497
|
+
default:
|
|
498
|
+
return { state: "unknown", reason: `no lookup for ${toolName}` };
|
|
499
|
+
}
|
|
500
|
+
}
|
|
501
|
+
catch (err) {
|
|
502
|
+
return { state: "unknown", reason: err instanceof Error ? err.message : String(err) };
|
|
503
|
+
}
|
|
504
|
+
}
|
|
505
|
+
/** The ids a write produced, nothing else (refs and URLs for receipts). */
|
|
506
|
+
function slimOutput(o, depth = 0) {
|
|
507
|
+
if (!o || typeof o !== "object" || Array.isArray(o) || depth > 3)
|
|
508
|
+
return null;
|
|
509
|
+
const out = {};
|
|
510
|
+
for (const [k, v] of Object.entries(o)) {
|
|
511
|
+
if (["number", "identifier", "id", "html_url", "url", "success", "reused"].includes(k)) {
|
|
512
|
+
if (typeof v === "string" || typeof v === "number" || typeof v === "boolean")
|
|
513
|
+
out[k] = v;
|
|
514
|
+
}
|
|
515
|
+
else if (v && typeof v === "object" && !Array.isArray(v)) {
|
|
516
|
+
const s = slimOutput(v, depth + 1);
|
|
517
|
+
if (s && Object.keys(s).length > 0)
|
|
518
|
+
out[k] = s;
|
|
519
|
+
}
|
|
520
|
+
}
|
|
521
|
+
return out;
|
|
522
|
+
}
|
|
523
|
+
export function journalDir(cwd, runId) {
|
|
524
|
+
return path.join(cwd, JOURNAL_DIR, runId);
|
|
525
|
+
}
|
|
526
|
+
/** Run ids with a journal on disk, oldest first. */
|
|
527
|
+
export function listJournalRuns(cwd = process.cwd()) {
|
|
528
|
+
try {
|
|
529
|
+
return fs
|
|
530
|
+
.readdirSync(path.join(cwd, JOURNAL_DIR))
|
|
531
|
+
.filter((d) => RUN_ID_RE.test(d) && fs.existsSync(path.join(cwd, JOURNAL_DIR, d, JOURNAL_FILE)))
|
|
532
|
+
.sort();
|
|
533
|
+
}
|
|
534
|
+
catch {
|
|
535
|
+
return [];
|
|
536
|
+
}
|
|
537
|
+
}
|
|
538
|
+
/** Exact run id, or a unique prefix. */
|
|
539
|
+
export function findJournalRun(ref, cwd = process.cwd()) {
|
|
540
|
+
const runs = listJournalRuns(cwd);
|
|
541
|
+
if (runs.includes(ref))
|
|
542
|
+
return ref;
|
|
543
|
+
const matches = runs.filter((r) => r.startsWith(ref));
|
|
544
|
+
return matches.length === 1 ? matches[0] : undefined;
|
|
545
|
+
}
|
|
546
|
+
/** Delete the oldest journals beyond `keep`, never `except`. Never throws. */
|
|
547
|
+
export function pruneJournals(cwd, keep = JOURNAL_KEEP, except) {
|
|
548
|
+
const runs = listJournalRuns(cwd).filter((r) => r !== except);
|
|
549
|
+
const extra = runs.slice(0, Math.max(0, runs.length - Math.max(0, keep - (except ? 1 : 0))));
|
|
550
|
+
let removed = 0;
|
|
551
|
+
for (const r of extra) {
|
|
552
|
+
try {
|
|
553
|
+
fs.rmSync(journalDir(cwd, r), { recursive: true, force: true });
|
|
554
|
+
removed++;
|
|
555
|
+
}
|
|
556
|
+
catch {
|
|
557
|
+
// leave it
|
|
558
|
+
}
|
|
559
|
+
}
|
|
560
|
+
return removed;
|
|
561
|
+
}
|
|
562
|
+
function pidAlive(pid) {
|
|
563
|
+
try {
|
|
564
|
+
process.kill(pid, 0);
|
|
565
|
+
return true;
|
|
566
|
+
}
|
|
567
|
+
catch (err) {
|
|
568
|
+
return err.code === "EPERM";
|
|
569
|
+
}
|
|
570
|
+
}
|
|
571
|
+
const quietLogger = { info() { }, warn() { }, error() { }, debug() { } };
|
|
572
|
+
export class RunJournal {
|
|
573
|
+
runId;
|
|
574
|
+
dir;
|
|
575
|
+
file;
|
|
576
|
+
cwd;
|
|
577
|
+
opts;
|
|
578
|
+
logger;
|
|
579
|
+
resume;
|
|
580
|
+
fd;
|
|
581
|
+
seq = 0;
|
|
582
|
+
dead = false;
|
|
583
|
+
began = false;
|
|
584
|
+
secrets = [];
|
|
585
|
+
consumed = new Set();
|
|
586
|
+
constructor(opts, resume) {
|
|
587
|
+
this.opts = opts;
|
|
588
|
+
this.runId = opts.runId;
|
|
589
|
+
this.cwd = opts.cwd ?? process.cwd();
|
|
590
|
+
this.dir = journalDir(this.cwd, opts.runId);
|
|
591
|
+
this.file = path.join(this.dir, JOURNAL_FILE);
|
|
592
|
+
this.logger = opts.logger ?? quietLogger;
|
|
593
|
+
if (resume) {
|
|
594
|
+
this.resume = {
|
|
595
|
+
plan: resume.plan,
|
|
596
|
+
force: resume.force === true,
|
|
597
|
+
allowRepeatWrites: resume.allowRepeatWrites === true,
|
|
598
|
+
};
|
|
599
|
+
this.seq = resume.read.records.length;
|
|
600
|
+
}
|
|
601
|
+
}
|
|
602
|
+
/** A journal for a new run. Nothing touches disk until the executor calls `begin`. */
|
|
603
|
+
static create(opts) {
|
|
604
|
+
return new RunJournal(opts);
|
|
605
|
+
}
|
|
606
|
+
/** A journal that continues `opts.read` (already repaired) under the resume plan. */
|
|
607
|
+
static openForResume(opts) {
|
|
608
|
+
const j = new RunJournal(opts, opts);
|
|
609
|
+
j.takeLock();
|
|
610
|
+
return j;
|
|
611
|
+
}
|
|
612
|
+
/** False once an append failed: the rest of the run is not journaled. */
|
|
613
|
+
get active() {
|
|
614
|
+
return !this.dead;
|
|
615
|
+
}
|
|
616
|
+
takeLock() {
|
|
617
|
+
const lock = path.join(this.dir, LOCK_FILE);
|
|
618
|
+
try {
|
|
619
|
+
const pid = Number(fs.readFileSync(lock, "utf-8").trim());
|
|
620
|
+
if (Number.isInteger(pid) && pid > 0 && pid !== process.pid && pidAlive(pid))
|
|
621
|
+
throw new JournalLockedError(pid);
|
|
622
|
+
}
|
|
623
|
+
catch (err) {
|
|
624
|
+
if (err instanceof JournalLockedError)
|
|
625
|
+
throw err;
|
|
626
|
+
}
|
|
627
|
+
fs.mkdirSync(this.dir, { recursive: true });
|
|
628
|
+
fs.writeFileSync(lock, String(process.pid));
|
|
629
|
+
}
|
|
630
|
+
releaseLock() {
|
|
631
|
+
try {
|
|
632
|
+
fs.rmSync(path.join(this.dir, LOCK_FILE), { force: true });
|
|
633
|
+
}
|
|
634
|
+
catch {
|
|
635
|
+
// nothing to do
|
|
636
|
+
}
|
|
637
|
+
}
|
|
638
|
+
append(type, fields) {
|
|
639
|
+
if (this.dead)
|
|
640
|
+
return;
|
|
641
|
+
const body = {
|
|
642
|
+
v: JOURNAL_SCHEMA_VERSION,
|
|
643
|
+
seq: this.seq + 1,
|
|
644
|
+
type,
|
|
645
|
+
at: new Date().toISOString(),
|
|
646
|
+
...fields,
|
|
647
|
+
};
|
|
648
|
+
const record = { ...body, h: recordHash(JSON.stringify(body)) };
|
|
649
|
+
if (this.opts.faults?.beforeAppend) {
|
|
650
|
+
try {
|
|
651
|
+
this.opts.faults.beforeAppend(record);
|
|
652
|
+
}
|
|
653
|
+
catch (err) {
|
|
654
|
+
// Simulated process death: nothing after this point reaches the disk.
|
|
655
|
+
this.dead = true;
|
|
656
|
+
this.closeFd();
|
|
657
|
+
this.releaseLock();
|
|
658
|
+
throw err;
|
|
659
|
+
}
|
|
660
|
+
}
|
|
661
|
+
try {
|
|
662
|
+
if (this.fd === undefined) {
|
|
663
|
+
fs.mkdirSync(this.dir, { recursive: true });
|
|
664
|
+
this.fd = fs.openSync(this.file, "a");
|
|
665
|
+
}
|
|
666
|
+
fs.writeSync(this.fd, JSON.stringify(record) + "\n");
|
|
667
|
+
fs.fsyncSync(this.fd);
|
|
668
|
+
this.seq++;
|
|
669
|
+
}
|
|
670
|
+
catch (err) {
|
|
671
|
+
this.dead = true;
|
|
672
|
+
this.closeFd();
|
|
673
|
+
this.logger.warn(` run journal: could not write ${this.file} (${err instanceof Error ? err.message : String(err)}); ` +
|
|
674
|
+
`this run cannot be resumed past this point`);
|
|
675
|
+
}
|
|
676
|
+
}
|
|
677
|
+
closeFd() {
|
|
678
|
+
if (this.fd === undefined)
|
|
679
|
+
return;
|
|
680
|
+
try {
|
|
681
|
+
fs.closeSync(this.fd);
|
|
682
|
+
}
|
|
683
|
+
catch {
|
|
684
|
+
// already closed
|
|
685
|
+
}
|
|
686
|
+
this.fd = undefined;
|
|
687
|
+
}
|
|
688
|
+
clean(value) {
|
|
689
|
+
return redact(value, this.secrets).value;
|
|
690
|
+
}
|
|
691
|
+
begin(info) {
|
|
692
|
+
if (this.began)
|
|
693
|
+
return;
|
|
694
|
+
this.began = true;
|
|
695
|
+
this.secrets = collectSecretValues(this.opts.env, info.config);
|
|
696
|
+
const workflowHash = workflowHashOf(info.workflow);
|
|
697
|
+
const instrHash = instructionHash(info.sources);
|
|
698
|
+
const toolHash = toolsHash(info.skills, this.opts.swenyVersion, info.harnessId);
|
|
699
|
+
if (this.resume) {
|
|
700
|
+
const start = this.resume.plan.start;
|
|
701
|
+
const changed = [];
|
|
702
|
+
if (start.workflow_hash !== workflowHash)
|
|
703
|
+
changed.push("the workflow");
|
|
704
|
+
if (start.instruction_hash !== instrHash)
|
|
705
|
+
changed.push("instructions, rules or context files");
|
|
706
|
+
if (start.input_hash !== canonicalHash(info.input))
|
|
707
|
+
changed.push("the input");
|
|
708
|
+
if (start.tools_hash !== toolHash)
|
|
709
|
+
changed.push("the configured skills, agent or sweny version");
|
|
710
|
+
if (changed.length > 0) {
|
|
711
|
+
if (!this.resume.force) {
|
|
712
|
+
// Nothing is appended for a refused resume: the journal stays as the crash left it.
|
|
713
|
+
this.dead = true;
|
|
714
|
+
this.closeFd();
|
|
715
|
+
this.releaseLock();
|
|
716
|
+
throw new JournalMismatchError(changed);
|
|
717
|
+
}
|
|
718
|
+
this.logger.warn(` resume --force: ${changed.join(", ")} changed since the run was journaled`);
|
|
719
|
+
}
|
|
720
|
+
const snap = this.resume.plan.writeState;
|
|
721
|
+
if (snap) {
|
|
722
|
+
info.writeState.counts = new Map(snap.counts);
|
|
723
|
+
info.writeState.total = snap.total;
|
|
724
|
+
info.writeState.seen = new Set(snap.seen);
|
|
725
|
+
}
|
|
726
|
+
this.append("run:resume", {
|
|
727
|
+
attempt: this.resume.plan.attempts + 1,
|
|
728
|
+
forced: this.resume.force && changed.length > 0,
|
|
729
|
+
allow_repeat_writes: this.resume.allowRepeatWrites,
|
|
730
|
+
...(changed.length > 0 ? { changed } : {}),
|
|
731
|
+
});
|
|
732
|
+
return;
|
|
733
|
+
}
|
|
734
|
+
try {
|
|
735
|
+
fs.mkdirSync(this.dir, { recursive: true });
|
|
736
|
+
// Journals hold node output: keep them out of commits (an agent's `git add -A` included).
|
|
737
|
+
fs.writeFileSync(path.join(this.dir, ".gitignore"), "*\n");
|
|
738
|
+
this.takeLock();
|
|
739
|
+
}
|
|
740
|
+
catch {
|
|
741
|
+
// append() reports the failure
|
|
742
|
+
}
|
|
743
|
+
pruneJournals(this.cwd, this.opts.keep ?? JOURNAL_KEEP, this.runId);
|
|
744
|
+
const input = redact(info.input, this.secrets);
|
|
745
|
+
this.append("run:start", {
|
|
746
|
+
run_id: this.runId,
|
|
747
|
+
workflow_id: info.workflow.id,
|
|
748
|
+
workflow_entry: info.workflow.entry,
|
|
749
|
+
...(this.opts.workflowFile ? { workflow_file: this.opts.workflowFile } : {}),
|
|
750
|
+
workflow_hash: workflowHash,
|
|
751
|
+
instruction_hash: instrHash,
|
|
752
|
+
input_hash: canonicalHash(info.input),
|
|
753
|
+
tools_hash: toolHash,
|
|
754
|
+
...(info.harnessId ? { harness: { id: info.harnessId } } : {}),
|
|
755
|
+
...(this.opts.swenyVersion ? { sweny_version: this.opts.swenyVersion } : {}),
|
|
756
|
+
input: input.value,
|
|
757
|
+
input_redacted: input.redacted,
|
|
758
|
+
});
|
|
759
|
+
}
|
|
760
|
+
replay(node, iteration) {
|
|
761
|
+
const plan = this.resume?.plan;
|
|
762
|
+
if (!plan)
|
|
763
|
+
return undefined;
|
|
764
|
+
const k = visitKey(node, iteration);
|
|
765
|
+
if (this.consumed.has(k))
|
|
766
|
+
return undefined;
|
|
767
|
+
const visit = plan.visits.find((v) => v.node === node && v.iteration === iteration);
|
|
768
|
+
if (!visit)
|
|
769
|
+
return undefined;
|
|
770
|
+
this.consumed.add(k);
|
|
771
|
+
if (visit.action === "replay") {
|
|
772
|
+
const end = plan.ends.get(k);
|
|
773
|
+
return {
|
|
774
|
+
kind: "complete",
|
|
775
|
+
result: structuredClone(end.result),
|
|
776
|
+
...(visit.next !== undefined ? { next: visit.next } : {}),
|
|
777
|
+
};
|
|
778
|
+
}
|
|
779
|
+
if (visit.action === "write-stage") {
|
|
780
|
+
const cp = plan.checkpoints.get(k);
|
|
781
|
+
return {
|
|
782
|
+
kind: "checkpoint",
|
|
783
|
+
result: structuredClone(cp.result),
|
|
784
|
+
intents: structuredClone(cp.intents),
|
|
785
|
+
agentRunFailed: cp.agent_failed,
|
|
786
|
+
attempt: cp.attempt,
|
|
787
|
+
};
|
|
788
|
+
}
|
|
789
|
+
return undefined;
|
|
790
|
+
}
|
|
791
|
+
nodeStart(node, iteration) {
|
|
792
|
+
this.append("node:start", { node, iteration });
|
|
793
|
+
}
|
|
794
|
+
checkpoint(node, iteration, cp) {
|
|
795
|
+
if (this.resume?.plan.checkpoints.has(visitKey(node, iteration)))
|
|
796
|
+
return;
|
|
797
|
+
this.append("node:checkpoint", {
|
|
798
|
+
node,
|
|
799
|
+
iteration,
|
|
800
|
+
result: this.clean(journalResult(cp.result)),
|
|
801
|
+
intents: this.clean(cp.intents),
|
|
802
|
+
agent_failed: cp.agentRunFailed,
|
|
803
|
+
attempt: cp.attempt,
|
|
804
|
+
});
|
|
805
|
+
}
|
|
806
|
+
nodeEnd(node, iteration, result, writeState) {
|
|
807
|
+
this.append("node:end", {
|
|
808
|
+
node,
|
|
809
|
+
iteration,
|
|
810
|
+
result: this.clean(journalResult(result)),
|
|
811
|
+
write_state: { counts: [...writeState.counts], total: writeState.total, seen: [...writeState.seen] },
|
|
812
|
+
});
|
|
813
|
+
}
|
|
814
|
+
route(from, to) {
|
|
815
|
+
this.append("route", { from, to });
|
|
816
|
+
}
|
|
817
|
+
/** Close the run: `run:end`, then release the lock. Safe to call twice. */
|
|
818
|
+
end(status) {
|
|
819
|
+
if (this.began)
|
|
820
|
+
this.append("run:end", { status });
|
|
821
|
+
this.closeFd();
|
|
822
|
+
this.releaseLock();
|
|
823
|
+
}
|
|
824
|
+
wrapWrites(node, iteration, skills) {
|
|
825
|
+
const out = new Map();
|
|
826
|
+
for (const [id, skill] of skills) {
|
|
827
|
+
out.set(id, {
|
|
828
|
+
...skill,
|
|
829
|
+
tools: skill.tools.map((t) => (t.access === "read" ? t : this.wrapTool(node, iteration, skill, t))),
|
|
830
|
+
});
|
|
831
|
+
}
|
|
832
|
+
return out;
|
|
833
|
+
}
|
|
834
|
+
wrapTool(node, iteration, skill, tool) {
|
|
835
|
+
return {
|
|
836
|
+
...tool,
|
|
837
|
+
handler: async (args, ctx) => {
|
|
838
|
+
const key = canonicalHash({ node, iteration, tool: tool.name, args });
|
|
839
|
+
const plan = this.resume?.plan;
|
|
840
|
+
const receipt = plan?.receipts.get(key);
|
|
841
|
+
if (receipt) {
|
|
842
|
+
this.logger.info(` run journal: ${tool.name} already applied before the crash; not re-sent`, { node });
|
|
843
|
+
return receipt.output ?? null;
|
|
844
|
+
}
|
|
845
|
+
const replayingStage = !!plan?.checkpoints.has(visitKey(node, iteration));
|
|
846
|
+
const pending = plan?.intents.has(key) === true;
|
|
847
|
+
if (pending || replayingStage) {
|
|
848
|
+
const probe = await probeProvider(tool.name, (args ?? {}), key, skill, ctx);
|
|
849
|
+
if (probe.state === "applied") {
|
|
850
|
+
const output = slimOutput(probe.output);
|
|
851
|
+
this.append("output:applied", { node, iteration, key, tool: tool.name, output, recovered: true });
|
|
852
|
+
this.logger.info(` run journal: ${tool.name} found on the provider; not re-sent`, { node });
|
|
853
|
+
return output;
|
|
854
|
+
}
|
|
855
|
+
if (probe.state === "unknown" && pending && !this.resume?.allowRepeatWrites) {
|
|
856
|
+
throw new Error(`cannot confirm whether ${tool.name} was applied before the crash (${probe.reason}); ` +
|
|
857
|
+
`check the target, then resume with --allow-repeat-writes to send it again`);
|
|
858
|
+
}
|
|
859
|
+
}
|
|
860
|
+
this.append("output:intent", { node, iteration, key, tool: tool.name });
|
|
861
|
+
const output = await tool.handler(withMarker(tool.name, args, key), ctx);
|
|
862
|
+
this.append("output:applied", { node, iteration, key, tool: tool.name, output: slimOutput(output) });
|
|
863
|
+
return output;
|
|
864
|
+
},
|
|
865
|
+
};
|
|
866
|
+
}
|
|
867
|
+
}
|
|
868
|
+
/** What a node result needs to continue a run. Tool call inputs and outputs are dropped. */
|
|
869
|
+
function journalResult(r) {
|
|
870
|
+
return {
|
|
871
|
+
status: r.status,
|
|
872
|
+
data: r.data ?? {},
|
|
873
|
+
toolCalls: (r.toolCalls ?? []).map((t) => ({
|
|
874
|
+
tool: t.tool,
|
|
875
|
+
input: null,
|
|
876
|
+
...(t.status ? { status: t.status } : {}),
|
|
877
|
+
})),
|
|
878
|
+
...(r.evals ? { evals: r.evals } : {}),
|
|
879
|
+
...(r.usage ? { usage: r.usage } : {}),
|
|
880
|
+
...(r.skippedWrites ? { skippedWrites: r.skippedWrites } : {}),
|
|
881
|
+
...(r.harness ? { harness: r.harness } : {}),
|
|
882
|
+
...(r.degraded ? { degraded: r.degraded } : {}),
|
|
883
|
+
...(r.outputs ? { outputs: r.outputs } : {}),
|
|
884
|
+
};
|
|
885
|
+
}
|