@sema-agent/core 7.26.2 → 7.27.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (53) hide show
  1. package/CHANGELOG.md +61 -0
  2. package/dist/core/auto-mode-directive.d.ts +6 -4
  3. package/dist/core/auto-mode-directive.js +1 -5
  4. package/dist/core/engine-notice.d.ts +13 -6
  5. package/dist/core/runner/contracts.d.ts +31 -7
  6. package/dist/core/runner/denial-limit-arms.d.ts +17 -15
  7. package/dist/core/runner/denial-limit-arms.js +4 -1
  8. package/dist/core/runner/prepare-delegation-surface.d.ts +4 -0
  9. package/dist/core/runner/prepare-delegation-surface.js +2 -1
  10. package/dist/core/runner/prepare-gate-stations.d.ts +1 -2
  11. package/dist/core/runner/prepare-inherited-gate.d.ts +1 -2
  12. package/dist/core/runner/prepare-policy-chain.d.ts +1 -2
  13. package/dist/core/runner/prepare-task.js +2 -2
  14. package/dist/core/runner/run-leg.js +5 -1
  15. package/dist/core/tool-policy.d.ts +23 -2
  16. package/dist/core/tool-policy.js +106 -103
  17. package/dist/index.d.ts +6 -1
  18. package/dist/stores/file/background-agent-store.d.ts +4 -0
  19. package/dist/stores/file/background-agent-store.js +10 -0
  20. package/dist/stores/file/checkpoint-store.d.ts +3 -0
  21. package/dist/stores/file/checkpoint-store.js +18 -0
  22. package/dist/stores/file/corrupt-read-sink.d.ts +20 -0
  23. package/dist/stores/file/corrupt-read-sink.js +15 -0
  24. package/dist/stores/file/file-history-store.d.ts +1 -2
  25. package/dist/stores/file/file-history-store.js +3 -8
  26. package/dist/stores/file/fs-atomic.d.ts +8 -3
  27. package/dist/stores/file/fs-atomic.js +19 -2
  28. package/dist/stores/file/index.js +4 -4
  29. package/dist/stores/file/mailbox-store.d.ts +3 -0
  30. package/dist/stores/file/mailbox-store.js +24 -18
  31. package/dist/stores/file/permission-rule-store.js +18 -1
  32. package/dist/stores/file/session-policy-store.d.ts +17 -1
  33. package/dist/stores/file/session-policy-store.js +3 -12
  34. package/dist/stores/file/session-store.d.ts +1 -5
  35. package/dist/stores/file/session-store.js +9 -14
  36. package/dist/stores/file/shared-ledger.d.ts +18 -0
  37. package/dist/stores/file/shared-ledger.js +16 -2
  38. package/dist/stores/file/tool-result-store.d.ts +9 -1
  39. package/dist/stores/file/tool-result-store.js +17 -2
  40. package/dist/stores/file/workflow-journal-store.d.ts +10 -1
  41. package/dist/stores/file/workflow-journal-store.js +8 -12
  42. package/dist/stores/file/workflow-run-store.d.ts +3 -0
  43. package/dist/stores/file/workflow-run-store.js +10 -0
  44. package/dist/tools/artifact/local-stub.js +16 -1
  45. package/dist/tools/fs/dead-cwd.d.ts +48 -0
  46. package/dist/tools/fs/dead-cwd.js +56 -0
  47. package/dist/tools/fs/fs-bash.js +4 -32
  48. package/dist/tools/monitor.d.ts +4 -0
  49. package/dist/tools/monitor.js +6 -0
  50. package/package.json +1 -1
  51. package/test/export-surface.snapshot.json +3 -1
  52. package/wire-changes/core@7.26.3.json +65 -0
  53. package/wire-changes/core@7.27.0.json +152 -0
@@ -4,6 +4,7 @@ import { assertRetentionPolicy } from "../../core/retention-policy.js";
4
4
  import { existsSync, linkSync, readdirSync, readFileSync, renameSync, statSync, unlinkSync, writeFileSync } from "node:fs";
5
5
  import { detachMailboxMessage, newestSentAt, readMailboxPeerMeta, } from "../../core/mailbox-store.js";
6
6
  import { AppendLog, atomicWriteFile, canonicalStoreKey, ensureDir, readJsonlRecords, sanitizeScope, sanitizePathComponent } from "./fs-atomic.js";
7
+ import { guardCorruptSink } from "./corrupt-read-sink.js";
7
8
  import { physicalPathOf } from "../../core/physical-path.js";
8
9
  import { assertAdoptionBootGate } from "./adoption/marker.js";
9
10
  function diskMarkOf(path) {
@@ -339,17 +340,7 @@ export class FileMailboxStore {
339
340
  throw new Error(`FileMailboxStore: lockWaitMs must be a finite number > 0, got ${JSON.stringify(opts.lockWaitMs)}`);
340
341
  }
341
342
  this.lockWaitMs = lockWaitMs;
342
- const sink = opts.onCorruptRead;
343
- this.discloseCorrupt =
344
- sink === undefined
345
- ? undefined
346
- : (info) => {
347
- try {
348
- sink(info);
349
- }
350
- catch {
351
- }
352
- };
343
+ this.discloseCorrupt = guardCorruptSink(opts.onCorruptRead);
353
344
  const lexicalDir = resolve(join(root, "mailboxes"));
354
345
  const lexicalTmp = resolve(join(root, "tmp"));
355
346
  ensureDir(lexicalDir);
@@ -382,6 +373,11 @@ export class FileMailboxStore {
382
373
  return result;
383
374
  });
