@sweny-ai/core 0.1.177 → 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)
@@ -29,18 +48,32 @@
29
48
  * output:intent a write is about to be applied (idempotency key + tool)
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
- * route the edge taken after the visit (null = the run ended)
33
- * usage spend of one agent attempt (cumulative; live while it runs, final when it returns)
51
+ * route the edge taken after the visit (null = the run ended), and the rung that chose it
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",
@@ -293,13 +632,15 @@ export function redact(value, secrets = []) {
293
632
  return v.map((x) => walk(x, depth + 1));
294
633
  if (v && typeof v === "object") {
295
634
  const out = {};
635
+ // defineProperty, not assignment: a key named `__proto__` stays a plain key.
636
+ const put = (k, x) => Object.defineProperty(out, k, { value: x, enumerable: true, writable: true, configurable: true });
296
637
  for (const [k, x] of Object.entries(v)) {
297
638
  if (typeof x === "string" && isSecretKey(k) && x.length > 0) {
298
- out[k] = REDACTED;
639
+ put(k, REDACTED);
299
640
  redacted = true;
300
641
  }
301
642
  else if (x !== undefined && typeof x !== "function") {
302
- out[k] = walk(x, depth + 1);
643
+ put(k, walk(x, depth + 1));
303
644
  }
304
645
  }
305
646
  return out;
@@ -312,7 +653,7 @@ export function redact(value, secrets = []) {
312
653
  export class JournalVersionError extends Error {
313
654
  constructor(version) {
314
655
  super(typeof version === "number" && version < JOURNAL_SCHEMA_VERSION
315
- ? `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 ` +
316
657
  `resumed safely. Start a new run.`
317
658
  : `run journal format v${String(version)} is newer than this sweny understands (v${JOURNAL_SCHEMA_VERSION}); upgrade sweny to resume it`);
318
659
  this.name = "JournalVersionError";
@@ -335,14 +676,15 @@ export class JournalMismatchError extends Error {
335
676
  this.name = "JournalMismatchError";
336
677
  }
337
678
  }
338
- /** Thrown when another live process holds the run's journal. */
339
- export class JournalLockedError extends Error {
340
- constructor(pid) {
341
- super(`run journal is in use by process ${pid}; wait for it to finish (or stop it) before resuming`);
342
- 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";
343
684
  }
344
685
  }
345
- 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) {
346
688
  let parsed;
347
689
  try {
348
690
  parsed = JSON.parse(line);
@@ -350,30 +692,24 @@ function verifyLine(line, expectedSeq, key) {
350
692
  catch {
351
693
  return undefined;
352
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.
353
697
  if (!parsed || typeof parsed !== "object" || Array.isArray(parsed))
354
- return undefined;
698
+ return "forged";
355
699
  const rec = parsed;
356
700
  if (typeof rec.v === "number" && rec.v !== JOURNAL_SCHEMA_VERSION && expectedSeq === 1)
357
701
  return "version";
358
702
  if (typeof rec.v === "number" && rec.v > JOURNAL_SCHEMA_VERSION)
359
703
  return "version";
360
704
  if (rec.v !== JOURNAL_SCHEMA_VERSION || typeof rec.type !== "string" || typeof rec.h !== "string")
361
- return undefined;
362
- const { h, ...body } = rec;
363
- const expected = Buffer.from(recordMac(key(), JSON.stringify(body)), "hex");
364
- const given = /^[0-9a-f]{64}$/.test(h) ? Buffer.from(h, "hex") : Buffer.alloc(0);
365
- // A whole, well-formed line that fails its MAC is tampering, not a torn append.
366
- if (given.length !== expected.length || !crypto.timingSafeEqual(given, expected))
367
705
  return "forged";
368
706
  if (rec.seq !== expectedSeq)
369
707
  return "forged";
708
+ const { h, ...body } = rec;
709
+ if (!sameMac(h, chainMac(key(), runId, expectedSeq, prev, body)))
710
+ return "forged";
370
711
  return rec;
371
712
  }
372
- /** `<cwd>/.sweny/runs/<run-id>/journal.ndjson` -> cwd and run id. */
373
- function journalLocation(file) {
374
- const runDir = path.dirname(path.resolve(file));
375
- return { runId: path.basename(runDir), cwd: path.dirname(path.dirname(path.dirname(runDir))) };
376
- }
377
713
  function looksLikeRecord(text) {
378
714
  try {
379
715
  const p = JSON.parse(text);
@@ -384,73 +720,118 @@ function looksLikeRecord(text) {
384
720
  }
385
721
  }
386
722
  /**
387
- * Read a journal. With `repair`, a torn tail is truncated on disk back to the
388
- * last valid record. Records are authenticated with the run's key (`key`, else
389
- * loaded from the state dir for the run the path names). Throws
390
- * {@link JournalVersionError} for another format and {@link JournalKeyError}
391
- * 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.
392
731
  */
393
732
  export function readJournal(file, opts = {}) {
394
- 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);
395
736
  let key = opts.key;
396
737
  const getKey = () => {
397
- if (!key) {
398
- const { cwd, runId } = journalLocation(file);
399
- key = loadRunKey(runKeyFile(cwd, runId, opts.keyDir ?? runKeyDir()));
400
- }
738
+ key ??= loadRunKey(path.join(dir, KEY_FILE));
401
739
  return key;
402
740
  };
403
741
  const records = [];
742
+ const prevMac = () => (records.length > 0 ? records[records.length - 1].h : "");
404
743
  let offset = 0;
405
744
  let validBytes = 0;
406
745
  let line = 0;
407
746
  let firstBad;
408
747
  let corruptAtLine;
409
748
  let forgedAtLine;
749
+ let missingNewline = false;
750
+ const forged = (at) => {
751
+ firstBad = at;
752
+ corruptAtLine = at;
753
+ forgedAtLine = at;
754
+ };
410
755
  while (offset < buf.length) {
411
756
  const nl = buf.indexOf(0x0a, offset);
412
757
  line++;
413
- if (nl === -1) {
414
- // A final segment without a newline is a torn append, whatever it parses as.
415
- firstBad ??= line;
416
- 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;
417
766
  }
418
- const text = buf.subarray(offset, nl).toString("utf-8");
419
- offset = nl + 1;
420
- if (firstBad === undefined) {
421
- const rec = verifyLine(text, records.length + 1, getKey);
422
- if (rec === "version")
423
- throw new JournalVersionError(JSON.parse(text).v);
424
- if (rec === "forged") {
425
- firstBad = line;
426
- corruptAtLine = line;
427
- forgedAtLine = line;
428
- }
429
- else if (rec) {
430
- records.push(rec);
431
- validBytes = offset;
432
- }
433
- else {
434
- 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}`);
435
792
  }
793
+ restored = rec;
436
794
  }
437
- else if (corruptAtLine === undefined && looksLikeRecord(text)) {
438
- // A well-formed record after a bad line: the damage is not a torn tail.
439
- 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`);
440
803
  }
441
804
  }
442
- const truncatedBytes = buf.length - validBytes;
443
- if (opts.repair && truncatedBytes > 0 && corruptAtLine === undefined) {
444
- 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);
445
813
  }
446
814
  return {
447
815
  file,
448
816
  records,
449
817
  truncatedBytes,
818
+ ...(missingNewline ? { missingNewline } : {}),
819
+ ...(restored ? { restored: true } : {}),
450
820
  ...(corruptAtLine !== undefined ? { corruptAtLine } : {}),
451
821
  ...(forgedAtLine !== undefined ? { forgedAtLine } : {}),
452
822
  };
453
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
+ }
454
835
  const visitKey = (node, iteration) => `${node}#${iteration}`;
455
836
  const RECORD_TYPES = new Set([
456
837
  "run:start",
@@ -462,11 +843,19 @@ const RECORD_TYPES = new Set([
462
843
  "node:end",
463
844
  "route",
464
845
  "usage",
846
+ "agent:unsandboxed",
465
847
  "run:end",
466
848
  ]);
467
849
  const isPosInt = (v) => typeof v === "number" && Number.isInteger(v) && v > 0;
468
850
  const isName = (v) => typeof v === "string" && v.length > 0;
469
851
  const isAmount = (v) => typeof v === "number" && Number.isFinite(v) && v >= 0;
852
+ const isRung = (v) => v === "expr" || v === "decider" || v === "agent";
853
+ const isCount = (v) => typeof v === "number" && Number.isInteger(v) && v >= 0;
854
+ const isCounters = (v) => typeof v === "object" &&
855
+ v !== null &&
856
+ isCount(v.calls) &&
857
+ isCount(v.failures) &&
858
+ typeof v.open === "boolean";
470
859
  /**
471
860
  * Check that the records are a sequence the executor can write: one run:start
472
861
  * first; nothing but run:resume after run:end; a visit starts only where the
@@ -565,6 +954,9 @@ export function checkRecordSequence(records) {
565
954
  bad(r, "has invalid amounts");
566
955
  }
567
956
  break;
957
+ case "agent:unsandboxed":
958
+ inVisit(r);
959
+ break;
568
960
  case "node:end": {
569
961
  inVisit(r);
570
962
  const result = r.result;
@@ -583,6 +975,10 @@ export function checkRecordSequence(records) {
583
975
  bad(r, `routes from ${String(r.from)}, but the visit that ended is ${cur.node}`);
584
976
  if (r.to !== null && !isName(r.to))
585
977
  bad(r, "has no valid target");
978
+ if (r.rung !== undefined && !isRung(r.rung))
979
+ bad(r, "has an unknown rung");
980
+ if (r.decider !== undefined && !isCounters(r.decider))
981
+ bad(r, "has invalid decider counters");
586
982
  cur.routed = true;
587
983
  next = r.to;
588
984
  break;
@@ -663,6 +1059,7 @@ export function buildResumePlan(records) {
663
1059
  const receipts = new Map();
664
1060
  let attempts = 1;
665
1061
  let lastStatus;
1062
+ let deciderCounters;
666
1063
  for (const r of records) {
667
1064
  switch (r.type) {
668
1065
  case "run:resume":
@@ -681,6 +1078,7 @@ export function buildResumePlan(records) {
681
1078
  // Re-run of a visit an earlier attempt did not finish (or finished failed).
682
1079
  delete v.end;
683
1080
  delete v.next;
1081
+ delete v.rung;
684
1082
  }
685
1083
  break;
686
1084
  }
@@ -699,8 +1097,14 @@ export function buildResumePlan(records) {
699
1097
  case "route": {
700
1098
  const from = r.from;
701
1099
  const v = [...order].reverse().find((x) => x.node === from);
702
- if (v)
1100
+ if (v) {
703
1101
  v.next = r.to ?? null;
1102
+ if (isRung(r.rung))
1103
+ v.rung = r.rung;
1104
+ }
1105
+ // Calls already made count even if the visit later re-runs.
1106
+ if (isCounters(r.decider))
1107
+ deciderCounters = r.decider;
704
1108
  break;
705
1109
  }
706
1110
  case "output:intent":
@@ -733,6 +1137,7 @@ export function buildResumePlan(records) {
733
1137
  action: "replay",
734
1138
  status: v.end.result.status,
735
1139
  next: v.next,
1140
+ ...(v.rung ? { rung: v.rung } : {}),
736
1141
  applied,
737
1142
  unconfirmed,
738
1143
  });
@@ -776,6 +1181,10 @@ export function buildResumePlan(records) {
776
1181
  intents,
777
1182
  receipts,
778
1183
  priorSpend: journaledSpend(records),
1184
+ unsandboxed: [...new Set(records.filter((r) => r.type === "agent:unsandboxed").map((r) => String(r.node)))],
1185
+ ...(start.decider
1186
+ ? { decider: { ...start.decider, ...(deciderCounters ? { counters: { ...deciderCounters } } : {}) } }
1187
+ : {}),
779
1188
  };
780
1189
  }
781
1190
  /**
@@ -820,12 +1229,57 @@ async function callRead(skill, name, args, ctx) {
820
1229
  throw new Error(`no ${name} tool configured`);
821
1230
  return tool.handler(args, ctx);
822
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
+ }
823
1274
  /**
824
1275
  * Was this write applied before the crash? Writes that are idempotent by
825
1276
  * nature (labels, state changes, a PR for the same head branch) are simply
826
- * 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`.
827
1281
  */
828
- async function probeProvider(toolName, args, key, skill, ctx) {
1282
+ async function probeProvider(toolName, args, key, skill, ctx, since) {
829
1283
  const token = markerToken(key);
830
1284
  try {
831
1285
  switch (toolName) {
@@ -836,6 +1290,10 @@ async function probeProvider(toolName, args, key, skill, ctx) {
836
1290
  return { state: "reapply" };
837
1291
  case "github_create_issue":
838
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;
839
1297
  const scope = toolName === "github_create_issue" ? "in:body is:issue" : "in:comments";
840
1298
  const out = (await callRead(skill, "github_search_issues", { query: `${token} ${scope}`, repo: args.repo }, ctx));
841
1299
  if (!out || !Array.isArray(out.items))
@@ -843,8 +1301,9 @@ async function probeProvider(toolName, args, key, skill, ctx) {
843
1301
  const hit = toolName === "github_create_issue"
844
1302
  ? out.items.find((i) => typeof i.body === "string" && i.body.includes(token))
845
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.
846
1305
  if (!hit)
847
- return { state: "absent" };
1306
+ return { state: "unknown", reason: "not in GitHub search, which can lag a just-made write" };
848
1307
  return { state: "applied", output: toolName === "github_create_issue" ? hit : {} };
849
1308
  }
850
1309
  case "linear_create_issue": {
@@ -890,40 +1349,71 @@ function slimOutput(o, depth = 0) {
890
1349
  }
891
1350
  return out;
892
1351
  }
893
- /** Minimum gap between two live usage records of one attempt. Final reports are always written. */
894
- export const USAGE_JOURNAL_INTERVAL_MS = 2000;
895
- export function journalDir(cwd, runId) {
896
- 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);
897
1355
  }
898
- /** Run ids with a journal on disk, oldest first. */
899
- 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;
900
1365
  try {
901
- return fs
902
- .readdirSync(path.join(cwd, JOURNAL_DIR))
903
- .filter((d) => RUN_ID_RE.test(d) && fs.existsSync(path.join(cwd, JOURNAL_DIR, d, JOURNAL_FILE)))
904
- .sort();
1366
+ names = fs.readdirSync(base);
905
1367
  }
906
1368
  catch {
907
1369
  return [];
908
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);
909
1382
  }
910
- /** Exact run id, or a unique prefix. */
911
- export function findJournalRun(ref, cwd = process.cwd()) {
912
- 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);
913
1386
  if (runs.includes(ref))
914
1387
  return ref;
915
1388
  const matches = runs.filter((r) => r.startsWith(ref));
916
1389
  return matches.length === 1 ? matches[0] : undefined;
917
1390
  }
918
- /** Delete the oldest journals (and their keys) beyond `keep`, never `except`. Never throws. */
919
- export function pruneJournals(cwd, keep = JOURNAL_KEEP, except, keyDir = runKeyDir()) {
920
- 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);
921
1412
  const extra = runs.slice(0, Math.max(0, runs.length - Math.max(0, keep - (except ? 1 : 0))));
922
1413
  let removed = 0;
923
1414
  for (const r of extra) {
924
1415
  try {
925
- fs.rmSync(journalDir(cwd, r), { recursive: true, force: true });
926
- fs.rmSync(runKeyFile(cwd, r, keyDir), { force: true });
1416
+ fs.rmSync(journalDir(cwd, r, root), { recursive: true, force: true });
927
1417
  removed++;
928
1418
  }
929
1419
  catch {
@@ -932,103 +1422,103 @@ export function pruneJournals(cwd, keep = JOURNAL_KEEP, except, keyDir = runKeyD
932
1422
  }
933
1423
  return removed;
934
1424
  }
935
- function pidAlive(pid) {
936
- try {
937
- process.kill(pid, 0);
938
- return true;
939
- }
940
- catch (err) {
941
- return err.code === "EPERM";
942
- }
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);
943
1428
  }
944
1429
  const quietLogger = { info() { }, warn() { }, error() { }, debug() { } };
1430
+ const errText = (err) => (err instanceof Error ? err.message : String(err));
945
1431
  export class RunJournal {
946
1432
  runId;
947
1433
  dir;
948
1434
  file;
949
1435
  cwd;
1436
+ root;
950
1437
  opts;
951
1438
  logger;
952
1439
  resume;
953
1440
  fd;
954
1441
  seq = 0;
955
- 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;
956
1448
  began = false;
957
1449
  closed = false;
958
1450
  secrets = [];
959
1451
  consumed = new Set();
960
- /** 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. */
961
1453
  key;
962
- keyFile;
1454
+ lock;
963
1455
  /** Last live usage record per attempt: time and spend, for throttling. */
964
1456
  lastUsage = new Map();
1457
+ /** Visits already recorded as run without an enforced sandbox. */
1458
+ uncontainedVisits = new Set();
965
1459
  constructor(opts, resume) {
966
1460
  this.opts = opts;
967
1461
  this.runId = opts.runId;
968
1462
  this.cwd = opts.cwd ?? process.cwd();
969
- this.dir = journalDir(this.cwd, opts.runId);
1463
+ this.root = opts.stateRoot ?? runStateRoot();
1464
+ this.dir = journalDir(this.cwd, opts.runId, this.root);
970
1465
  this.file = path.join(this.dir, JOURNAL_FILE);
971
1466
  this.logger = opts.logger ?? quietLogger;
972
- this.keyFile = runKeyFile(this.cwd, opts.runId, opts.keyDir ?? runKeyDir());
973
1467
  if (resume) {
974
1468
  this.resume = {
975
1469
  plan: resume.plan,
976
1470
  force: resume.force === true,
977
1471
  allowRepeatWrites: resume.allowRepeatWrites === true,
978
1472
  };
979
- this.seq = resume.read.records.length;
980
- 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));
981
1478
  }
982
1479
  }
983
1480
  /** A journal for a new run. Nothing touches disk until the executor calls `begin`. */
984
1481
  static create(opts) {
985
1482
  return new RunJournal(opts);
986
1483
  }
987
- /** 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. */
988
1485
  static openForResume(opts) {
989
- const j = new RunJournal(opts, opts);
990
- j.takeLock();
991
- return j;
1486
+ return new RunJournal(opts, opts);
992
1487
  }
993
- /** 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. */
994
1489
  get active() {
995
- return !this.dead;
996
- }
997
- takeLock() {
998
- const lock = path.join(this.dir, LOCK_FILE);
999
- try {
1000
- const pid = Number(fs.readFileSync(lock, "utf-8").trim());
1001
- if (Number.isInteger(pid) && pid > 0 && pid !== process.pid && pidAlive(pid))
1002
- throw new JournalLockedError(pid);
1003
- }
1004
- catch (err) {
1005
- if (err instanceof JournalLockedError)
1006
- throw err;
1007
- }
1008
- this.makeDir();
1009
- writeFileNoFollow(lock, String(process.pid), { root: this.cwd, mode: 0o600 });
1010
- }
1011
- /** The run dir is private (0700); no link on the path from cwd is followed (safe-file.ts). */
1012
- makeDir() {
1013
- ensureDirNoFollow(this.dir, this.cwd);
1490
+ return !this.failure && !this.killed;
1014
1491
  }
1015
1492
  releaseLock() {
1016
1493
  try {
1017
- fs.rmSync(path.join(this.dir, LOCK_FILE), { force: true });
1494
+ this.lock?.release();
1018
1495
  }
1019
1496
  catch {
1020
1497
  // nothing to do
1021
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;
1022
1514
  }
1023
1515
  append(type, fields) {
1024
- if (this.dead)
1516
+ if (this.failure)
1517
+ throw this.failure;
1518
+ if (this.killed)
1025
1519
  return;
1026
- if (!this.key) {
1027
- // No key, no record resume could trust: stop journaling rather than write unauthenticated records.
1028
- this.dead = true;
1029
- this.logger.warn(` run journal: no signing key for this run; this run cannot be resumed`);
1030
- return;
1031
- }
1520
+ if (!this.key)
1521
+ return this.fail("no signing key for this run");
1032
1522
  const body = {
1033
1523
  v: JOURNAL_SCHEMA_VERSION,
1034
1524
  seq: this.seq + 1,
@@ -1036,34 +1526,48 @@ export class RunJournal {
1036
1526
  at: new Date().toISOString(),
1037
1527
  ...fields,
1038
1528
  };
1039
- 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 };
1040
1531
  if (this.opts.faults?.beforeAppend) {
1041
1532
  try {
1042
1533
  this.opts.faults.beforeAppend(record);
1043
1534
  }
1044
1535
  catch (err) {
1045
- // Simulated process death: nothing after this point reaches the disk.
1046
- this.dead = true;
1047
- this.closeFd();
1048
- this.releaseLock();
1049
- 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);
1050
1552
  }
1051
1553
  }
1052
1554
  try {
1053
1555
  if (this.fd === undefined) {
1054
- this.makeDir();
1055
- 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);
1056
1558
  }
1057
- 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);
1058
1564
  fs.fsyncSync(this.fd);
1059
- this.seq++;
1060
1565
  }
1061
1566
  catch (err) {
1062
- this.dead = true;
1063
- this.closeFd();
1064
- this.logger.warn(` run journal: could not write ${this.file} (${err instanceof Error ? err.message : String(err)}); ` +
1065
- `this run cannot be resumed past this point`);
1567
+ this.fail(`could not write ${this.file}`, err);
1066
1568
  }
1569
+ this.seq++;
1570
+ this.prev = h;
1067
1571
  }
1068
1572
  closeFd() {
1069
1573
  if (this.fd === undefined)
@@ -1101,7 +1605,7 @@ export class RunJournal {
1101
1605
  if (changed.length > 0) {
1102
1606
  if (!this.resume.force) {
1103
1607
  // Nothing is appended for a refused resume: the journal stays as the crash left it.
1104
- this.dead = true;
1608
+ this.killed = true;
1105
1609
  this.closeFd();
1106
1610
  this.releaseLock();
1107
1611
  throw new JournalMismatchError(changed);
@@ -1123,22 +1627,28 @@ export class RunJournal {
1123
1627
  return;
1124
1628
  }
1125
1629
  try {
1126
- this.makeDir();
1127
- // Journals hold node output: keep them out of commits (an agent's `git add -A` included).
1128
- writeFileNoFollow(path.join(this.dir, ".gitignore"), "*\n", { root: this.cwd, mode: 0o644 });
1129
- this.takeLock();
1630
+ makePrivateDir(this.dir);
1631
+ assertStateOutsideWorkspace(this.dir, this.cwd);
1130
1632
  }
1131
- catch {
1132
- // 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);
1133
1638
  }
1134
1639
  try {
1135
- 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;
1136
1645
  }
1137
1646
  catch (err) {
1138
- this.logger.warn(` run journal: could not write the run key under ${path.dirname(this.keyFile)} ` +
1139
- `(${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);
1140
1650
  }
1141
- 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);
1142
1652
  const input = redact(info.input, this.secrets);
1143
1653
  this.append("run:start", {
1144
1654
  run_id: this.runId,
@@ -1153,8 +1663,12 @@ export class RunJournal {
1153
1663
  ...(this.opts.swenyVersion ? { sweny_version: this.opts.swenyVersion } : {}),
1154
1664
  input: input.value,
1155
1665
  input_redacted: input.redacted,
1666
+ ...(info.decider ? { decider: info.decider } : {}),
1156
1667
  });
1157
1668
  }
1669
+ deciderResume() {
1670
+ return this.resume?.plan.decider;
1671
+ }
1158
1672
  replay(node, iteration) {
1159
1673
  const plan = this.resume?.plan;
1160
1674
  if (!plan)
@@ -1172,6 +1686,7 @@ export class RunJournal {
1172
1686
  kind: "complete",
1173
1687
  result: structuredClone(end.result),
1174
1688
  ...(visit.next !== undefined ? { next: visit.next } : {}),
1689
+ ...(visit.rung ? { rung: visit.rung } : {}),
1175
1690
  };
1176
1691
  }
1177
1692
  if (visit.action === "write-stage") {
@@ -1209,9 +1724,17 @@ export class RunJournal {
1209
1724
  write_state: { counts: [...writeState.counts], total: writeState.total, seen: [...writeState.seen] },
1210
1725
  });
1211
1726
  }
1212
- route(from, to) {
1213
- this.append("route", { from, to });
1727
+ route(from, to, rung, decider) {
1728
+ this.append("route", { from, to, ...(rung ? { rung } : {}), ...(decider ? { decider } : {}) });
1214
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
+ */
1215
1738
  usage(node, iteration, attempt, usage, final) {
1216
1739
  const s = spendOf(usage);
1217
1740
  if (s.tokens === undefined && s.costUsd === undefined)
@@ -1220,29 +1743,43 @@ export class RunJournal {
1220
1743
  const costUsd = s.costUsd ?? 0;
1221
1744
  const k = `${visitKey(node, iteration)}|${attempt}`;
1222
1745
  const last = this.lastUsage.get(k);
1223
- const now = Date.now();
1224
- if (!final && last) {
1225
- if (tokens <= last.tokens && costUsd <= last.costUsd)
1226
- return;
1227
- if (now - last.at < USAGE_JOURNAL_INTERVAL_MS)
1228
- return;
1229
- }
1230
- if (final && last && tokens <= last.tokens && costUsd <= last.costUsd)
1746
+ if (last && tokens <= last.tokens && costUsd <= last.costUsd)
1231
1747
  return;
1232
1748
  this.lastUsage.set(k, {
1233
- at: now,
1234
1749
  tokens: Math.max(tokens, last?.tokens ?? 0),
1235
1750
  costUsd: Math.max(costUsd, last?.costUsd ?? 0),
1236
1751
  });
1237
1752
  this.append("usage", { node, iteration, attempt, tokens, cost_usd: costUsd, final });
1238
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
+ }
1239
1770
  priorSpend() {
1240
1771
  return this.resume ? { ...this.resume.plan.priorSpend } : undefined;
1241
1772
  }
1242
- /** 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. */
1243
1774
  end(status) {
1244
- if (this.began && !this.closed)
1245
- 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
+ }
1246
1783
  this.closed = true;
1247
1784
  this.closeFd();
1248
1785
  this.releaseLock();
@@ -1271,7 +1808,10 @@ export class RunJournal {
1271
1808
  const replayingStage = !!plan?.checkpoints.has(visitKey(node, iteration));
1272
1809
  const pending = plan?.intents.has(key) === true;
1273
1810
  if (pending || replayingStage) {
1274
- 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);
1275
1815
  if (probe.state === "applied") {
1276
1816
  const output = slimOutput(probe.output);
1277
1817
  this.append("output:applied", { node, iteration, key, tool: tool.name, output, recovered: true });
@@ -1283,6 +1823,7 @@ export class RunJournal {
1283
1823
  `check the target, then resume with --allow-repeat-writes to send it again`);
1284
1824
  }
1285
1825
  }
1826
+ // A write the journal cannot record is never sent: append throws first.
1286
1827
  this.append("output:intent", { node, iteration, key, tool: tool.name });
1287
1828
  const output = await tool.handler(withMarker(tool.name, args, key), ctx);
1288
1829
  this.append("output:applied", { node, iteration, key, tool: tool.name, output: slimOutput(output) });