@sweny-ai/core 0.1.178 → 0.1.179
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 +5 -3
- package/dist/agent-env.js +12 -7
- package/dist/cli/final-output.js +4 -1
- package/dist/cli/main.js +3 -2
- package/dist/cli/resume.d.ts +10 -1
- package/dist/cli/resume.js +76 -7
- package/dist/executor.d.ts +1 -1
- package/dist/executor.js +77 -4
- package/dist/harness/acp.js +2 -0
- package/dist/harness/claude-code.d.ts +18 -0
- package/dist/harness/claude-code.js +51 -6
- package/dist/harness/codex.js +2 -0
- package/dist/harness/pi.js +2 -0
- package/dist/harness/sandbox-wrapper.js +8 -8
- package/dist/journal.d.ts +225 -70
- package/dist/journal.js +713 -202
- package/dist/skills/github.js +39 -0
- package/dist/types.d.ts +6 -0
- package/package.json +1 -1
package/dist/journal.js
CHANGED
|
@@ -2,23 +2,42 @@
|
|
|
2
2
|
* Run journal (#363): an append-only log of one workflow run, enough to resume
|
|
3
3
|
* a killed or failed run without re-running the nodes that already finished.
|
|
4
4
|
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
* a per-run key). Each append is written and fsync'd before the executor moves
|
|
9
|
-
* on, so a record on disk is a fact.
|
|
5
|
+
* Location: the journal never lives in the workspace. Each run gets a private
|
|
6
|
+
* directory in the user's sweny state dir (`$SWENY_STATE_DIR`, else
|
|
7
|
+
* `$XDG_STATE_HOME/sweny`, else `~/.local/state/sweny`):
|
|
10
8
|
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
15
|
-
*
|
|
16
|
-
*
|
|
17
|
-
*
|
|
18
|
-
*
|
|
19
|
-
*
|
|
20
|
-
*
|
|
21
|
-
* the
|
|
9
|
+
* runs/<workspace-hash>/<run-id>/ (dirs 0700, files 0600, opened O_NOFOLLOW)
|
|
10
|
+
* key 32 random bytes, the run's HMAC key
|
|
11
|
+
* journal.ndjson one JSON record per line, fsync'd per append
|
|
12
|
+
* head.json the record being appended, written (atomically) first
|
|
13
|
+
* meta.json run id, workspace hash, creation time; authenticated, drives retention
|
|
14
|
+
* lock the process that owns the run (pid + start time)
|
|
15
|
+
*
|
|
16
|
+
* Both agent sandboxes deny reading AND writing the whole `runs/` tree, so an
|
|
17
|
+
* agent confined to the workspace cannot read the key, edit or cut the
|
|
18
|
+
* journal, delete the lock or fake a run. The workspace keeps only user-facing
|
|
19
|
+
* artifacts (`.sweny/runs/<run-id>/output.md` and the run history record).
|
|
20
|
+
* Journals an older sweny wrote in the workspace are refused for resume.
|
|
21
|
+
*
|
|
22
|
+
* Integrity: every record carries the format version (`v`), a sequence number
|
|
23
|
+
* (`seq`) and `h`, an HMAC-SHA256 under the run key over the run id, the seq,
|
|
24
|
+
* the previous record's `h` and the record body. The chain means records
|
|
25
|
+
* cannot be reordered, duplicated, or spliced in from another run. The head
|
|
26
|
+
* file holds the full record being appended, written and renamed into place
|
|
27
|
+
* BEFORE the journal append: a crash between the two leaves a journal exactly
|
|
28
|
+
* one record short, and resume restores that record from the head. A journal
|
|
29
|
+
* shorter than that, or one that disagrees with the head, is refused, so the
|
|
30
|
+
* end of the journal cannot be cut away even by something that can write the
|
|
31
|
+
* state dir but not read the key.
|
|
32
|
+
*
|
|
33
|
+
* All of this holds only for an agent under an enforced sandbox. An
|
|
34
|
+
* unsandboxed agent running as the same OS user can read the key and rewrite
|
|
35
|
+
* the state dir. Each such visit is journaled (`agent:unsandboxed`), and a
|
|
36
|
+
* resume of that run warns and marks the receipt `journal_unsandboxed`.
|
|
37
|
+
* Resume also checks the record sequence (see {@link buildResumePlan}) and
|
|
38
|
+
* every replayed route against the workflow's real edges. A state dir whose
|
|
39
|
+
* real path is inside the workspace is refused. A resume on another machine
|
|
40
|
+
* needs the same state dir.
|
|
22
41
|
*
|
|
23
42
|
* Records, in run order:
|
|
24
43
|
* run:start workflow / instruction / input / tool hashes, harness id, input (redacted)
|
|
@@ -30,17 +49,31 @@
|
|
|
30
49
|
* output:applied the write returned (key + the ids it produced)
|
|
31
50
|
* node:end final result of the visit and the write-stage counters
|
|
32
51
|
* route the edge taken after the visit (null = the run ended), and the rung that chose it
|
|
33
|
-
* usage spend of one agent attempt (cumulative;
|
|
52
|
+
* usage spend of one agent attempt (cumulative; one per model turn that raised it, written
|
|
53
|
+
* synchronously, and the final figure when the attempt returns)
|
|
54
|
+
* agent:unsandboxed the visit's agent ran without an enforced sandbox
|
|
34
55
|
* run:end success | failed | crashed
|
|
35
56
|
*
|
|
57
|
+
* Failure: an append that cannot be written (head or journal) is fatal. The
|
|
58
|
+
* executor stops the run before the next model call or side effect and fails
|
|
59
|
+
* the node with the reason, so a resume never under-counts spend or repeats a
|
|
60
|
+
* write the journal did not see.
|
|
61
|
+
*
|
|
36
62
|
* Spend: a resume seeds the run budget with every attempt's journaled usage,
|
|
37
|
-
* so a crash never resets the whole-run ceiling.
|
|
63
|
+
* from authenticated records only, so a crash never resets the whole-run ceiling.
|
|
38
64
|
*
|
|
39
|
-
* Recovery:
|
|
40
|
-
*
|
|
65
|
+
* Recovery: an unterminated tail that is not complete JSON (power loss
|
|
66
|
+
* mid-append) is truncated. An unterminated tail that IS complete JSON is
|
|
67
|
+
* never assumed torn: it must authenticate as the next record, or the journal
|
|
68
|
+
* is refused. Corruption in the middle of the file is refused.
|
|
41
69
|
*
|
|
42
|
-
*
|
|
43
|
-
*
|
|
70
|
+
* Lock: taken with O_CREAT|O_EXCL before anything is read or repaired, and
|
|
71
|
+
* held for the whole run or resume. A lock whose process is gone (pid not
|
|
72
|
+
* alive, or alive with a different start time) is taken over under a
|
|
73
|
+
* second exclusive lock, re-checked first, so two resumes cannot both win.
|
|
74
|
+
*
|
|
75
|
+
* Privacy: the journal holds what the run needs to continue (node data, eval
|
|
76
|
+
* verdicts, safe-output intents). It never holds environment values:
|
|
44
77
|
* secret-looking keys, known token shapes, and the values of secret env vars
|
|
45
78
|
* and skill credentials are replaced with `[redacted]` before anything is
|
|
46
79
|
* written. Tool call inputs and outputs are not journaled.
|
|
@@ -59,13 +92,19 @@ import path from "node:path";
|
|
|
59
92
|
import { resolveNodePermissions } from "./node-policy.js";
|
|
60
93
|
import { CURRENT_SPEC_VERSION } from "./migrations.js";
|
|
61
94
|
import { spendOf } from "./budget.js";
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
95
|
+
/**
|
|
96
|
+
* v3: each record's HMAC chains the run id and the previous record's HMAC.
|
|
97
|
+
* (v2 authenticated records one by one; v1 used a public checksum. Both are refused.)
|
|
98
|
+
*/
|
|
99
|
+
export const JOURNAL_SCHEMA_VERSION = 3;
|
|
100
|
+
/** Where journals lived before v3: in the workspace. Never read for resume, only recognized to refuse. */
|
|
101
|
+
export const LEGACY_JOURNAL_DIR = path.join(".sweny", "runs");
|
|
66
102
|
export const JOURNAL_FILE = "journal.ndjson";
|
|
67
|
-
const
|
|
68
|
-
|
|
103
|
+
const KEY_FILE = "key";
|
|
104
|
+
const HEAD_FILE = "head.json";
|
|
105
|
+
const META_FILE = "meta.json";
|
|
106
|
+
const LOCK_FILE = "lock";
|
|
107
|
+
/** Journals kept per workspace; the oldest beyond this are deleted when a new run starts. */
|
|
69
108
|
export const JOURNAL_KEEP = 20;
|
|
70
109
|
export const REDACTED = "[redacted]";
|
|
71
110
|
const RUN_ID_RE = /^\d{8}-\d{6}-[0-9a-f]{6}$/;
|
|
@@ -153,7 +192,7 @@ export function toolsHash(skills, swenyVersion, harnessId, secrets = []) {
|
|
|
153
192
|
function recordMac(key, body) {
|
|
154
193
|
return crypto.createHmac("sha256", key).update(body).digest("hex");
|
|
155
194
|
}
|
|
156
|
-
// ───
|
|
195
|
+
// ─── State dir layout ─────────────────────────────────────────────
|
|
157
196
|
/** sweny's per-user state dir: `$SWENY_STATE_DIR`, else `$XDG_STATE_HOME/sweny`, else `~/.local/state/sweny`. */
|
|
158
197
|
export function swenyStateDir(env = process.env) {
|
|
159
198
|
if (env.SWENY_STATE_DIR && env.SWENY_STATE_DIR.trim() !== "")
|
|
@@ -162,9 +201,9 @@ export function swenyStateDir(env = process.env) {
|
|
|
162
201
|
const base = xdg && path.isAbsolute(xdg) ? xdg : path.join(os.homedir(), ".local", "state");
|
|
163
202
|
return path.join(base, "sweny");
|
|
164
203
|
}
|
|
165
|
-
/** Where run
|
|
166
|
-
export function
|
|
167
|
-
return path.join(swenyStateDir(env), "
|
|
204
|
+
/** Where every run journal (and its key, head, meta and lock) lives. Agents can neither read nor write it. */
|
|
205
|
+
export function runStateRoot(env = process.env) {
|
|
206
|
+
return path.join(swenyStateDir(env), "runs");
|
|
168
207
|
}
|
|
169
208
|
function realOr(p) {
|
|
170
209
|
try {
|
|
@@ -174,10 +213,93 @@ function realOr(p) {
|
|
|
174
213
|
return path.resolve(p);
|
|
175
214
|
}
|
|
176
215
|
}
|
|
177
|
-
/** The
|
|
178
|
-
export function
|
|
179
|
-
|
|
180
|
-
|
|
216
|
+
/** The workspace's directory name under {@link runStateRoot}: its real path hashed, never stored. */
|
|
217
|
+
export function workspaceScope(cwd) {
|
|
218
|
+
return sha256(realOr(cwd)).slice(0, 16);
|
|
219
|
+
}
|
|
220
|
+
/** One run's private directory: `<state>/runs/<workspace-hash>/<run-id>/`. */
|
|
221
|
+
export function journalDir(cwd, runId, root = runStateRoot()) {
|
|
222
|
+
return path.join(root, workspaceScope(cwd), runId);
|
|
223
|
+
}
|
|
224
|
+
/** The run's key file (inside its private directory). */
|
|
225
|
+
export function runKeyFile(cwd, runId, root = runStateRoot()) {
|
|
226
|
+
return path.join(journalDir(cwd, runId, root), KEY_FILE);
|
|
227
|
+
}
|
|
228
|
+
const NOFOLLOW = fs.constants.O_NOFOLLOW ?? 0;
|
|
229
|
+
/** Read a file without following a symlink at its last component. */
|
|
230
|
+
function readNoFollow(file) {
|
|
231
|
+
const fd = fs.openSync(file, fs.constants.O_RDONLY | NOFOLLOW);
|
|
232
|
+
try {
|
|
233
|
+
return fs.readFileSync(fd);
|
|
234
|
+
}
|
|
235
|
+
finally {
|
|
236
|
+
fs.closeSync(fd);
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
/** Create a new file (never an existing one, never through a symlink), write it and fsync it. */
|
|
240
|
+
function writeNew(file, data) {
|
|
241
|
+
const c = fs.constants;
|
|
242
|
+
const fd = fs.openSync(file, c.O_WRONLY | c.O_CREAT | c.O_EXCL | NOFOLLOW, 0o600);
|
|
243
|
+
try {
|
|
244
|
+
fs.writeSync(fd, data);
|
|
245
|
+
fs.fsyncSync(fd);
|
|
246
|
+
}
|
|
247
|
+
finally {
|
|
248
|
+
fs.closeSync(fd);
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
function fsyncDir(dir) {
|
|
252
|
+
try {
|
|
253
|
+
const fd = fs.openSync(dir, "r");
|
|
254
|
+
try {
|
|
255
|
+
fs.fsyncSync(fd);
|
|
256
|
+
}
|
|
257
|
+
finally {
|
|
258
|
+
fs.closeSync(fd);
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
catch {
|
|
262
|
+
// Not every platform can fsync a directory.
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
/** Replace a file atomically: write a new temp file, fsync, rename over, fsync the dir. */
|
|
266
|
+
function writeAtomic(file, data) {
|
|
267
|
+
const tmp = `${file}.${process.pid}.tmp`;
|
|
268
|
+
fs.rmSync(tmp, { force: true });
|
|
269
|
+
try {
|
|
270
|
+
writeNew(tmp, data);
|
|
271
|
+
fs.renameSync(tmp, file);
|
|
272
|
+
}
|
|
273
|
+
catch (err) {
|
|
274
|
+
fs.rmSync(tmp, { force: true });
|
|
275
|
+
throw err;
|
|
276
|
+
}
|
|
277
|
+
fsyncDir(path.dirname(file));
|
|
278
|
+
}
|
|
279
|
+
/** Make a run's private directory (and its parents) 0700. */
|
|
280
|
+
function makePrivateDir(dir) {
|
|
281
|
+
fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
|
|
282
|
+
fs.chmodSync(dir, 0o700);
|
|
283
|
+
}
|
|
284
|
+
/** Thrown when the journal's state dir resolves into the workspace, where an agent could edit it. */
|
|
285
|
+
export class JournalLocationError extends Error {
|
|
286
|
+
constructor(dir, cwd) {
|
|
287
|
+
super(`the run journal's state dir ${dir} resolves inside the workspace ${cwd}, where an agent can edit it. ` +
|
|
288
|
+
`Set SWENY_STATE_DIR to a dir outside the workspace, or pass --no-journal (the run then cannot be resumed)`);
|
|
289
|
+
this.name = "JournalLocationError";
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
/**
|
|
293
|
+
* Refuse a state dir that resolves into the workspace. Real paths are compared
|
|
294
|
+
* (the dir must exist), so a symlink anywhere in either chain counts as where
|
|
295
|
+
* it points. Throws {@link JournalLocationError}.
|
|
296
|
+
*/
|
|
297
|
+
export function assertStateOutsideWorkspace(dir, cwd) {
|
|
298
|
+
const real = fs.realpathSync(dir);
|
|
299
|
+
const ws = fs.realpathSync(cwd);
|
|
300
|
+
if (real === ws || real.startsWith(ws.endsWith(path.sep) ? ws : ws + path.sep)) {
|
|
301
|
+
throw new JournalLocationError(real, ws);
|
|
302
|
+
}
|
|
181
303
|
}
|
|
182
304
|
/** Thrown when a journal's key is missing or unreadable: its records cannot be checked. */
|
|
183
305
|
export class JournalKeyError extends Error {
|
|
@@ -189,16 +311,14 @@ export class JournalKeyError extends Error {
|
|
|
189
311
|
}
|
|
190
312
|
function createRunKey(file) {
|
|
191
313
|
const key = crypto.randomBytes(32);
|
|
192
|
-
|
|
193
|
-
fs.writeFileSync(file, key.toString("hex") + "\n", { mode: 0o600 });
|
|
194
|
-
fs.chmodSync(file, 0o600);
|
|
314
|
+
writeNew(file, key.toString("hex") + "\n");
|
|
195
315
|
return key;
|
|
196
316
|
}
|
|
197
317
|
/** Read a run key. Throws {@link JournalKeyError}. */
|
|
198
318
|
export function loadRunKey(file) {
|
|
199
319
|
let text;
|
|
200
320
|
try {
|
|
201
|
-
text =
|
|
321
|
+
text = readNoFollow(file).toString("utf-8").trim();
|
|
202
322
|
}
|
|
203
323
|
catch {
|
|
204
324
|
throw new JournalKeyError(file, "missing");
|
|
@@ -207,6 +327,225 @@ export function loadRunKey(file) {
|
|
|
207
327
|
throw new JournalKeyError(file, "not a valid key");
|
|
208
328
|
return Buffer.from(text, "hex");
|
|
209
329
|
}
|
|
330
|
+
/** MAC of one record: run id, seq and the previous record's MAC chained in, so records cannot move. */
|
|
331
|
+
function chainMac(key, runId, seq, prev, body) {
|
|
332
|
+
return recordMac(key, `sweny-journal\n${runId}\n${seq}\n${prev}\n${JSON.stringify(body)}`);
|
|
333
|
+
}
|
|
334
|
+
/** MAC of a side file (head, meta): domain-separated from records. */
|
|
335
|
+
function sideMac(key, kind, body) {
|
|
336
|
+
return recordMac(key, `sweny-journal-${kind}\n${JSON.stringify(body)}`);
|
|
337
|
+
}
|
|
338
|
+
function sameMac(given, expected) {
|
|
339
|
+
if (typeof given !== "string" || !/^[0-9a-f]{64}$/.test(given))
|
|
340
|
+
return false;
|
|
341
|
+
return crypto.timingSafeEqual(Buffer.from(given, "hex"), Buffer.from(expected, "hex"));
|
|
342
|
+
}
|
|
343
|
+
/** Thrown when the journal is shorter than, or disagrees with, the record its head file holds. */
|
|
344
|
+
export class JournalRollbackError extends Error {
|
|
345
|
+
constructor(message) {
|
|
346
|
+
super(`${message}. Refusing to resume (not overridable by --force); start a new run.`);
|
|
347
|
+
this.name = "JournalRollbackError";
|
|
348
|
+
}
|
|
349
|
+
}
|
|
350
|
+
const SIDE_VERSION = 1;
|
|
351
|
+
function writeJournalHead(dir, key, runId, head) {
|
|
352
|
+
const body = { v: SIDE_VERSION, run_id: runId, seq: head.seq, record: head.record };
|
|
353
|
+
writeAtomic(path.join(dir, HEAD_FILE), JSON.stringify({ ...body, h: sideMac(key, "head", body) }) + "\n");
|
|
354
|
+
}
|
|
355
|
+
/** Read and authenticate a run's head file. Throws {@link JournalRollbackError}. */
|
|
356
|
+
export function loadJournalHead(dir, key, runId) {
|
|
357
|
+
const file = path.join(dir, HEAD_FILE);
|
|
358
|
+
let text;
|
|
359
|
+
try {
|
|
360
|
+
text = readNoFollow(file).toString("utf-8");
|
|
361
|
+
}
|
|
362
|
+
catch {
|
|
363
|
+
throw new JournalRollbackError(`cannot check the run journal's length: its head file ${file} is missing`);
|
|
364
|
+
}
|
|
365
|
+
const invalid = () => new JournalRollbackError(`the run journal's head file ${file} is not valid`);
|
|
366
|
+
let parsed;
|
|
367
|
+
try {
|
|
368
|
+
parsed = JSON.parse(text);
|
|
369
|
+
}
|
|
370
|
+
catch {
|
|
371
|
+
throw invalid();
|
|
372
|
+
}
|
|
373
|
+
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed))
|
|
374
|
+
throw invalid();
|
|
375
|
+
const { h, ...body } = parsed;
|
|
376
|
+
if (!sameMac(h, sideMac(key, "head", body)))
|
|
377
|
+
throw invalid();
|
|
378
|
+
const { v, run_id, seq, record } = body;
|
|
379
|
+
if (v !== SIDE_VERSION || run_id !== runId)
|
|
380
|
+
throw invalid();
|
|
381
|
+
if (typeof seq !== "number" || !Number.isInteger(seq) || seq < 0)
|
|
382
|
+
throw invalid();
|
|
383
|
+
if (seq === 0 ? record !== null : !record || typeof record !== "object" || Array.isArray(record))
|
|
384
|
+
throw invalid();
|
|
385
|
+
return { seq, record: record };
|
|
386
|
+
}
|
|
387
|
+
function writeRunMeta(dir, key, runId, scope) {
|
|
388
|
+
const body = { v: SIDE_VERSION, run_id: runId, scope, created_at: new Date().toISOString() };
|
|
389
|
+
writeNew(path.join(dir, META_FILE), JSON.stringify({ ...body, h: sideMac(key, "meta", body) }) + "\n");
|
|
390
|
+
}
|
|
391
|
+
/** A run's authenticated metadata, or undefined when it is missing, unreadable or not this run's. */
|
|
392
|
+
function loadRunMeta(dir, runId, scope) {
|
|
393
|
+
try {
|
|
394
|
+
const key = loadRunKey(path.join(dir, KEY_FILE));
|
|
395
|
+
const parsed = JSON.parse(readNoFollow(path.join(dir, META_FILE)).toString("utf-8"));
|
|
396
|
+
const { h, ...body } = parsed;
|
|
397
|
+
if (!sameMac(h, sideMac(key, "meta", body)))
|
|
398
|
+
return undefined;
|
|
399
|
+
if (body.v !== SIDE_VERSION || body.run_id !== runId || body.scope !== scope)
|
|
400
|
+
return undefined;
|
|
401
|
+
if (typeof body.created_at !== "string" || Number.isNaN(Date.parse(body.created_at)))
|
|
402
|
+
return undefined;
|
|
403
|
+
return { runId, createdAt: body.created_at };
|
|
404
|
+
}
|
|
405
|
+
catch {
|
|
406
|
+
return undefined;
|
|
407
|
+
}
|
|
408
|
+
}
|
|
409
|
+
// ─── Lock ─────────────────────────────────────────────────────────
|
|
410
|
+
/** Thrown when another live process holds the run. */
|
|
411
|
+
export class JournalLockedError extends Error {
|
|
412
|
+
pid;
|
|
413
|
+
constructor(pid) {
|
|
414
|
+
super(`run journal is in use by process ${pid}; wait for it to finish (or stop it) before resuming`);
|
|
415
|
+
this.pid = pid;
|
|
416
|
+
this.name = "JournalLockedError";
|
|
417
|
+
}
|
|
418
|
+
}
|
|
419
|
+
/** Start time (ms since epoch) of a process, where the OS exposes it cheaply (Linux); else undefined. */
|
|
420
|
+
export function processStartTime(pid) {
|
|
421
|
+
if (process.platform !== "linux")
|
|
422
|
+
return undefined;
|
|
423
|
+
try {
|
|
424
|
+
const stat = fs.readFileSync(`/proc/${pid}/stat`, "utf-8");
|
|
425
|
+
const fields = stat.slice(stat.lastIndexOf(")") + 2).split(" ");
|
|
426
|
+
const ticks = Number(fields[19]);
|
|
427
|
+
const btime = Number(/^btime (\d+)$/m.exec(fs.readFileSync("/proc/stat", "utf-8"))?.[1]);
|
|
428
|
+
if (!Number.isFinite(ticks) || !Number.isFinite(btime))
|
|
429
|
+
return undefined;
|
|
430
|
+
return btime * 1000 + ticks * 10;
|
|
431
|
+
}
|
|
432
|
+
catch {
|
|
433
|
+
return undefined;
|
|
434
|
+
}
|
|
435
|
+
}
|
|
436
|
+
const SELF_STARTED = processStartTime(process.pid) ?? Math.round(Date.now() - process.uptime() * 1000);
|
|
437
|
+
/** Start times are compared with this much slack (clock ticks, boot time rounding). */
|
|
438
|
+
const START_SLACK_MS = 5000;
|
|
439
|
+
function pidAlive(pid) {
|
|
440
|
+
try {
|
|
441
|
+
process.kill(pid, 0);
|
|
442
|
+
return true;
|
|
443
|
+
}
|
|
444
|
+
catch (err) {
|
|
445
|
+
return err.code === "EPERM";
|
|
446
|
+
}
|
|
447
|
+
}
|
|
448
|
+
function parseHolder(raw) {
|
|
449
|
+
try {
|
|
450
|
+
const p = JSON.parse(raw);
|
|
451
|
+
if (!Number.isInteger(p.pid) || p.pid <= 0 || typeof p.started !== "number")
|
|
452
|
+
return undefined;
|
|
453
|
+
return { pid: p.pid, started: p.started };
|
|
454
|
+
}
|
|
455
|
+
catch {
|
|
456
|
+
return undefined;
|
|
457
|
+
}
|
|
458
|
+
}
|
|
459
|
+
/**
|
|
460
|
+
* Is the process that wrote this lock still running? A reused pid (other
|
|
461
|
+
* start time) is not. Where the start time is not available (macOS, Windows),
|
|
462
|
+
* any live process with that pid counts as the holder: a reused pid errs
|
|
463
|
+
* toward refusing the resume, never toward two processes sharing the run.
|
|
464
|
+
*/
|
|
465
|
+
function holderAlive(h) {
|
|
466
|
+
if (h.pid === process.pid)
|
|
467
|
+
return Math.abs(h.started - SELF_STARTED) <= START_SLACK_MS;
|
|
468
|
+
if (!pidAlive(h.pid))
|
|
469
|
+
return false;
|
|
470
|
+
const started = processStartTime(h.pid);
|
|
471
|
+
return started === undefined || Math.abs(started - h.started) <= START_SLACK_MS;
|
|
472
|
+
}
|
|
473
|
+
function readText(file) {
|
|
474
|
+
try {
|
|
475
|
+
return readNoFollow(file).toString("utf-8");
|
|
476
|
+
}
|
|
477
|
+
catch {
|
|
478
|
+
return undefined;
|
|
479
|
+
}
|
|
480
|
+
}
|
|
481
|
+
/** A takeover lock older than this belongs to a process that died mid-takeover. */
|
|
482
|
+
const TAKEOVER_STALE_MS = 10_000;
|
|
483
|
+
/**
|
|
484
|
+
* Take a run's lock: O_CREAT|O_EXCL in the run's private dir. A lock whose
|
|
485
|
+
* process is gone is taken over under a second exclusive lock, and only if it
|
|
486
|
+
* still holds the exact bytes judged stale, so two processes racing for a
|
|
487
|
+
* stale lock cannot both win. Throws {@link JournalLockedError}.
|
|
488
|
+
*/
|
|
489
|
+
export function acquireRunLock(dir, hooks = {}) {
|
|
490
|
+
const file = path.join(dir, LOCK_FILE);
|
|
491
|
+
const mine = JSON.stringify({
|
|
492
|
+
pid: process.pid,
|
|
493
|
+
started: SELF_STARTED,
|
|
494
|
+
nonce: crypto.randomBytes(8).toString("hex"),
|
|
495
|
+
});
|
|
496
|
+
let lastPid = 0;
|
|
497
|
+
for (let attempt = 0; attempt < 4; attempt++) {
|
|
498
|
+
try {
|
|
499
|
+
writeNew(file, mine);
|
|
500
|
+
return {
|
|
501
|
+
file,
|
|
502
|
+
release() {
|
|
503
|
+
// Only ever remove our own lock.
|
|
504
|
+
if (readText(file) === mine)
|
|
505
|
+
fs.rmSync(file, { force: true });
|
|
506
|
+
},
|
|
507
|
+
};
|
|
508
|
+
}
|
|
509
|
+
catch (err) {
|
|
510
|
+
if (err.code !== "EEXIST")
|
|
511
|
+
throw err;
|
|
512
|
+
}
|
|
513
|
+
const stale = readText(file);
|
|
514
|
+
if (stale === undefined)
|
|
515
|
+
continue; // released meanwhile: try again
|
|
516
|
+
const holder = parseHolder(stale);
|
|
517
|
+
if (holder)
|
|
518
|
+
lastPid = holder.pid;
|
|
519
|
+
if (holder && holderAlive(holder))
|
|
520
|
+
throw new JournalLockedError(holder.pid);
|
|
521
|
+
hooks.afterStaleRead?.();
|
|
522
|
+
const takeover = `${file}.takeover`;
|
|
523
|
+
try {
|
|
524
|
+
writeNew(takeover, mine);
|
|
525
|
+
}
|
|
526
|
+
catch (err) {
|
|
527
|
+
if (err.code !== "EEXIST")
|
|
528
|
+
throw err;
|
|
529
|
+
try {
|
|
530
|
+
if (Date.now() - fs.lstatSync(takeover).mtimeMs > TAKEOVER_STALE_MS)
|
|
531
|
+
fs.rmSync(takeover, { force: true });
|
|
532
|
+
}
|
|
533
|
+
catch {
|
|
534
|
+
// gone already
|
|
535
|
+
}
|
|
536
|
+
continue;
|
|
537
|
+
}
|
|
538
|
+
try {
|
|
539
|
+
// Re-read under the takeover lock: remove it only if nobody replaced it since.
|
|
540
|
+
if (readText(file) === stale)
|
|
541
|
+
fs.rmSync(file, { force: true });
|
|
542
|
+
}
|
|
543
|
+
finally {
|
|
544
|
+
fs.rmSync(takeover, { force: true });
|
|
545
|
+
}
|
|
546
|
+
}
|
|
547
|
+
throw new JournalLockedError(lastPid);
|
|
548
|
+
}
|
|
210
549
|
// ─── Redaction ────────────────────────────────────────────────────
|
|
211
550
|
const SECRET_KEY_PARTS = [
|
|
212
551
|
"token",
|
|
@@ -314,7 +653,7 @@ export function redact(value, secrets = []) {
|
|
|
314
653
|
export class JournalVersionError extends Error {
|
|
315
654
|
constructor(version) {
|
|
316
655
|
super(typeof version === "number" && version < JOURNAL_SCHEMA_VERSION
|
|
317
|
-
? `run journal format v${version} predates
|
|
656
|
+
? `run journal format v${version} predates chained records (v${JOURNAL_SCHEMA_VERSION}); it cannot be ` +
|
|
318
657
|
`resumed safely. Start a new run.`
|
|
319
658
|
: `run journal format v${String(version)} is newer than this sweny understands (v${JOURNAL_SCHEMA_VERSION}); upgrade sweny to resume it`);
|
|
320
659
|
this.name = "JournalVersionError";
|
|
@@ -337,14 +676,15 @@ export class JournalMismatchError extends Error {
|
|
|
337
676
|
this.name = "JournalMismatchError";
|
|
338
677
|
}
|
|
339
678
|
}
|
|
340
|
-
/** Thrown when
|
|
341
|
-
export class
|
|
342
|
-
constructor(
|
|
343
|
-
super(`run journal
|
|
344
|
-
this.name = "
|
|
679
|
+
/** Thrown when the journal cannot be written. Fatal: the executor stops the run before anything else happens. */
|
|
680
|
+
export class JournalWriteError extends Error {
|
|
681
|
+
constructor(message) {
|
|
682
|
+
super(`run journal: ${message}; the run stopped so a resume cannot lose spend or repeat a write`);
|
|
683
|
+
this.name = "JournalWriteError";
|
|
345
684
|
}
|
|
346
685
|
}
|
|
347
|
-
|
|
686
|
+
/** Check one line as record `expectedSeq` following a record whose MAC is `prev`. Undefined = not complete JSON. */
|
|
687
|
+
function verifyLine(line, expectedSeq, prev, runId, key) {
|
|
348
688
|
let parsed;
|
|
349
689
|
try {
|
|
350
690
|
parsed = JSON.parse(line);
|
|
@@ -352,30 +692,24 @@ function verifyLine(line, expectedSeq, key) {
|
|
|
352
692
|
catch {
|
|
353
693
|
return undefined;
|
|
354
694
|
}
|
|
695
|
+
// Complete JSON is never a torn append (no strict prefix of a record parses):
|
|
696
|
+
// from here on, anything that is not this run's next record is refused.
|
|
355
697
|
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed))
|
|
356
|
-
return
|
|
698
|
+
return "forged";
|
|
357
699
|
const rec = parsed;
|
|
358
700
|
if (typeof rec.v === "number" && rec.v !== JOURNAL_SCHEMA_VERSION && expectedSeq === 1)
|
|
359
701
|
return "version";
|
|
360
702
|
if (typeof rec.v === "number" && rec.v > JOURNAL_SCHEMA_VERSION)
|
|
361
703
|
return "version";
|
|
362
704
|
if (rec.v !== JOURNAL_SCHEMA_VERSION || typeof rec.type !== "string" || typeof rec.h !== "string")
|
|
363
|
-
return undefined;
|
|
364
|
-
const { h, ...body } = rec;
|
|
365
|
-
const expected = Buffer.from(recordMac(key(), JSON.stringify(body)), "hex");
|
|
366
|
-
const given = /^[0-9a-f]{64}$/.test(h) ? Buffer.from(h, "hex") : Buffer.alloc(0);
|
|
367
|
-
// A whole, well-formed line that fails its MAC is tampering, not a torn append.
|
|
368
|
-
if (given.length !== expected.length || !crypto.timingSafeEqual(given, expected))
|
|
369
705
|
return "forged";
|
|
370
706
|
if (rec.seq !== expectedSeq)
|
|
371
707
|
return "forged";
|
|
708
|
+
const { h, ...body } = rec;
|
|
709
|
+
if (!sameMac(h, chainMac(key(), runId, expectedSeq, prev, body)))
|
|
710
|
+
return "forged";
|
|
372
711
|
return rec;
|
|
373
712
|
}
|
|
374
|
-
/** `<cwd>/.sweny/runs/<run-id>/journal.ndjson` -> cwd and run id. */
|
|
375
|
-
function journalLocation(file) {
|
|
376
|
-
const runDir = path.dirname(path.resolve(file));
|
|
377
|
-
return { runId: path.basename(runDir), cwd: path.dirname(path.dirname(path.dirname(runDir))) };
|
|
378
|
-
}
|
|
379
713
|
function looksLikeRecord(text) {
|
|
380
714
|
try {
|
|
381
715
|
const p = JSON.parse(text);
|
|
@@ -386,73 +720,118 @@ function looksLikeRecord(text) {
|
|
|
386
720
|
}
|
|
387
721
|
}
|
|
388
722
|
/**
|
|
389
|
-
* Read a journal
|
|
390
|
-
*
|
|
391
|
-
*
|
|
392
|
-
*
|
|
393
|
-
*
|
|
723
|
+
* Read a journal from a run's private directory. Records are authenticated
|
|
724
|
+
* and chained with the run key, then checked against the head: the journal
|
|
725
|
+
* must end at the head's record, or one short of it (then that record is
|
|
726
|
+
* restored from the head). Only an unterminated final segment that is not
|
|
727
|
+
* complete JSON is a torn append; with `repair` it is cut. Throws
|
|
728
|
+
* {@link JournalVersionError} for another format, {@link JournalKeyError}
|
|
729
|
+
* when the key is missing and {@link JournalRollbackError} when the journal
|
|
730
|
+
* was cut back or does not match its head.
|
|
394
731
|
*/
|
|
395
732
|
export function readJournal(file, opts = {}) {
|
|
396
|
-
const
|
|
733
|
+
const dir = path.dirname(path.resolve(file));
|
|
734
|
+
const runId = opts.runId ?? path.basename(dir);
|
|
735
|
+
const buf = readNoFollow(file);
|
|
397
736
|
let key = opts.key;
|
|
398
737
|
const getKey = () => {
|
|
399
|
-
|
|
400
|
-
const { cwd, runId } = journalLocation(file);
|
|
401
|
-
key = loadRunKey(runKeyFile(cwd, runId, opts.keyDir ?? runKeyDir()));
|
|
402
|
-
}
|
|
738
|
+
key ??= loadRunKey(path.join(dir, KEY_FILE));
|
|
403
739
|
return key;
|
|
404
740
|
};
|
|
405
741
|
const records = [];
|
|
742
|
+
const prevMac = () => (records.length > 0 ? records[records.length - 1].h : "");
|
|
406
743
|
let offset = 0;
|
|
407
744
|
let validBytes = 0;
|
|
408
745
|
let line = 0;
|
|
409
746
|
let firstBad;
|
|
410
747
|
let corruptAtLine;
|
|
411
748
|
let forgedAtLine;
|
|
749
|
+
let missingNewline = false;
|
|
750
|
+
const forged = (at) => {
|
|
751
|
+
firstBad = at;
|
|
752
|
+
corruptAtLine = at;
|
|
753
|
+
forgedAtLine = at;
|
|
754
|
+
};
|
|
412
755
|
while (offset < buf.length) {
|
|
413
756
|
const nl = buf.indexOf(0x0a, offset);
|
|
414
757
|
line++;
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
758
|
+
const end = nl === -1 ? buf.length : nl;
|
|
759
|
+
const text = buf.subarray(offset, end).toString("utf-8");
|
|
760
|
+
offset = end + 1;
|
|
761
|
+
if (firstBad !== undefined) {
|
|
762
|
+
// A well-formed record after a bad line: the damage is not a torn tail.
|
|
763
|
+
if (corruptAtLine === undefined && looksLikeRecord(text))
|
|
764
|
+
corruptAtLine = firstBad;
|
|
765
|
+
continue;
|
|
419
766
|
}
|
|
420
|
-
const
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
767
|
+
const rec = verifyLine(text, records.length + 1, prevMac(), runId, getKey);
|
|
768
|
+
if (rec === "version")
|
|
769
|
+
throw new JournalVersionError(JSON.parse(text).v);
|
|
770
|
+
if (rec === "forged")
|
|
771
|
+
forged(line);
|
|
772
|
+
else if (rec === undefined)
|
|
773
|
+
firstBad = line;
|
|
774
|
+
else {
|
|
775
|
+
records.push(rec);
|
|
776
|
+
validBytes = Math.min(offset, buf.length);
|
|
777
|
+
// Complete JSON without its line end is the next record, authenticated like any other.
|
|
778
|
+
if (nl === -1)
|
|
779
|
+
missingNewline = true;
|
|
780
|
+
}
|
|
781
|
+
}
|
|
782
|
+
const truncatedBytes = buf.length - validBytes;
|
|
783
|
+
let restored;
|
|
784
|
+
if (corruptAtLine === undefined && opts.head !== false) {
|
|
785
|
+
const head = opts.head ?? loadJournalHead(dir, getKey(), runId);
|
|
786
|
+
const n = records.length;
|
|
787
|
+
if (n === head.seq - 1 && head.record) {
|
|
788
|
+
// A crash after the head was written, before the journal append: the record is in the head.
|
|
789
|
+
const rec = verifyLine(JSON.stringify(head.record), head.seq, prevMac(), runId, getKey);
|
|
790
|
+
if (!rec || rec === "version" || rec === "forged") {
|
|
791
|
+
throw new JournalRollbackError(`the run journal's head holds a record that does not follow record ${n}`);
|
|
437
792
|
}
|
|
793
|
+
restored = rec;
|
|
438
794
|
}
|
|
439
|
-
else if (
|
|
440
|
-
|
|
441
|
-
|
|
795
|
+
else if (n < head.seq) {
|
|
796
|
+
throw new JournalRollbackError(`the run journal ends at record ${n}, but the run had written ${head.seq}: records were removed from its end (rolled back)`);
|
|
797
|
+
}
|
|
798
|
+
else if (n > head.seq) {
|
|
799
|
+
throw new JournalRollbackError(`the run journal has ${n} records, but its head is record ${head.seq}: it was not written by this run alone`);
|
|
800
|
+
}
|
|
801
|
+
else if (n > 0 && records[n - 1].h !== head.record?.h) {
|
|
802
|
+
throw new JournalRollbackError(`run journal record ${n} is not the record the run wrote there`);
|
|
442
803
|
}
|
|
443
804
|
}
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
805
|
+
if (restored)
|
|
806
|
+
records.push(restored);
|
|
807
|
+
if (opts.repair && corruptAtLine === undefined) {
|
|
808
|
+
if (truncatedBytes > 0)
|
|
809
|
+
fs.truncateSync(file, validBytes);
|
|
810
|
+
const tail = (missingNewline ? "\n" : "") + (restored ? JSON.stringify(restored) + "\n" : "");
|
|
811
|
+
if (tail)
|
|
812
|
+
appendDurably(file, tail);
|
|
447
813
|
}
|
|
448
814
|
return {
|
|
449
815
|
file,
|
|
450
816
|
records,
|
|
451
817
|
truncatedBytes,
|
|
818
|
+
...(missingNewline ? { missingNewline } : {}),
|
|
819
|
+
...(restored ? { restored: true } : {}),
|
|
452
820
|
...(corruptAtLine !== undefined ? { corruptAtLine } : {}),
|
|
453
821
|
...(forgedAtLine !== undefined ? { forgedAtLine } : {}),
|
|
454
822
|
};
|
|
455
823
|
}
|
|
824
|
+
function appendDurably(file, text) {
|
|
825
|
+
const c = fs.constants;
|
|
826
|
+
const fd = fs.openSync(file, c.O_WRONLY | c.O_APPEND | NOFOLLOW);
|
|
827
|
+
try {
|
|
828
|
+
fs.writeSync(fd, text);
|
|
829
|
+
fs.fsyncSync(fd);
|
|
830
|
+
}
|
|
831
|
+
finally {
|
|
832
|
+
fs.closeSync(fd);
|
|
833
|
+
}
|
|
834
|
+
}
|
|
456
835
|
const visitKey = (node, iteration) => `${node}#${iteration}`;
|
|
457
836
|
const RECORD_TYPES = new Set([
|
|
458
837
|
"run:start",
|
|
@@ -464,6 +843,7 @@ const RECORD_TYPES = new Set([
|
|
|
464
843
|
"node:end",
|
|
465
844
|
"route",
|
|
466
845
|
"usage",
|
|
846
|
+
"agent:unsandboxed",
|
|
467
847
|
"run:end",
|
|
468
848
|
]);
|
|
469
849
|
const isPosInt = (v) => typeof v === "number" && Number.isInteger(v) && v > 0;
|
|
@@ -574,6 +954,9 @@ export function checkRecordSequence(records) {
|
|
|
574
954
|
bad(r, "has invalid amounts");
|
|
575
955
|
}
|
|
576
956
|
break;
|
|
957
|
+
case "agent:unsandboxed":
|
|
958
|
+
inVisit(r);
|
|
959
|
+
break;
|
|
577
960
|
case "node:end": {
|
|
578
961
|
inVisit(r);
|
|
579
962
|
const result = r.result;
|
|
@@ -798,6 +1181,7 @@ export function buildResumePlan(records) {
|
|
|
798
1181
|
intents,
|
|
799
1182
|
receipts,
|
|
800
1183
|
priorSpend: journaledSpend(records),
|
|
1184
|
+
unsandboxed: [...new Set(records.filter((r) => r.type === "agent:unsandboxed").map((r) => String(r.node)))],
|
|
801
1185
|
...(start.decider
|
|
802
1186
|
? { decider: { ...start.decider, ...(deciderCounters ? { counters: { ...deciderCounters } } : {}) } }
|
|
803
1187
|
: {}),
|
|
@@ -845,12 +1229,57 @@ async function callRead(skill, name, args, ctx) {
|
|
|
845
1229
|
throw new Error(`no ${name} tool configured`);
|
|
846
1230
|
return tool.handler(args, ctx);
|
|
847
1231
|
}
|
|
1232
|
+
const hasRead = (skill, name) => !!skill?.tools.some((t) => t.name === name && t.access === "read");
|
|
1233
|
+
/** Pages of 100 a marker lookup reads before it gives up (cannot confirm). */
|
|
1234
|
+
const LIST_PAGE_SIZE = 100;
|
|
1235
|
+
const LIST_MAX_PAGES = 30;
|
|
1236
|
+
/**
|
|
1237
|
+
* Look for a GitHub issue or comment by its marker through the listing
|
|
1238
|
+
* endpoints (consistent, unlike search), page by page until the marker is
|
|
1239
|
+
* found, the listing ends, or it reaches back past the write. Undefined when
|
|
1240
|
+
* the skill has no listing tool, so the caller falls back to search. A page
|
|
1241
|
+
* that cannot be read is "cannot confirm" (the caller's catch).
|
|
1242
|
+
*/
|
|
1243
|
+
async function listGitHubForMarker(toolName, args, token, since, skill, ctx) {
|
|
1244
|
+
const hasBody = (i) => typeof i.body === "string" && i.body.includes(token);
|
|
1245
|
+
const comments = toolName === "github_add_comment";
|
|
1246
|
+
const tool = comments ? "github_list_issue_comments" : "github_list_issues";
|
|
1247
|
+
if (!hasRead(skill, tool))
|
|
1248
|
+
return undefined;
|
|
1249
|
+
for (let page = 1; page <= LIST_MAX_PAGES; page++) {
|
|
1250
|
+
const query = comments
|
|
1251
|
+
? { repo: args.repo, issue_number: args.issue_number, ...(since ? { since } : {}), page }
|
|
1252
|
+
: { repo: args.repo, per_page: LIST_PAGE_SIZE, page };
|
|
1253
|
+
const out = await callRead(skill, tool, query, ctx);
|
|
1254
|
+
if (!Array.isArray(out))
|
|
1255
|
+
return { state: "unknown", reason: `${comments ? "comment" : "issue"} list unavailable` };
|
|
1256
|
+
const items = out;
|
|
1257
|
+
// Issues listing includes pull requests; a marker only ever lands in an issue.
|
|
1258
|
+
const hit = items.find((i) => hasBody(i) && (comments || !i.pull_request));
|
|
1259
|
+
if (hit)
|
|
1260
|
+
return { state: "applied", output: comments ? {} : hit };
|
|
1261
|
+
// The listing ended: everything since the write was seen.
|
|
1262
|
+
if (items.length < LIST_PAGE_SIZE)
|
|
1263
|
+
return { state: "absent" };
|
|
1264
|
+
// Issues come newest first: a page that reaches back past the write ends the search.
|
|
1265
|
+
const oldest = items.at(-1)?.created_at;
|
|
1266
|
+
if (!comments && since !== undefined && typeof oldest === "string" && oldest < since)
|
|
1267
|
+
return { state: "absent" };
|
|
1268
|
+
}
|
|
1269
|
+
return {
|
|
1270
|
+
state: "unknown",
|
|
1271
|
+
reason: `too many ${comments ? "comments" : "issues"} to search (more than ${LIST_MAX_PAGES * LIST_PAGE_SIZE})`,
|
|
1272
|
+
};
|
|
1273
|
+
}
|
|
848
1274
|
/**
|
|
849
1275
|
* Was this write applied before the crash? Writes that are idempotent by
|
|
850
1276
|
* nature (labels, state changes, a PR for the same head branch) are simply
|
|
851
|
-
* re-applied. Creates and comments are
|
|
1277
|
+
* re-applied. Creates and comments are looked up by their marker: GitHub's
|
|
1278
|
+
* listing endpoints first (consistent), search otherwise; Linear comments by
|
|
1279
|
+
* listing, Linear issues by search. A write with no lookup is `unknown`, and a
|
|
1280
|
+
* pending one is not re-sent without `--allow-repeat-writes`.
|
|
852
1281
|
*/
|
|
853
|
-
async function probeProvider(toolName, args, key, skill, ctx) {
|
|
1282
|
+
async function probeProvider(toolName, args, key, skill, ctx, since) {
|
|
854
1283
|
const token = markerToken(key);
|
|
855
1284
|
try {
|
|
856
1285
|
switch (toolName) {
|
|
@@ -861,6 +1290,10 @@ async function probeProvider(toolName, args, key, skill, ctx) {
|
|
|
861
1290
|
return { state: "reapply" };
|
|
862
1291
|
case "github_create_issue":
|
|
863
1292
|
case "github_add_comment": {
|
|
1293
|
+
// Prefer the listing endpoints: unlike search, they see a write the moment it lands.
|
|
1294
|
+
const listed = await listGitHubForMarker(toolName, args, token, since, skill, ctx);
|
|
1295
|
+
if (listed)
|
|
1296
|
+
return listed;
|
|
864
1297
|
const scope = toolName === "github_create_issue" ? "in:body is:issue" : "in:comments";
|
|
865
1298
|
const out = (await callRead(skill, "github_search_issues", { query: `${token} ${scope}`, repo: args.repo }, ctx));
|
|
866
1299
|
if (!out || !Array.isArray(out.items))
|
|
@@ -868,8 +1301,9 @@ async function probeProvider(toolName, args, key, skill, ctx) {
|
|
|
868
1301
|
const hit = toolName === "github_create_issue"
|
|
869
1302
|
? out.items.find((i) => typeof i.body === "string" && i.body.includes(token))
|
|
870
1303
|
: out.items.find((i) => Number(i.number) === Number(args.issue_number));
|
|
1304
|
+
// The search index lags writes: a miss is not proof the write never landed.
|
|
871
1305
|
if (!hit)
|
|
872
|
-
return { state: "
|
|
1306
|
+
return { state: "unknown", reason: "not in GitHub search, which can lag a just-made write" };
|
|
873
1307
|
return { state: "applied", output: toolName === "github_create_issue" ? hit : {} };
|
|
874
1308
|
}
|
|
875
1309
|
case "linear_create_issue": {
|
|
@@ -915,40 +1349,71 @@ function slimOutput(o, depth = 0) {
|
|
|
915
1349
|
}
|
|
916
1350
|
return out;
|
|
917
1351
|
}
|
|
918
|
-
/**
|
|
919
|
-
export
|
|
920
|
-
|
|
921
|
-
return path.join(cwd, JOURNAL_DIR, runId);
|
|
1352
|
+
/** A run's journal file. */
|
|
1353
|
+
export function journalFile(cwd, runId, root = runStateRoot()) {
|
|
1354
|
+
return path.join(journalDir(cwd, runId, root), JOURNAL_FILE);
|
|
922
1355
|
}
|
|
923
|
-
/**
|
|
924
|
-
|
|
1356
|
+
/**
|
|
1357
|
+
* Runs of this workspace with a journal, oldest first. Only the state dir is
|
|
1358
|
+
* read, and only runs whose metadata authenticates count: nothing in the
|
|
1359
|
+
* workspace can add, hide or reorder a run.
|
|
1360
|
+
*/
|
|
1361
|
+
export function listJournalRuns(cwd = process.cwd(), root = runStateRoot()) {
|
|
1362
|
+
const scope = workspaceScope(cwd);
|
|
1363
|
+
const base = path.join(root, scope);
|
|
1364
|
+
let names;
|
|
925
1365
|
try {
|
|
926
|
-
|
|
927
|
-
.readdirSync(path.join(cwd, JOURNAL_DIR))
|
|
928
|
-
.filter((d) => RUN_ID_RE.test(d) && fs.existsSync(path.join(cwd, JOURNAL_DIR, d, JOURNAL_FILE)))
|
|
929
|
-
.sort();
|
|
1366
|
+
names = fs.readdirSync(base);
|
|
930
1367
|
}
|
|
931
1368
|
catch {
|
|
932
1369
|
return [];
|
|
933
1370
|
}
|
|
1371
|
+
const runs = [];
|
|
1372
|
+
for (const name of names) {
|
|
1373
|
+
if (!RUN_ID_RE.test(name))
|
|
1374
|
+
continue;
|
|
1375
|
+
const meta = loadRunMeta(path.join(base, name), name, scope);
|
|
1376
|
+
if (meta)
|
|
1377
|
+
runs.push(meta);
|
|
1378
|
+
}
|
|
1379
|
+
return runs
|
|
1380
|
+
.sort((a, b) => a.createdAt.localeCompare(b.createdAt) || a.runId.localeCompare(b.runId))
|
|
1381
|
+
.map((r) => r.runId);
|
|
934
1382
|
}
|
|
935
|
-
/** Exact run id, or a unique prefix. */
|
|
936
|
-
export function findJournalRun(ref, cwd = process.cwd()) {
|
|
937
|
-
const runs = listJournalRuns(cwd);
|
|
1383
|
+
/** Exact run id, or a unique prefix, among this workspace's journaled runs. */
|
|
1384
|
+
export function findJournalRun(ref, cwd = process.cwd(), root = runStateRoot()) {
|
|
1385
|
+
const runs = listJournalRuns(cwd, root);
|
|
938
1386
|
if (runs.includes(ref))
|
|
939
1387
|
return ref;
|
|
940
1388
|
const matches = runs.filter((r) => r.startsWith(ref));
|
|
941
1389
|
return matches.length === 1 ? matches[0] : undefined;
|
|
942
1390
|
}
|
|
943
|
-
/**
|
|
944
|
-
export function
|
|
945
|
-
|
|
1391
|
+
/** A journal an older sweny left in the workspace for this run id (or unique prefix), if any. Never read. */
|
|
1392
|
+
export function findLegacyJournal(ref, cwd = process.cwd()) {
|
|
1393
|
+
try {
|
|
1394
|
+
const base = path.join(cwd, LEGACY_JOURNAL_DIR);
|
|
1395
|
+
const hits = fs
|
|
1396
|
+
.readdirSync(base)
|
|
1397
|
+
.filter((d) => RUN_ID_RE.test(d) && (d === ref || d.startsWith(ref)))
|
|
1398
|
+
.filter((d) => fs.existsSync(path.join(base, d, JOURNAL_FILE)));
|
|
1399
|
+
return hits.length === 1 ? path.join(base, hits[0], JOURNAL_FILE) : undefined;
|
|
1400
|
+
}
|
|
1401
|
+
catch {
|
|
1402
|
+
return undefined;
|
|
1403
|
+
}
|
|
1404
|
+
}
|
|
1405
|
+
/**
|
|
1406
|
+
* Delete this workspace's oldest journals beyond `keep`, never `except`, by
|
|
1407
|
+
* their authenticated creation time. Runs whose metadata does not
|
|
1408
|
+
* authenticate are never deleted. Never throws.
|
|
1409
|
+
*/
|
|
1410
|
+
export function pruneJournals(cwd, keep = JOURNAL_KEEP, except, root = runStateRoot()) {
|
|
1411
|
+
const runs = listJournalRuns(cwd, root).filter((r) => r !== except);
|
|
946
1412
|
const extra = runs.slice(0, Math.max(0, runs.length - Math.max(0, keep - (except ? 1 : 0))));
|
|
947
1413
|
let removed = 0;
|
|
948
1414
|
for (const r of extra) {
|
|
949
1415
|
try {
|
|
950
|
-
fs.rmSync(journalDir(cwd, r), { recursive: true, force: true });
|
|
951
|
-
fs.rmSync(runKeyFile(cwd, r, keyDir), { force: true });
|
|
1416
|
+
fs.rmSync(journalDir(cwd, r, root), { recursive: true, force: true });
|
|
952
1417
|
removed++;
|
|
953
1418
|
}
|
|
954
1419
|
catch {
|
|
@@ -957,103 +1422,103 @@ export function pruneJournals(cwd, keep = JOURNAL_KEEP, except, keyDir = runKeyD
|
|
|
957
1422
|
}
|
|
958
1423
|
return removed;
|
|
959
1424
|
}
|
|
960
|
-
|
|
961
|
-
|
|
962
|
-
|
|
963
|
-
return true;
|
|
964
|
-
}
|
|
965
|
-
catch (err) {
|
|
966
|
-
return err.code === "EPERM";
|
|
967
|
-
}
|
|
1425
|
+
/** Take a run's lock before reading its journal (resume). Throws {@link JournalLockedError}. */
|
|
1426
|
+
export function lockRun(cwd, runId, root = runStateRoot(), hooks) {
|
|
1427
|
+
return acquireRunLock(journalDir(cwd, runId, root), hooks);
|
|
968
1428
|
}
|
|
969
1429
|
const quietLogger = { info() { }, warn() { }, error() { }, debug() { } };
|
|
1430
|
+
const errText = (err) => (err instanceof Error ? err.message : String(err));
|
|
970
1431
|
export class RunJournal {
|
|
971
1432
|
runId;
|
|
972
1433
|
dir;
|
|
973
1434
|
file;
|
|
974
1435
|
cwd;
|
|
1436
|
+
root;
|
|
975
1437
|
opts;
|
|
976
1438
|
logger;
|
|
977
1439
|
resume;
|
|
978
1440
|
fd;
|
|
979
1441
|
seq = 0;
|
|
980
|
-
|
|
1442
|
+
/** MAC of the last record written: the next record chains to it. */
|
|
1443
|
+
prev = "";
|
|
1444
|
+
/** Set once an append failed: every later append throws it again. */
|
|
1445
|
+
failure;
|
|
1446
|
+
/** Set when a fault seam simulated the process dying: nothing more is written. */
|
|
1447
|
+
killed = false;
|
|
981
1448
|
began = false;
|
|
982
1449
|
closed = false;
|
|
983
1450
|
secrets = [];
|
|
984
1451
|
consumed = new Set();
|
|
985
|
-
/** Per-run HMAC key; lives only here and in the
|
|
1452
|
+
/** Per-run HMAC key; lives only here and in the run's private state dir. */
|
|
986
1453
|
key;
|
|
987
|
-
|
|
1454
|
+
lock;
|
|
988
1455
|
/** Last live usage record per attempt: time and spend, for throttling. */
|
|
989
1456
|
lastUsage = new Map();
|
|
1457
|
+
/** Visits already recorded as run without an enforced sandbox. */
|
|
1458
|
+
uncontainedVisits = new Set();
|
|
990
1459
|
constructor(opts, resume) {
|
|
991
1460
|
this.opts = opts;
|
|
992
1461
|
this.runId = opts.runId;
|
|
993
1462
|
this.cwd = opts.cwd ?? process.cwd();
|
|
994
|
-
this.
|
|
1463
|
+
this.root = opts.stateRoot ?? runStateRoot();
|
|
1464
|
+
this.dir = journalDir(this.cwd, opts.runId, this.root);
|
|
995
1465
|
this.file = path.join(this.dir, JOURNAL_FILE);
|
|
996
1466
|
this.logger = opts.logger ?? quietLogger;
|
|
997
|
-
this.keyFile = runKeyFile(this.cwd, opts.runId, opts.keyDir ?? runKeyDir());
|
|
998
1467
|
if (resume) {
|
|
999
1468
|
this.resume = {
|
|
1000
1469
|
plan: resume.plan,
|
|
1001
1470
|
force: resume.force === true,
|
|
1002
1471
|
allowRepeatWrites: resume.allowRepeatWrites === true,
|
|
1003
1472
|
};
|
|
1004
|
-
this.
|
|
1005
|
-
|
|
1473
|
+
this.lock = resume.lock;
|
|
1474
|
+
const records = resume.read.records;
|
|
1475
|
+
this.seq = records.length;
|
|
1476
|
+
this.prev = records.length > 0 ? records[records.length - 1].h : "";
|
|
1477
|
+
this.key = loadRunKey(path.join(this.dir, KEY_FILE));
|
|
1006
1478
|
}
|
|
1007
1479
|
}
|
|
1008
1480
|
/** A journal for a new run. Nothing touches disk until the executor calls `begin`. */
|
|
1009
1481
|
static create(opts) {
|
|
1010
1482
|
return new RunJournal(opts);
|
|
1011
1483
|
}
|
|
1012
|
-
/** A journal that continues `opts.read` (already repaired) under the resume plan. */
|
|
1484
|
+
/** A journal that continues `opts.read` (already repaired, under `opts.lock`) under the resume plan. */
|
|
1013
1485
|
static openForResume(opts) {
|
|
1014
|
-
|
|
1015
|
-
j.takeLock();
|
|
1016
|
-
return j;
|
|
1486
|
+
return new RunJournal(opts, opts);
|
|
1017
1487
|
}
|
|
1018
|
-
/** False once an append failed
|
|
1488
|
+
/** False once an append failed (or the process was simulated dead): the run cannot be resumed past that point. */
|
|
1019
1489
|
get active() {
|
|
1020
|
-
return !this.
|
|
1021
|
-
}
|
|
1022
|
-
takeLock() {
|
|
1023
|
-
const lock = path.join(this.dir, LOCK_FILE);
|
|
1024
|
-
try {
|
|
1025
|
-
const pid = Number(fs.readFileSync(lock, "utf-8").trim());
|
|
1026
|
-
if (Number.isInteger(pid) && pid > 0 && pid !== process.pid && pidAlive(pid))
|
|
1027
|
-
throw new JournalLockedError(pid);
|
|
1028
|
-
}
|
|
1029
|
-
catch (err) {
|
|
1030
|
-
if (err instanceof JournalLockedError)
|
|
1031
|
-
throw err;
|
|
1032
|
-
}
|
|
1033
|
-
this.makeDir();
|
|
1034
|
-
writeFileNoFollow(lock, String(process.pid), { root: this.cwd, mode: 0o600 });
|
|
1035
|
-
}
|
|
1036
|
-
/** The run dir is private (0700); no link on the path from cwd is followed (safe-file.ts). */
|
|
1037
|
-
makeDir() {
|
|
1038
|
-
ensureDirNoFollow(this.dir, this.cwd);
|
|
1490
|
+
return !this.failure && !this.killed;
|
|
1039
1491
|
}
|
|
1040
1492
|
releaseLock() {
|
|
1041
1493
|
try {
|
|
1042
|
-
|
|
1494
|
+
this.lock?.release();
|
|
1043
1495
|
}
|
|
1044
1496
|
catch {
|
|
1045
1497
|
// nothing to do
|
|
1046
1498
|
}
|
|
1499
|
+
this.lock = undefined;
|
|
1500
|
+
}
|
|
1501
|
+
/** Mark the journal failed and throw: the run must stop here. */
|
|
1502
|
+
fail(what, err) {
|
|
1503
|
+
this.failure = new JournalWriteError(err === undefined ? what : `${what} (${errText(err)})`);
|
|
1504
|
+
this.closeFd();
|
|
1505
|
+
this.logger.error(` ${this.failure.message}`);
|
|
1506
|
+
throw this.failure;
|
|
1507
|
+
}
|
|
1508
|
+
simulatedDeath(err) {
|
|
1509
|
+
// Test seam: nothing after this point reaches the disk.
|
|
1510
|
+
this.killed = true;
|
|
1511
|
+
this.closeFd();
|
|
1512
|
+
this.releaseLock();
|
|
1513
|
+
throw err;
|
|
1047
1514
|
}
|
|
1048
1515
|
append(type, fields) {
|
|
1049
|
-
if (this.
|
|
1050
|
-
|
|
1051
|
-
if (
|
|
1052
|
-
// No key, no record resume could trust: stop journaling rather than write unauthenticated records.
|
|
1053
|
-
this.dead = true;
|
|
1054
|
-
this.logger.warn(` run journal: no signing key for this run; this run cannot be resumed`);
|
|
1516
|
+
if (this.failure)
|
|
1517
|
+
throw this.failure;
|
|
1518
|
+
if (this.killed)
|
|
1055
1519
|
return;
|
|
1056
|
-
|
|
1520
|
+
if (!this.key)
|
|
1521
|
+
return this.fail("no signing key for this run");
|
|
1057
1522
|
const body = {
|
|
1058
1523
|
v: JOURNAL_SCHEMA_VERSION,
|
|
1059
1524
|
seq: this.seq + 1,
|
|
@@ -1061,34 +1526,48 @@ export class RunJournal {
|
|
|
1061
1526
|
at: new Date().toISOString(),
|
|
1062
1527
|
...fields,
|
|
1063
1528
|
};
|
|
1064
|
-
const
|
|
1529
|
+
const h = chainMac(this.key, this.runId, this.seq + 1, this.prev, body);
|
|
1530
|
+
const record = { ...body, h };
|
|
1065
1531
|
if (this.opts.faults?.beforeAppend) {
|
|
1066
1532
|
try {
|
|
1067
1533
|
this.opts.faults.beforeAppend(record);
|
|
1068
1534
|
}
|
|
1069
1535
|
catch (err) {
|
|
1070
|
-
|
|
1071
|
-
|
|
1072
|
-
|
|
1073
|
-
|
|
1074
|
-
|
|
1536
|
+
this.simulatedDeath(err);
|
|
1537
|
+
}
|
|
1538
|
+
}
|
|
1539
|
+
// Head first: a crash before the journal append leaves the record recoverable, never lost.
|
|
1540
|
+
try {
|
|
1541
|
+
writeJournalHead(this.dir, this.key, this.runId, { seq: this.seq + 1, record });
|
|
1542
|
+
}
|
|
1543
|
+
catch (err) {
|
|
1544
|
+
this.fail(`could not write the head file in ${this.dir}`, err);
|
|
1545
|
+
}
|
|
1546
|
+
if (this.opts.faults?.afterHeadUpdate) {
|
|
1547
|
+
try {
|
|
1548
|
+
this.opts.faults.afterHeadUpdate(record);
|
|
1549
|
+
}
|
|
1550
|
+
catch (err) {
|
|
1551
|
+
this.simulatedDeath(err);
|
|
1075
1552
|
}
|
|
1076
1553
|
}
|
|
1077
1554
|
try {
|
|
1078
1555
|
if (this.fd === undefined) {
|
|
1079
|
-
|
|
1080
|
-
this.fd =
|
|
1556
|
+
const c = fs.constants;
|
|
1557
|
+
this.fd = fs.openSync(this.file, c.O_WRONLY | c.O_APPEND | c.O_CREAT | NOFOLLOW, 0o600);
|
|
1081
1558
|
}
|
|
1082
|
-
|
|
1559
|
+
const line = JSON.stringify(record) + "\n";
|
|
1560
|
+
if (this.opts.faults?.writeJournal)
|
|
1561
|
+
this.opts.faults.writeJournal(this.fd, line);
|
|
1562
|
+
else
|
|
1563
|
+
fs.writeSync(this.fd, line);
|
|
1083
1564
|
fs.fsyncSync(this.fd);
|
|
1084
|
-
this.seq++;
|
|
1085
1565
|
}
|
|
1086
1566
|
catch (err) {
|
|
1087
|
-
this.
|
|
1088
|
-
this.closeFd();
|
|
1089
|
-
this.logger.warn(` run journal: could not write ${this.file} (${err instanceof Error ? err.message : String(err)}); ` +
|
|
1090
|
-
`this run cannot be resumed past this point`);
|
|
1567
|
+
this.fail(`could not write ${this.file}`, err);
|
|
1091
1568
|
}
|
|
1569
|
+
this.seq++;
|
|
1570
|
+
this.prev = h;
|
|
1092
1571
|
}
|
|
1093
1572
|
closeFd() {
|
|
1094
1573
|
if (this.fd === undefined)
|
|
@@ -1126,7 +1605,7 @@ export class RunJournal {
|
|
|
1126
1605
|
if (changed.length > 0) {
|
|
1127
1606
|
if (!this.resume.force) {
|
|
1128
1607
|
// Nothing is appended for a refused resume: the journal stays as the crash left it.
|
|
1129
|
-
this.
|
|
1608
|
+
this.killed = true;
|
|
1130
1609
|
this.closeFd();
|
|
1131
1610
|
this.releaseLock();
|
|
1132
1611
|
throw new JournalMismatchError(changed);
|
|
@@ -1148,22 +1627,28 @@ export class RunJournal {
|
|
|
1148
1627
|
return;
|
|
1149
1628
|
}
|
|
1150
1629
|
try {
|
|
1151
|
-
this.
|
|
1152
|
-
|
|
1153
|
-
writeFileNoFollow(path.join(this.dir, ".gitignore"), "*\n", { root: this.cwd, mode: 0o644 });
|
|
1154
|
-
this.takeLock();
|
|
1630
|
+
makePrivateDir(this.dir);
|
|
1631
|
+
assertStateOutsideWorkspace(this.dir, this.cwd);
|
|
1155
1632
|
}
|
|
1156
|
-
catch {
|
|
1157
|
-
|
|
1633
|
+
catch (err) {
|
|
1634
|
+
if (err instanceof JournalLocationError)
|
|
1635
|
+
return this.fail(err.message);
|
|
1636
|
+
return this.fail(`could not set up the run's journal in ${this.dir}; set SWENY_STATE_DIR to a writable dir, ` +
|
|
1637
|
+
`or pass --no-journal (the run then cannot be resumed)`, err);
|
|
1158
1638
|
}
|
|
1159
1639
|
try {
|
|
1160
|
-
this.
|
|
1640
|
+
this.lock = acquireRunLock(this.dir);
|
|
1641
|
+
const key = createRunKey(path.join(this.dir, KEY_FILE));
|
|
1642
|
+
writeRunMeta(this.dir, key, this.runId, workspaceScope(this.cwd));
|
|
1643
|
+
writeJournalHead(this.dir, key, this.runId, { seq: 0, record: null });
|
|
1644
|
+
this.key = key;
|
|
1161
1645
|
}
|
|
1162
1646
|
catch (err) {
|
|
1163
|
-
this.
|
|
1164
|
-
|
|
1647
|
+
this.releaseLock();
|
|
1648
|
+
this.fail(`could not set up the run's journal in ${this.dir}; set SWENY_STATE_DIR to a writable dir, ` +
|
|
1649
|
+
`or pass --no-journal (the run then cannot be resumed)`, err);
|
|
1165
1650
|
}
|
|
1166
|
-
pruneJournals(this.cwd, this.opts.keep ?? JOURNAL_KEEP, this.runId, this.
|
|
1651
|
+
pruneJournals(this.cwd, this.opts.keep ?? JOURNAL_KEEP, this.runId, this.root);
|
|
1167
1652
|
const input = redact(info.input, this.secrets);
|
|
1168
1653
|
this.append("run:start", {
|
|
1169
1654
|
run_id: this.runId,
|
|
@@ -1242,6 +1727,14 @@ export class RunJournal {
|
|
|
1242
1727
|
route(from, to, rung, decider) {
|
|
1243
1728
|
this.append("route", { from, to, ...(rung ? { rung } : {}), ...(decider ? { decider } : {}) });
|
|
1244
1729
|
}
|
|
1730
|
+
/**
|
|
1731
|
+
* Journal one usage report, synchronously, whenever it raises the attempt's
|
|
1732
|
+
* spend (reports are cumulative; the largest figure seen per unit counts).
|
|
1733
|
+
* Nothing is held back: harnesses report once per model turn (Claude Code
|
|
1734
|
+
* per assistant message, ACP per usage update), so every turn's spend is on
|
|
1735
|
+
* disk before the next turn runs, and a process killed at any point has lost
|
|
1736
|
+
* at most the turn it was in.
|
|
1737
|
+
*/
|
|
1245
1738
|
usage(node, iteration, attempt, usage, final) {
|
|
1246
1739
|
const s = spendOf(usage);
|
|
1247
1740
|
if (s.tokens === undefined && s.costUsd === undefined)
|
|
@@ -1250,29 +1743,43 @@ export class RunJournal {
|
|
|
1250
1743
|
const costUsd = s.costUsd ?? 0;
|
|
1251
1744
|
const k = `${visitKey(node, iteration)}|${attempt}`;
|
|
1252
1745
|
const last = this.lastUsage.get(k);
|
|
1253
|
-
|
|
1254
|
-
if (!final && last) {
|
|
1255
|
-
if (tokens <= last.tokens && costUsd <= last.costUsd)
|
|
1256
|
-
return;
|
|
1257
|
-
if (now - last.at < USAGE_JOURNAL_INTERVAL_MS)
|
|
1258
|
-
return;
|
|
1259
|
-
}
|
|
1260
|
-
if (final && last && tokens <= last.tokens && costUsd <= last.costUsd)
|
|
1746
|
+
if (last && tokens <= last.tokens && costUsd <= last.costUsd)
|
|
1261
1747
|
return;
|
|
1262
1748
|
this.lastUsage.set(k, {
|
|
1263
|
-
at: now,
|
|
1264
1749
|
tokens: Math.max(tokens, last?.tokens ?? 0),
|
|
1265
1750
|
costUsd: Math.max(costUsd, last?.costUsd ?? 0),
|
|
1266
1751
|
});
|
|
1267
1752
|
this.append("usage", { node, iteration, attempt, tokens, cost_usd: costUsd, final });
|
|
1268
1753
|
}
|
|
1754
|
+
uncontained(node, iteration) {
|
|
1755
|
+
const k = visitKey(node, iteration);
|
|
1756
|
+
if (this.uncontainedVisits.has(k))
|
|
1757
|
+
return;
|
|
1758
|
+
this.uncontainedVisits.add(k);
|
|
1759
|
+
this.append("agent:unsandboxed", { node, iteration });
|
|
1760
|
+
}
|
|
1761
|
+
degraded() {
|
|
1762
|
+
const nodes = this.resume?.plan.unsandboxed ?? [];
|
|
1763
|
+
if (nodes.length === 0)
|
|
1764
|
+
return [];
|
|
1765
|
+
return [
|
|
1766
|
+
`journal_unsandboxed: an earlier attempt ran ${nodes.join(", ")} without an enforced sandbox, so its ` +
|
|
1767
|
+
`journaled spend and write records could have been edited by that agent`,
|
|
1768
|
+
];
|
|
1769
|
+
}
|
|
1269
1770
|
priorSpend() {
|
|
1270
1771
|
return this.resume ? { ...this.resume.plan.priorSpend } : undefined;
|
|
1271
1772
|
}
|
|
1272
|
-
/** Close the run: `run:end
|
|
1773
|
+
/** Close the run: `run:end` (when the journal still works), then release the lock. Never throws. */
|
|
1273
1774
|
end(status) {
|
|
1274
|
-
if (this.began && !this.closed)
|
|
1275
|
-
|
|
1775
|
+
if (this.began && !this.closed && this.active) {
|
|
1776
|
+
try {
|
|
1777
|
+
this.append("run:end", { status });
|
|
1778
|
+
}
|
|
1779
|
+
catch {
|
|
1780
|
+
// already reported by append
|
|
1781
|
+
}
|
|
1782
|
+
}
|
|
1276
1783
|
this.closed = true;
|
|
1277
1784
|
this.closeFd();
|
|
1278
1785
|
this.releaseLock();
|
|
@@ -1301,7 +1808,10 @@ export class RunJournal {
|
|
|
1301
1808
|
const replayingStage = !!plan?.checkpoints.has(visitKey(node, iteration));
|
|
1302
1809
|
const pending = plan?.intents.has(key) === true;
|
|
1303
1810
|
if (pending || replayingStage) {
|
|
1304
|
-
|
|
1811
|
+
// Look back from a little before the intent (clock skew between this host and the provider).
|
|
1812
|
+
const intentAt = Date.parse(String(plan?.intents.get(key)?.at ?? ""));
|
|
1813
|
+
const since = Number.isFinite(intentAt) ? new Date(intentAt - 5 * 60_000).toISOString() : undefined;
|
|
1814
|
+
const probe = await probeProvider(tool.name, (args ?? {}), key, skill, ctx, since);
|
|
1305
1815
|
if (probe.state === "applied") {
|
|
1306
1816
|
const output = slimOutput(probe.output);
|
|
1307
1817
|
this.append("output:applied", { node, iteration, key, tool: tool.name, output, recovered: true });
|
|
@@ -1313,6 +1823,7 @@ export class RunJournal {
|
|
|
1313
1823
|
`check the target, then resume with --allow-repeat-writes to send it again`);
|
|
1314
1824
|
}
|
|
1315
1825
|
}
|
|
1826
|
+
// A write the journal cannot record is never sent: append throws first.
|
|
1316
1827
|
this.append("output:intent", { node, iteration, key, tool: tool.name });
|
|
1317
1828
|
const output = await tool.handler(withMarker(tool.name, args, key), ctx);
|
|
1318
1829
|
this.append("output:applied", { node, iteration, key, tool: tool.name, output: slimOutput(output) });
|