@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/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
- * File: `.sweny/runs/<run-id>/journal.ndjson` (mode 0600), one JSON record per
6
- * line. Every record carries the format version (`v`), a sequence number (`seq`)
7
- * and an authentication code (`h`, HMAC-SHA256 of the record without `h`, under
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
- * Integrity: the key is 32 random bytes made when the run starts and kept
12
- * OUTSIDE the workspace, in the user's sweny state dir
13
- * (`$SWENY_STATE_DIR`, else `$XDG_STATE_HOME/sweny`, else
14
- * `~/.local/state/sweny`), under `run-keys/` (dir 0700, file 0600). Anything
15
- * that can write the workspace but not that dir (an agent confined to the
16
- * workspace, a checked-out branch, an artifact) cannot mint a record resume
17
- * accepts. Both sandboxes deny reading the key dir; an unsandboxed agent
18
- * running as the same OS user can still read it, which is why resume also
19
- * checks the record sequence (see {@link buildResumePlan}) and every replayed
20
- * route against the workflow's real edges. A resume on another machine needs
21
- * the same state dir.
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; live while it runs, final when it returns)
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: a torn or corrupt tail (power loss mid-append) is truncated back to
40
- * the last valid record. Corruption in the middle of the file is refused.
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
- * Privacy: the journal is local and holds what the run needs to continue (node
43
- * data, eval verdicts, safe-output intents). It never holds environment values:
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
- import { ensureDirNoFollow, openNoFollow, writeFileNoFollow } from "./safe-file.js";
63
- /** v2: records are authenticated with a per-run HMAC key (v1 used a public checksum and is refused). */
64
- export const JOURNAL_SCHEMA_VERSION = 2;
65
- export const JOURNAL_DIR = path.join(".sweny", "runs");
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 LOCK_FILE = "journal.lock";
68
- /** Journals kept on disk; the oldest beyond this are deleted when a new run starts. */
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
- // ─── Run keys ─────────────────────────────────────────────────────
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 keys live. Never inside the workspace. */
166
- export function runKeyDir(env = process.env) {
167
- return path.join(swenyStateDir(env), "run-keys");
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 key file for one run in one workspace (the workspace path is hashed, not stored). */
178
- export function runKeyFile(cwd, runId, keyDir = runKeyDir()) {
179
- const scope = sha256(realOr(cwd)).slice(0, 16);
180
- return path.join(keyDir, `${scope}-${runId}.key`);
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
- fs.mkdirSync(path.dirname(file), { recursive: true, mode: 0o700 });
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 = fs.readFileSync(file, "utf-8").trim();
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 authenticated records (v${JOURNAL_SCHEMA_VERSION}); it cannot be ` +
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 another live process holds the run's journal. */
341
- export class JournalLockedError extends Error {
342
- constructor(pid) {
343
- super(`run journal is in use by process ${pid}; wait for it to finish (or stop it) before resuming`);
344
- this.name = "JournalLockedError";
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
- function verifyLine(line, expectedSeq, key) {
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 undefined;
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. With `repair`, a torn tail is truncated on disk back to the
390
- * last valid record. Records are authenticated with the run's key (`key`, else
391
- * loaded from the state dir for the run the path names). Throws
392
- * {@link JournalVersionError} for another format and {@link JournalKeyError}
393
- * when the key is missing.
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 buf = fs.readFileSync(file);
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
- if (!key) {
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
- if (nl === -1) {
416
- // A final segment without a newline is a torn append, whatever it parses as.
417
- firstBad ??= line;
418
- break;
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 text = buf.subarray(offset, nl).toString("utf-8");
421
- offset = nl + 1;
422
- if (firstBad === undefined) {
423
- const rec = verifyLine(text, records.length + 1, getKey);
424
- if (rec === "version")
425
- throw new JournalVersionError(JSON.parse(text).v);
426
- if (rec === "forged") {
427
- firstBad = line;
428
- corruptAtLine = line;
429
- forgedAtLine = line;
430
- }
431
- else if (rec) {
432
- records.push(rec);
433
- validBytes = offset;
434
- }
435
- else {
436
- firstBad = line;
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 (corruptAtLine === undefined && looksLikeRecord(text)) {
440
- // A well-formed record after a bad line: the damage is not a torn tail.
441
- corruptAtLine = firstBad;
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
- const truncatedBytes = buf.length - validBytes;
445
- if (opts.repair && truncatedBytes > 0 && corruptAtLine === undefined) {
446
- fs.truncateSync(file, validBytes);
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 searched for by their marker.
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: "absent" };
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
- /** Minimum gap between two live usage records of one attempt. Final reports are always written. */
919
- export const USAGE_JOURNAL_INTERVAL_MS = 2000;
920
- export function journalDir(cwd, runId) {
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
- /** Run ids with a journal on disk, oldest first. */
924
- export function listJournalRuns(cwd = process.cwd()) {
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
- return fs
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
- /** Delete the oldest journals (and their keys) beyond `keep`, never `except`. Never throws. */
944
- export function pruneJournals(cwd, keep = JOURNAL_KEEP, except, keyDir = runKeyDir()) {
945
- const runs = listJournalRuns(cwd).filter((r) => r !== except);
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
- function pidAlive(pid) {
961
- try {
962
- process.kill(pid, 0);
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
- dead = false;
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 key file outside the workspace. */
1452
+ /** Per-run HMAC key; lives only here and in the run's private state dir. */
986
1453
  key;
987
- keyFile;
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.dir = journalDir(this.cwd, opts.runId);
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.seq = resume.read.records.length;
1005
- this.key = loadRunKey(this.keyFile);
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
- const j = new RunJournal(opts, opts);
1015
- j.takeLock();
1016
- return j;
1486
+ return new RunJournal(opts, opts);
1017
1487
  }
1018
- /** False once an append failed: the rest of the run is not journaled. */
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.dead;
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
- fs.rmSync(path.join(this.dir, LOCK_FILE), { force: true });
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.dead)
1050
- return;
1051
- if (!this.key) {
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 record = { ...body, h: recordMac(this.key, JSON.stringify(body)) };
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
- // Simulated process death: nothing after this point reaches the disk.
1071
- this.dead = true;
1072
- this.closeFd();
1073
- this.releaseLock();
1074
- throw err;
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
- this.makeDir();
1080
- this.fd = openNoFollow(this.file, { root: this.cwd, append: true, mode: 0o600 });
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
- fs.writeSync(this.fd, JSON.stringify(record) + "\n");
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.dead = true;
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.dead = true;
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.makeDir();
1152
- // Journals hold node output: keep them out of commits (an agent's `git add -A` included).
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
- // append() reports the failure
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.key = createRunKey(this.keyFile);
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.logger.warn(` run journal: could not write the run key under ${path.dirname(this.keyFile)} ` +
1164
- `(${err instanceof Error ? err.message : String(err)}); set SWENY_STATE_DIR to a writable dir`);
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.opts.keyDir ?? runKeyDir());
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
- const now = Date.now();
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`, then release the lock. Safe to call twice (one 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
- this.append("run:end", { status });
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
- const probe = await probeProvider(tool.name, (args ?? {}), key, skill, ctx);
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) });