384
375
  }
376
+ applyReplayed(b, ev, path) {
377
+ if (applyEvent(b, ev))
378
+ return;
379
+ this.discloseCorrupt?.({ path, reason: "missing-required: a mailbox record is not one of this box's events — an unknown tag, or a known tag whose payload is missing (skipped; the rest of the replay is intact)" });
380
+ }
385
381
  reconcile(b) {
386
382
  const now = diskMarkOf(b.path);
387
383
  if (sameDiskMark(now, b.mark))
@@ -393,7 +389,7 @@ export class FileMailboxStore {
393
389
  b.nextSeq = 1;
394
390
  delete b.lease;
395
391
  for (const ev of events)
396
- applyEvent(b, ev);
392
+ this.applyReplayed(b, ev, b.path);
397
393
  b.events = Math.max(0, events.length - (b.messages.length + 1 + (b.lease !== undefined ? 1 : 0)));
398
394
  b.mark = now;
399
395
  }
@@ -413,7 +409,7 @@ export class FileMailboxStore {
413
409
  const events = readJsonlRecords(path, this.discloseCorrupt);
414
410
  const state = { messages: [], nextSeq: 1, log: new AppendLog(path), events: events.length, refs: 1, path, mark: undefined };
415
411
  for (const ev of events)
416
- applyEvent(state, ev);
412
+ this.applyReplayed(state, ev, path);
417
413
  state.mark = diskMarkOf(path);
418
414
  sharedBoxes.set(key, state);
419
415
  this.touched.set(key, state);
@@ -501,7 +497,7 @@ export class FileMailboxStore {
501
497
  return withPathLock(this.lockKey(scope, handle), () => {
502
498
  const b = { messages: [], nextSeq: 1 };
503
499
  for (const ev of readJsonlRecords(path, this.discloseCorrupt))
504
- applyEvent(b, ev);
500
+ this.applyReplayed(b, ev, path);
505
501
  return b.messages.length;
506
502
  });
507
503
  }
@@ -561,7 +557,7 @@ export class FileMailboxStore {
561
557
  return false;
562
558
  const b = { messages: [], nextSeq: 1 };
563
559
  for (const ev of readJsonlRecords(path, this.discloseCorrupt))
564
- applyEvent(b, ev);
560
+ this.applyReplayed(b, ev, path);
565
561
  const newest = newestSentAt(b.messages);
566
562
  if (newest === undefined || newest >= now - maxAgeMs)
567
563
  return false;
@@ -587,26 +583,36 @@ export class FileMailboxStore {
587
583
  this.touched.clear();
588
584
  }
589
585
  }
586
+ const num = (v) => typeof v === "number" && Number.isFinite(v);
590
587
  function applyEvent(b, ev) {
591
588
  if (ev.t === "append") {
589
+ if (ev.m === null || typeof ev.m !== "object" || !num(ev.m.seq))
590
+ return false;
592
591
  b.messages.push(ev.m);
593
592
  if (ev.m.seq >= b.nextSeq)
594
593
  b.nextSeq = ev.m.seq + 1;
595
- return;
594
+ return true;
596
595
  }
597
596
  if (ev.t === "lease") {
597
+ if (typeof ev.owner !== "string" || !num(ev.maxSeq) || !num(ev.expiresAt))
598
+ return false;
598
599
  if (ev.maxSeq >= b.nextSeq)
599
600
  b.nextSeq = ev.maxSeq + 1;
600
601
  if (ev.owner !== "")
601
602
  b.lease = { owner: ev.owner, expiresAt: ev.expiresAt, maxSeq: ev.maxSeq };
602
- return;
603
+ return true;
603
604
  }
604
605
  if (ev.t === "ack") {
606
+ if (!num(ev.upToSeq))
607
+ return false;
605
608
  b.messages = b.messages.filter((m) => m.seq > ev.upToSeq);
606
609
  if (b.lease !== undefined && b.lease.maxSeq <= ev.upToSeq)
607
610
  b.lease = undefined;
608
- return;
611
+ return true;
609
612
  }
613
+ if (ev.t !== "release" || typeof ev.owner !== "string")
614
+ return false;
610
615
  if (b.lease?.owner === ev.owner)
611
616
  b.lease = undefined;
617
+ return true;
612
618
  }
@@ -48,8 +48,25 @@ export function readRuleAdoptionFile(dir) {
48
48
  }
49
49
  return parsed;
50
50
  }
51
+ function isRuleOwnerShape(v) {
52
+ if (v === null || typeof v !== "object")
53
+ return false;
54
+ const owner = v;
55
+ return owner.kind === "local-owner" || (owner.kind === "principal" && typeof owner.principal === "string");
56
+ }
51
57
  function isRuleAdoptionFileShape(v) {
52
- return typeof v === "object" && v !== null && v.schemaVersion === 1 && ("marker" in v || "adopted" in v);
58
+ if (v === null || typeof v !== "object" || v.schemaVersion !== 1)
59
+ return false;
60
+ const hasMarker = "marker" in v;
61
+ const hasAdopted = "adopted" in v;
62
+ if (hasMarker === hasAdopted)
63
+ return false;
64
+ const rec = (hasMarker ? v.marker : v.adopted);
65
+ if (rec === null || typeof rec !== "object")
66
+ return false;
67
+ if (!isRuleOwnerShape(rec.from) || typeof rec.toPrincipal !== "string" || rec.toPrincipal === "")
68
+ return false;
69
+ return hasMarker ? rec.phase === 2 || rec.phase === 3 || rec.phase === 4 || rec.phase === 5 : typeof rec.atMs === "number" && Number.isFinite(rec.atMs);
53
70
  }
54
71
  export function writeRuleAdoptionFile(dir, content) {
55
72
  assertSafeDir(dir);
@@ -1,4 +1,20 @@
1
1
  import { type PutRulesOptions, type SessionPermissionRules, type SessionPolicyStore, type SessionRulesRecord, type StoredSessionRules } from "../../core/session-policy-store.js";
2
+ /**
3
+ * design/99 §E6 — file-backed {@link SessionPolicyStore} for the local (TOC) backend: one JSON row file per
4
+ * `(sessionId, principal)`, plus — once that key has ever been stamped — a second, tiny file beside it holding
5
+ * the number the erase contract on {@link SessionPolicyStore.deleteBySession} says must outlive the row. Both
6
+ * names share a stem, and the mark's suffix is what every directory sweep here tests first
7
+ * ({@link MARK_SUFFIX}). Semantics otherwise **byte-for-byte identical to `InMemorySessionPolicyStore`** (the
8
+ * cross-backend equivalence contract). CAS-rev OCC + tighten-only reuse the SAME pure helpers
9
+ * (`loosenReasons`/`normalizeRules`/`stripRev`) as core — no re-implemented rule logic that could drift.
10
+ *
11
+ * Atomicity: the file backend is single-process (the `FileStorageBackend` boot lock guarantees ONE writer per
12
+ * data dir), and `getRules`/`putRules` read-check-write SYNCHRONOUSLY (no await between the rev read and the
13
+ * atomic write), so the read-modify-write is atomic in the one event loop — exactly the InMemory store's premise.
14
+ * Cross-process CORRECT concurrency is the Pg/TiDB backend's job (a CAS WHERE clause), by design.
15
+ */
16
+ /** Coordinates of one corrupt-treated-as-absent policy read (the {@link FileSessionPolicyStoreOptions.onCorruptRead}
17
+ * payload). Named rather than inline so the backend option that forwards it names the SAME shape. */
2
18
  export interface SessionPolicyCorruptReadInfo {
3
19
  /** The session whose read observed the corrupt row. On the enumeration face this is the session being
4
20
  * ENUMERATED — a corrupt row's own `__sid` is by definition unreadable, so it cannot be attributed. */
@@ -84,7 +100,7 @@ export declare class FileSessionPolicyStore implements SessionPolicyStore {
84
100
  * alike (a window-era anonymous write must not mint a fresh orphan row). */
85
101
  private readonly anonymousAlias;
86
102
  constructor(root: string, opts?: FileSessionPolicyStoreOptions);
87
- /** The one delivery point for {@link onCorruptRead}. Swallow-guarded here so no caller has to remember. */
103
+ /** The one delivery point for {@link onCorruptRead}. */
88
104
  private disclose;
89
105
  /** RB-482 top item (baggage class-4 ruling): corrupt-read fail-open is a DOCUMENTED compromise (a throwing
90
106
  * getRules fails the whole task; a throwing putRules self-locks the repair write) — but it must not
@@ -2,13 +2,8 @@ import { existsSync, readFileSync, readdirSync, unlinkSync } from "node:fs";
2
2
  import { join } from "node:path";
3
3
  import { loosenReasons, normalizeRules, stripRev, SessionPolicyError, } from "../../core/session-policy-store.js";
4
4
  import { atomicWriteFile, ensureDir, sanitizeScope } from "./fs-atomic.js";
5
+ import { guardCorruptSink } from "./corrupt-read-sink.js";
5
6
  import { assertAdoptionBootGate, readRootAdoptionFile } from "./adoption/marker.js";
6
- function containSinkThenable(r) {
7
- if (typeof r?.then === "function") {
8
- r.then(undefined, () => {
9
- });
10
- }
11
- }
12
7
  export const SESSION_POLICY_MARK_SUFFIX = ".hwm.json";
13
8
  const MARK_SUFFIX = SESSION_POLICY_MARK_SUFFIX;
14
9
  export function readSessionPolicyMark(path) {
@@ -76,17 +71,13 @@ export class FileSessionPolicyStore {
76
71
  assertAdoptionBootGate(root, "FileSessionPolicyStore");
77
72
  this.dir = join(root, "session-policy");
78
73
  ensureDir(this.dir);
79
- this.onCorruptRead = opts?.onCorruptRead;
74
+ this.onCorruptRead = guardCorruptSink(opts?.onCorruptRead);
80
75
  const adoption = readRootAdoptionFile(root);
81
76
  this.anonymousAlias =
82
77
  adoption !== undefined && "adopted" in adoption && adoption.adopted.from.kind === "local-owner" ? adoption.adopted.toPrincipal : undefined;
83
78
  }
84
79
  disclose(info) {
85
- try {
86
- containSinkThenable(this.onCorruptRead?.(info));
87
- }
88
- catch {
89
- }
80
+ this.onCorruptRead?.(info);
90
81
  }
91
82
  discloseCorrupt(sessionId, principal, reason) {
92
83
  this.disclose({ sessionId, ...(principal !== undefined ? { principal } : {}), path: this.pathFor(sessionId, principal), reason });
@@ -28,11 +28,7 @@ export declare class FileSessionRepo implements SessionRepo {
28
28
  * check on the way out possible without pinning a storage the table itself only holds weakly. */
29
29
  private readonly joined;
30
30
  constructor(root: string, opts?: FileSessionRepoOptions);
31
- /** The one delivery point for {@link FileSessionRepoOptions.onCorruptRead}; swallow-guarded here so
32
- * no call site has to remember. The sink seat is void-typed but a host may hand it an async
33
- * function — an async sink's rejection is observed off its returned thenable (same containment
34
- * as the strategy store's incident sink), so neither a sync throw nor an async rejection can
35
- * re-introduce the failure mode the fail-open avoids. */
31
+ /** The one delivery point for {@link FileSessionRepoOptions.onCorruptRead}. */
36
32
  private disclose;
37
33
  private pathFor;
38
34
  /** Read + torn-tail-recover a session file into (meta, entries); a missing file → not_found. */
@@ -2,14 +2,9 @@ import { existsSync, readdirSync, rmSync, statSync } from "node:fs";
2
2
  import { join } from "node:path";
3
3
  import { BaseSessionStorage, StoredSession, SessionError, getEntriesToFork, uuidv7, validateEntriesForImport, } from "../../internal/harness.js";
4
4
  import { canonicalStoreKey, AppendLog, atomicWriteFile, ensureDir, readJsonlRecords, sanitizePathComponent } from "./fs-atomic.js";
5
+ import { guardCorruptSink } from "./corrupt-read-sink.js";
5
6
  import { assertAdoptionBootGate } from "./adoption/marker.js";
6
7
  import { thrownText } from "../../core/thrown-value.js";
7
- function containSinkThenable(r) {
8
- if (typeof r?.then === "function") {
9
- r.then(undefined, () => {
10
- });
11
- }
12
- }
13
8
  const SUFFIX = ".jsonl";
14
9
  const sharedSessionStorages = new Map();
15
10
  const sessionStorageFinalizer = new FinalizationRegistry(({ canonical, log }) => {
@@ -70,14 +65,10 @@ export class FileSessionRepo {
70
65
  this.tmpDir = join(root, "tmp");
71
66
  ensureDir(this.dir);
72
67
  ensureDir(this.tmpDir);
73
- this.onCorruptRead = opts?.onCorruptRead;
68
+ this.onCorruptRead = guardCorruptSink(opts?.onCorruptRead);
74
69
  }
75
70
  disclose(path, reason) {
76
- try {
77
- containSinkThenable(this.onCorruptRead?.({ path, reason }));
78
- }
79
- catch {
80
- }
71
+ this.onCorruptRead?.({ path, reason });
81
72
  }
82
73
  pathFor(id) {
83
74
  return join(this.dir, `${sanitizePathComponent(id)}${SUFFIX}`);
@@ -97,9 +88,13 @@ export class FileSessionRepo {
97
88
  createdAt = line.createdAt;
98
89
  forkedFrom = line.forkedFrom;
99
90
  placement = line.placement;
91
+ continue;
92
+ }
93
+ if (line.kind !== "entry" || line.entry === null || typeof line.entry !== "object") {
94
+ this.disclose(path, "missing-required: a session line is neither a meta header nor an entry record (skipped; the rest of the replay is intact)");
95
+ continue;
100
96
  }
101
- else
102
- entries.push(line.entry);
97
+ entries.push(line.entry);
103
98
  }
104
99
  return { createdAt, ...(forkedFrom !== undefined ? { forkedFrom } : {}), ...(placement !== undefined ? { placement } : {}), entries };
105
100
  }
@@ -1,3 +1,4 @@
1
+ import type { CorruptReadInfo } from "./corrupt-read-sink.js";
1
2
  /**
2
3
  * design/157 A9 — the SHARED substrate under the three snapshot+ledger file stores
3
4
  * ({@link import("./background-agent-store.js").FileBackgroundAgentStore},
@@ -61,6 +62,11 @@
61
62
  /** The paths one ledger authority owns. `dir` is also what the authority is keyed on. */
62
63
  export interface LedgerPaths {
63
64
  readonly dir: string;
65
+ /** Disclosure seat for a replay row this authority DROPPED as corrupt — without it the loss is
66
+ * indistinguishable from "fewer rows". Already swallow-guarded by the store that wired it. Only the
67
+ * FIRST instance over a directory replays, so only its seat can fire: a joiner reads the authority
68
+ * that instance built, which is the same fact its refcount records. */
69
+ readonly onCorruptRead?: (info: CorruptReadInfo) => void;
64
70
  /** Scratch dir for {@link atomicWriteFile} (same filesystem as the targets → rename is atomic). */
65
71
  readonly tmpDir: string;
66
72
  readonly ledgerPath: string;
@@ -73,6 +79,18 @@ export interface LedgerModel<Row, Ev> {
73
79
  label: string;
74
80
  keyOf(row: Row): string;
75
81
  apply(rows: Map<string, Row>, ev: Ev): void;
82
+ /**
83
+ * `@contract store.read_back_shape_judged` floor: is this parsed body one of MY rows / MY events at
84
+ * all? Judged on the DISCRIMINANT only — the key a row is filed under, the tag an event is folded by —
85
+ * because what to do with a discriminant this binary does not KNOW is the model's own business (the
86
+ * checkpoint ledger refuses the whole replay for a kind from a newer worker, deliberately). A body
87
+ * that carries no discriminant carries no such choice: it is damage, and `bootstrap` discloses and
88
+ * skips it. Required rather than optional, so a fourth ledger store cannot inherit the silence by
89
+ * omission — before this floor existed, `{"note":"x"}` in an agent ledger filed a row under the key
90
+ * `undefined` and the next enumeration read members off it.
91
+ */
92
+ judgeRow(x: unknown): boolean;
93
+ judgeEvent(x: unknown): boolean;
76
94
  }
77
95
  /**
78
96
  * One directory's authority: the replayed row map, the per-key async mutex, the append log and the
@@ -20,9 +20,23 @@ export class LedgerCore {
20
20
  bootstrap() {
21
21
  this.fence = acquireStoreDirLock(this.paths.dir, { label: this.model.label });
22
22
  try {
23
- for (const row of readJsonlRecords(this.paths.snapshotPath))
23
+ const disclose = this.paths.onCorruptRead;
24
+ const mine = (xs, judge, path, what) => {
25
+ const kept = [];
26
+ for (const x of xs) {
27
+ if (judge(x))
28
+ kept.push(x);
29
+ else
30
+ disclose?.({ path, reason: `missing-required: a ${what} carries none of this ${this.model.label}'s own members (skipped; the rest of the replay is intact)` });
31
+ }
32
+ return kept;
33
+ };
34
+ const snapPath = this.paths.snapshotPath;
35
+ const ledgerPath = this.paths.ledgerPath;
36
+ for (const row of mine(readJsonlRecords(snapPath, disclose), this.model.judgeRow, snapPath, "snapshot row")) {
24
37
  this.rows.set(this.model.keyOf(row), row);
25
- const events = readJsonlRecords(this.paths.ledgerPath);
38
+ }
39
+ const events = mine(readJsonlRecords(ledgerPath, disclose), this.model.judgeEvent, ledgerPath, "ledger event");
26
40
  this.events = events.length;
27
41
  for (const ev of events)
28
42
  this.model.apply(this.rows, ev);
@@ -1,4 +1,5 @@
1
1
  import type { ToolResultDeletionReport, ToolResultProvenance, ToolResultSlice, ToolResultStore } from "../../core/tool-result-store.js";
2
+ import { type CorruptReadInfo } from "./corrupt-read-sink.js";
2
3
  /**
3
4
  * design/80 §2.5 — file {@link ToolResultStore}: write-once-idempotent, ONE file per ref.
4
5
  *
@@ -26,9 +27,16 @@ import type { ToolResultDeletionReport, ToolResultProvenance, ToolResultSlice, T
26
27
  * is present but unreadable stops the write loudly, where treating it as "no owner" would report
27
28
  * success and leave the entry unclaimable forever.
28
29
  */
30
+ export interface FileToolResultStoreOptions {
31
+ /** Disclosure seat for an owner sidecar that is PRESENT but unreadable: {@link
32
+ * FileToolResultStore.ownerOf} answers `undefined` — the same word it uses for an entry nobody owns,
33
+ * so a host that authorizes on it reads damage as "unowned". Swallow-guarded. */
34
+ onCorruptRead?: (info: CorruptReadInfo) => void;
35
+ }
29
36
  export declare class FileToolResultStore implements ToolResultStore {
30
37
  private readonly dir;
31
- constructor(root: string);
38
+ private readonly discloseCorrupt;
39
+ constructor(root: string, opts?: FileToolResultStoreOptions);
32
40
  /** The directory a ref's files live in, and their shared stem. See {@link encodeRefFilename} for why
33
41
  * a FOLDED name gets its own directory instead of sharing one with identity names. */
34
42
  private locate;
@@ -3,12 +3,15 @@ import { readFileSync, readdirSync, unlinkSync } from "node:fs";
3
3
  import { join } from "node:path";
4
4
  import { assertSafeToolResultRef, assertToolResultProvenanceMatch, normalizeToolResultProvenance, ToolResultRefConflictError, } from "../../core/tool-result-store.js";
5
5
  import { ensureDir, sanitizePathComponent, writeThenLink } from "./fs-atomic.js";
6
+ import { guardCorruptSink } from "./corrupt-read-sink.js";
6
7
  import { assertAdoptionBootGate } from "./adoption/marker.js";
7
8
  export class FileToolResultStore {
8
9
  dir;
9
- constructor(root) {
10
+ discloseCorrupt;
11
+ constructor(root, opts) {
10
12
  assertAdoptionBootGate(root, "FileToolResultStore");
11
13
  this.dir = join(root, "tool-results");
14
+ this.discloseCorrupt = guardCorruptSink(opts?.onCorruptRead);
12
15
  ensureDir(this.dir);
13
16
  }
14
17
  locate(ref) {
@@ -59,15 +62,26 @@ export class FileToolResultStore {
59
62
  return this.readOwnerFile(ref, { loud: false });
60
63
  }
61
64
  readOwnerFile(ref, opts) {
65
+ const ownerPath = (() => {
66
+ try {
67
+ return this.ownerPathFor(ref);
68
+ }
69
+ catch {
70
+ return undefined;
71
+ }
72
+ })();
73
+ if (ownerPath === undefined)
74
+ return undefined;
62
75
  let raw;
63
76
  try {
64
- raw = readFileSync(this.ownerPathFor(ref), "utf8");
77
+ raw = readFileSync(ownerPath, "utf8");
65
78
  }
66
79
  catch (err) {
67
80
  if (err.code === "ENOENT")
68
81
  return undefined;
69
82
  if (opts.loud)
70
83
  throw err;
84
+ this.discloseCorrupt?.({ path: ownerPath, reason: `unreadable owner sidecar (${err.code ?? "unknown"}) — read as unowned` });
71
85
  return undefined;
72
86
  }
73
87
  const parsed = parseOwnerRecord(raw);
@@ -76,6 +90,7 @@ export class FileToolResultStore {
76
90
  if (opts.loud) {
77
91
  throw new Error(`tool-result store: owner record for ref ${JSON.stringify(ref)} is present but unreadable — refusing to write over it`);
78
92
  }
93
+ this.discloseCorrupt?.({ path: ownerPath, reason: "not-an-object: owner sidecar is present but is not an owner record — read as unowned" });
79
94
  return undefined;
80
95
  }
81
96
  writeOwnerFile(ref, provenance) {
@@ -1,4 +1,5 @@
1
1
  import { type WorkflowJournalEntry, type WorkflowJournalStore, type ResumeClaimArgs } from "../../core/workflow-journal-store.js";
2
+ import { type CorruptReadInfo } from "./corrupt-read-sink.js";
2
3
  /**
3
4
  * SVC-2 — the shared size bound for a journaled workflow-agent entry, so ALL backends answer an oversize
4
5
  * TaskResult with the SAME refusal (no dialect divergence). Inlined from service's `workflow-journal-limits` on
@@ -46,6 +47,12 @@ export declare const RESUME_CLAIM_SWEEP_INTERVAL_MS = 60000;
46
47
  * stays well clear of that handover rather than adding a second unlinker to it.
47
48
  */
48
49
  export declare const RESUME_CLAIM_REAP_GRACE_MS: number;
50
+ export interface FileWorkflowJournalStoreOptions {
51
+ /** Disclosure seat for a ledger line this store DROPPED on replay (not JSON, not a record, or a record
52
+ * with none of this ledger's members) — a resume that silently finds "nothing to replay" re-runs the
53
+ * work with no signal. Never fires on a missing ledger; swallow-guarded. */
54
+ onCorruptRead?: (info: CorruptReadInfo) => void;
55
+ }
49
56
  export declare class FileWorkflowJournalStore implements WorkflowJournalStore {
50
57
  private readonly fsyncEnabled;
51
58
  private readonly dir;
@@ -65,7 +72,9 @@ export declare class FileWorkflowJournalStore implements WorkflowJournalStore {
65
72
  * ledger per append (a journaled result can be MBs; a many-agent workflow would go O(n²) on disk). One replay
66
73
  * discovers it; every later append compares against the cache. */
67
74
  private get scopes();
68
- constructor(root: string, fsyncEnabled?: boolean);
75
+ /** The swallow-guarded corrupt-read seat (undefined = the old silent tolerance). */
76
+ private readonly discloseCorrupt;
77
+ constructor(root: string, fsyncEnabled?: boolean, opts?: FileWorkflowJournalStoreOptions);
69
78
  private pathFor;
70
79
  /** CC local form: the run's on-disk journal path for the notification diagnostics line.
71
80
  * Traversal-shaped ids (LLM-controlled on some paths) degrade to undefined, never throw. */
@@ -2,7 +2,8 @@ import { existsSync, mkdirSync, readFileSync, readdirSync, statSync, unlinkSync
2
2
  import { createHash } from "node:crypto";
3
3
  import { join } from "node:path";
4
4
  import { callKeyOrdinal } from "../../core/workflow-journal-store.js";
5
- import { AppendLog } from "./fs-atomic.js";
5
+ import { AppendLog, readJsonlRecords } from "./fs-atomic.js";
6
+ import { guardCorruptSink } from "./corrupt-read-sink.js";
6
7
  import { canonicalStoreKey, sanitizePathComponent, writeThenLink } from "./fs-atomic.js";
7
8
  import { assertAdoptionBootGate } from "./adoption/marker.js";
8
9
  import { assertJournalEntryFits } from "../../core/workflow-journal-store.js";
@@ -32,8 +33,10 @@ export class FileWorkflowJournalStore {
32
33
  get scopes() {
33
34
  return this.shared.scopes;
34
35
  }
35
- constructor(root, fsyncEnabled = true) {
36
+ discloseCorrupt;
37
+ constructor(root, fsyncEnabled = true, opts) {
36
38
  this.fsyncEnabled = fsyncEnabled;
39
+ this.discloseCorrupt = guardCorruptSink(opts?.onCorruptRead);
37
40
  assertAdoptionBootGate(root, "FileWorkflowJournalStore");
38
41
  this.dir = join(root, "workflow-journal");
39
42
  mkdirSync(this.dir, { recursive: true, mode: 0o700 });
@@ -72,18 +75,11 @@ export class FileWorkflowJournalStore {
72
75
  return undefined;
73
76
  let scope;
74
77
  const byOrdinal = new Map();
75
- for (const line of readFileSync(path, "utf8").split("\n")) {
76
- if (!line.trim())
78
+ for (const rec of readJsonlRecords(path, this.discloseCorrupt)) {
79
+ if (typeof rec.callKey !== "string" || typeof rec.scope !== "string" || !Number.isFinite(rec.ordinal)) {
80
+ this.discloseCorrupt?.({ path, reason: "missing-required: a journal line carries no callKey/scope pair (skipped; the rest of the replay is intact)" });
77
81
  continue;
78
- let rec;
79
- try {
80
- rec = JSON.parse(line);
81
82
  }
82
- catch {
83
- continue;
84
- }
85
- if (typeof rec.callKey !== "string" || typeof rec.scope !== "string")
86
- continue;
87
83
  scope = scope ?? rec.scope;
88
84
  if (rec.scope !== scope)
89
85
  continue;
@@ -1,10 +1,13 @@
1
1
  import { type WorkflowRunListByScopeOptions, type WorkflowRunStore, type WorkflowRunSummary } from "../../core/workflow-run-store.js";
2
2
  import type { WorkflowRun } from "../../orchestration/workflow.js";
3
+ import { type CorruptReadInfo } from "./corrupt-read-sink.js";
3
4
  export interface FileWorkflowRunStoreOptions {
4
5
  /** When false, a ledger append is NOT fsync'd (the observability store is non-engine-critical). Default true. */
5
6
  fsync?: boolean;
6
7
  /** Compact the ledger into a snapshot once it exceeds this many events (then truncate). Default 1000. */
7
8
  compactEvery?: number;
9
+ /** The observability ledger's seat; see {@link import("./shared-ledger.js").LedgerPaths.onCorruptRead}. */
10
+ onCorruptRead?: (info: CorruptReadInfo) => void;
8
11
  }
9
12
  export declare class FileWorkflowRunStore implements WorkflowRunStore {
10
13
  private readonly fsyncEnabled;
@@ -2,7 +2,9 @@ import { join } from "node:path";
2
2
  import { assertRetentionPolicy } from "../../core/retention-policy.js";
3
3
  import { WorkflowRunStoreError, isTerminalWorkflowStatus, nextWorkflowRunOnUpdate, queryWorkflowRuns, } from "../../core/workflow-run-store.js";
4
4
  import { SharedLedgerTable } from "./shared-ledger.js";
5
+ import { guardCorruptSink } from "./corrupt-read-sink.js";
5
6
  import { assertAdoptionBootGate } from "./adoption/marker.js";
7
+ const isRunRow = (x) => x !== null && typeof x === "object" && typeof x.id === "string";
6
8
  const runLedgers = new SharedLedgerTable({
7
9
  label: "workflow-run ledger",
8
10
  keyOf: (run) => run.id,
@@ -13,6 +15,13 @@ const runLedgers = new SharedLedgerTable({
13
15
  }
14
16
  runs.set(ev.id, ev.run);
15
17
  },
18
+ judgeRow: isRunRow,
19
+ judgeEvent: (x) => {
20
+ const ev = x;
21
+ if (typeof ev.id !== "string")
22
+ return false;
23
+ return ev.t === "delete" ? true : isRunRow(ev.run);
24
+ },
16
25
  });
17
26
  export class FileWorkflowRunStore {
18
27
  fsyncEnabled;
@@ -30,6 +39,7 @@ export class FileWorkflowRunStore {
30
39
  this.ledger = runLedgers.acquire({
31
40
  dir,
32
41
  tmpDir: join(root, "tmp"),
42
+ ...(opts.onCorruptRead !== undefined ? { onCorruptRead: guardCorruptSink(opts.onCorruptRead) } : {}),
33
43
  ledgerPath: join(dir, "runs.jsonl"),
34
44
  snapshotPath: join(dir, "runs.snapshot.jsonl"),
35
45
  });
@@ -1,7 +1,7 @@
1
1
  import { randomBytes } from "node:crypto";
2
2
  import { closeSync, existsSync, fsyncSync, mkdirSync, openSync, readFileSync, readdirSync, renameSync, rmSync, writeFileSync } from "node:fs";
3
3
  import { dirname, join, resolve, sep } from "node:path";
4
- import {} from "../../core/artifact-host.js";
4
+ import { ARTIFACT_HOST_FAILURE_CODES, } from "../../core/artifact-host.js";
5
5
  import { physicalPathOf } from "../../core/physical-path.js";
6
6
  import { acquireStoreDirLock } from "../../stores/file/fs-atomic.js";
7
7
  import { normalizePublishedPath } from "./artifact-tool.js";
@@ -25,6 +25,18 @@ const isRow = (x, key) => {
25
25
  return false;
26
26
  return r.files.every((f) => f !== null && typeof f === "object" && typeof f.path === "string" && normalizePublishedPath(f.path).ok && Number.isSafeInteger(f.bytes));
27
27
  };
28
+ const isAttempt = (x) => {
29
+ if (x === null || typeof x !== "object" || Array.isArray(x))
30
+ return false;
31
+ const a = x;
32
+ if ("ref" in a) {
33
+ const r = a.ref;
34
+ return r !== null && typeof r === "object" && typeof r.url === "string" && typeof r.slug === "string" && typeof r.version === "string";
35
+ }
36
+ if (typeof a.code !== "string" || !ARTIFACT_HOST_FAILURE_CODES.includes(a.code))
37
+ return false;
38
+ return a.code === "conflict" ? typeof a.slug === "string" && typeof a.currentVersion === "string" : typeof a.message === "string";
39
+ };
28
40
  function parseLedger(text, path) {
29
41
  let parsed;
30
42
  try {
@@ -40,6 +52,9 @@ function parseLedger(text, path) {
40
52
  for (const [key, row] of Object.entries(l.artifacts))
41
53
  if (!isRow(row, key))
42
54
  throw new Error(`artifact stub ledger is corrupt (bad artifact row): ${path}`);
55
+ for (const attempt of Object.values(l.attempts))
56
+ if (!isAttempt(attempt))
57
+ throw new Error(`artifact stub ledger is corrupt (bad attempt record): ${path}`);
43
58
  return { v: 1, artifacts: { ...l.artifacts }, attempts: { ...l.attempts } };
44
59
  }
45
60
  function fsyncDirBestEffort(dir) {
@@ -0,0 +1,48 @@
1
+ /**
2
+ * The tracked-cwd liveness probe and its diagnosis, shared by the three sites that spawn into the tracked
3
+ * logical cwd: the shell's foreground and background legs, and the watch tool's background leg. A second
4
+ * copy of either half would be a second answer to "is this directory usable?" and a second refusal
5
+ * wording for the same fact, so both are minted here once and each leg passes only its own tool name.
6
+ */
7
+ import type { ExecutionEnv } from "../../internal/harness-types.js";
8
+ /**
9
+ * Is `dir` USABLE as a spawn cwd? "Dead" is the whole class, not just deletion: gone, replaced by a FILE
10
+ * of the same name or standing under one, or a symlink that does not resolve to a directory. `fileInfo`
11
+ * is lstat-semantics, so a symlink is judged by FOLLOWING it (`canonicalPath`, then one probe of the
12
+ * target — a link retargeted at a file resolves perfectly well and is still not a cwd).
13
+ *
14
+ * The failure direction is deliberately asymmetric. Where a failure says nothing about the chain (the
15
+ * initial lstat, the target's own), an error means death only when it SAYS so — refusing a command, or
16
+ * resetting somebody's working directory, over one flaky stat would be worse than the failure being
17
+ * diagnosed. The RESOLUTION step is the exception: a chain that will not resolve is no directory by any
18
+ * reading; see {@link resolutionFailureMeansDead} for the one code that step reads as death on top.
19
+ */
20
+ export declare function isDeadCwd(env: ExecutionEnv, dir: string, signal?: AbortSignal): Promise<boolean>;
21
+ /**
22
+ * Dead-cwd diagnosis for a never-started command (spawn_error / shell_unavailable): a command that
23
+ * DELETED or REPLACED the tracked cwd (e.g. `opam clean` after `cd` into a build dir) leaves the
24
+ * persistent session pinned to a dead directory — every later spawn fails ENOENT/ENOTDIR with a message
25
+ * blaming the SHELL BINARY ("spawn /bin/bash ENOENT"), the model concludes bash is gone, and the run is
26
+ * permanently disabled (TB compile-compcert, 14 contaminated calls). The env can't distinguish this (spawn
27
+ * reports the same code either way), so probe here:
28
+ * - tracked cwd dead + task root alive → SELF-HEAL: reset the tracked cwd to the root, tell the model
29
+ * what happened and that a retry works;
30
+ * - tracked cwd dead + root ALSO dead (never promise a retry that must fail) → honest
31
+ * unrecoverable verdict;
32
+ * - cwd IS the root and it's dead (`rm -rf "$PWD"` in place — a diverged-guard would
33
+ * skip diagnosis entirely) → honest root-gone verdict (no reset target exists);
34
+ * - the caller knows NO root (`root === undefined` — a direct mount that was never handed one) → say the
35
+ * cwd is gone and that this tool has no reset target, and stop there. Calling that directory "the task
36
+ * root" or declaring the tree unrecoverable would both be claims the caller cannot back: the root may
37
+ * well be alive, and a `cd` to it recovers. Nothing is reset.
38
+ * - cwd healthy → return undefined (the spawn failure has another cause; keep the original error).
39
+ * Returns the model-facing message, or undefined when this is not a dead-cwd case. Uses only the public
40
+ * env faces (`fileInfo`/`canonicalPath`) — works for any env that maps never-started failures to
41
+ * spawn_error/shell_unavailable (contract note on ExecutionErrorCode).
42
+ *
43
+ * `root` is REQUIRED-but-nullable on purpose (not an optional parameter): a caller without a root has to
44
+ * say so at the call site, so "this mount has no root" can never be spelled the same way as "I forgot".
45
+ */
46
+ export declare function diagnoseDeadCwd(env: ExecutionEnv, toolName: string, effectiveCwd: string, root: string | undefined, cwdRef?: {
47
+ current: string;
48
+ }, signal?: AbortSignal): Promise<string | undefined>;