@mono-agent/agent-app 0.10.0 → 0.11.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.
@@ -1,19 +1,30 @@
1
1
  import { randomBytes, randomUUID } from "node:crypto";
2
+ import { constants as fsConstants } from "node:fs";
2
3
  import { chmod, lstat, mkdir, open, readdir, readFile, rename, rm, writeFile, } from "node:fs/promises";
3
4
  import { dirname, join } from "node:path";
4
- import { canonicalContinuationJson, continuationDigest, isContinuationMode, isContinuationState, } from "./continuations.js";
5
+ import { AGENT_CONTINUATION_ORIGIN_CONTEXT_MAX_BYTES, AGENT_CONTINUATION_ORIGIN_CONTEXT_MAX_MESSAGE_BYTES, AGENT_CONTINUATION_ORIGIN_CONTEXT_MAX_MESSAGES, assertAgentContinuationOriginContext, } from "@mono-agent/agent-contracts";
6
+ import { canonicalContinuationJson, continuationDigest, isContinuationMode, isContinuationState, TERMINAL_CONTINUATION_STATES, } from "./continuations.js";
5
7
  export const CONTINUATION_STORE_SCHEMA_VERSION = 1;
6
- export const CONTINUATION_RECORD_STORE_SCHEMA_VERSION = 2;
7
- const RECORDS_DIRECTORY = "records-v2";
8
- const TRANSACTION_FILE = "continuation-transaction-v2.json";
9
- const MANIFEST_FILE = "continuation-store-v2.json";
8
+ export const CONTINUATION_RECORD_STORE_SCHEMA_VERSION = 3;
9
+ const RECORDS_DIRECTORY = "records-v3";
10
+ const TRANSACTION_FILE = "continuation-transaction-v3.json";
11
+ const MANIFEST_FILE = "continuation-store-v3.json";
12
+ const LEGACY_RECORDS_DIRECTORY = "records-v2";
13
+ const LEGACY_TRANSACTION_FILE = "continuation-transaction-v2.json";
14
+ const V2_ROLLBACK_GUARD = "UPGRADED-TO-RECORDS-V3";
15
+ const ORIGIN_CONTEXT_GROUPS_DIRECTORY = "origin-context-groups-v1";
10
16
  const OWNER_DATABASE_FILE = "continuations-owner.sqlite";
17
+ const ORIGIN_CONTEXTS_DIRECTORY = "origin-context-v1";
11
18
  const MAX_RECORD_BYTES = 2 * 1024 * 1024;
12
19
  const MAX_TRANSACTION_BYTES = 16 * 1024 * 1024;
13
20
  const DEFAULT_TERMINAL_MAX_RECORDS = 50_000;
14
21
  const DEFAULT_TERMINAL_MAX_AGE_MS = 365 * 24 * 60 * 60 * 1_000;
15
22
  const DEFAULT_CAPTURED_TEXT_MAX_RECORDS = 1_000;
16
23
  const DEFAULT_CAPTURED_TEXT_MAX_AGE_MS = 30 * 24 * 60 * 60 * 1_000;
24
+ export const MAX_CONTINUATION_ORIGIN_CONTEXT_BYTES = AGENT_CONTINUATION_ORIGIN_CONTEXT_MAX_BYTES;
25
+ export const MAX_CONTINUATION_ORIGIN_CONTEXT_MESSAGES = AGENT_CONTINUATION_ORIGIN_CONTEXT_MAX_MESSAGES;
26
+ export const MAX_CONTINUATION_ORIGIN_CONTEXT_MESSAGE_BYTES = AGENT_CONTINUATION_ORIGIN_CONTEXT_MAX_MESSAGE_BYTES;
27
+ export const MAX_CONTINUATION_ORIGIN_CONTEXT_STORE_BYTES = 256 * 1024 * 1024;
17
28
  /**
18
29
  * Claim exclusive ownership of a continuation state directory for this process.
19
30
  * SQLite holds an OS-backed exclusive transaction for the process lifetime.
@@ -88,37 +99,75 @@ export async function openContinuationStore(stateDir, options = {}) {
88
99
  await ensureOwnerOnlyDirectory(stateDir);
89
100
  const recordsDir = join(stateDir, RECORDS_DIRECTORY);
90
101
  await ensureOwnerOnlyDirectory(recordsDir);
102
+ const legacyRecordsDir = join(stateDir, LEGACY_RECORDS_DIRECTORY);
103
+ await ensureOwnerOnlyDirectory(legacyRecordsDir);
104
+ const originContextsDir = join(stateDir, ORIGIN_CONTEXTS_DIRECTORY);
105
+ await ensureOwnerOnlyDirectory(originContextsDir);
106
+ const originContextGroupsDir = join(stateDir, ORIGIN_CONTEXT_GROUPS_DIRECTORY);
107
+ await ensureOwnerOnlyDirectory(originContextGroupsDir);
91
108
  const transactionPath = join(stateDir, TRANSACTION_FILE);
92
109
  const manifestPath = join(stateDir, MANIFEST_FILE);
93
110
  const legacyPath = join(stateDir, "continuations-v1.json");
111
+ const legacyTransactionPath = join(stateDir, LEGACY_TRANSACTION_FILE);
112
+ const rollbackGuardPath = join(legacyRecordsDir, V2_ROLLBACK_GUARD);
94
113
  const policy = resolveRetention(options.retention);
95
114
  const now = options.now ?? (() => new Date());
96
- const recoveredGeneration = await recoverRecordTransaction(recordsDir, transactionPath);
115
+ const manifestExists = await continuationPathExists(manifestPath);
116
+ if (manifestExists)
117
+ await assertV3Manifest(manifestPath);
118
+ // Finish any v2 transaction before installing the rollback guard. Once the
119
+ // guard exists, v0.10 fails closed while v3 deliberately leaves the v2/v1
120
+ // evidence untouched for audit and manual recovery.
121
+ if (!await continuationPathExists(rollbackGuardPath)) {
122
+ await recoverRecordTransaction(legacyRecordsDir, legacyTransactionPath, 2);
123
+ }
124
+ const recoveredGeneration = await recoverRecordTransaction(recordsDir, transactionPath, CONTINUATION_RECORD_STORE_SCHEMA_VERSION);
97
125
  const records = await loadRecordDirectory(recordsDir);
98
126
  const beforeOpen = cloneRecords(records);
99
- const legacyExists = await continuationPathExists(legacyPath);
100
- if (legacyExists) {
101
- const legacy = await loadLegacyStore(legacyPath);
102
- for (const [id, record] of legacy) {
103
- const current = records.get(id);
104
- if (current === undefined) {
105
- records.set(id, record);
106
- }
107
- else if (canonicalContinuationJson(current) !== canonicalContinuationJson(record)) {
108
- throw new Error(`Legacy and v2 continuation records conflict for id ${id}; refusing lossy migration.`);
109
- }
127
+ normalizeLegacyContinuationRecords(records);
128
+ if (!manifestExists) {
129
+ const migrationSource = await loadRecordDirectory(legacyRecordsDir, new Set([V2_ROLLBACK_GUARD]));
130
+ normalizeLegacyContinuationRecords(migrationSource);
131
+ if (await continuationPathExists(legacyPath)) {
132
+ const legacy = await loadLegacyStore(legacyPath);
133
+ normalizeLegacyContinuationRecords(legacy);
134
+ mergeMigrationRecords(migrationSource, legacy, "v1 and v2");
135
+ }
136
+ // Install the old-reader poison before the first v3 record becomes active.
137
+ // A crash on either side is restart-safe: v3 repeats a semantic merge, and
138
+ // v0.10 cannot start against a stale v2 snapshot.
139
+ if (!await continuationPathExists(rollbackGuardPath)) {
140
+ await writeTextAtomic(rollbackGuardPath, "This state directory uses continuation records v3. Older runtimes must not open records-v2.\n", 4 * 1024);
110
141
  }
142
+ mergeMigrationRecords(records, migrationSource, "v2 and v3");
111
143
  }
144
+ const committedOriginGroups = await applyOriginContextGroupCommits(originContextGroupsDir, records);
112
145
  applyRetention(records, policy, now());
113
146
  const migrationGeneration = await persistRecordChanges(recordsDir, transactionPath, beforeOpen, records);
114
- if (legacyExists) {
115
- await rm(legacyPath, { force: true });
116
- await syncDirectory(stateDir);
117
- }
118
147
  let generation = migrationGeneration ?? recoveredGeneration ?? randomUUID();
119
148
  await persistManifest(manifestPath, generation, continuationStoreStats(records, policy), now());
149
+ await removeOriginContextGroupCommits(originContextGroupsDir, committedOriginGroups);
150
+ await sweepOriginContextBlobs(originContextsDir, referencedOriginContextDigests(records), new Set());
120
151
  let tail = Promise.resolve();
152
+ let originTail = Promise.resolve();
153
+ // Multiple capabilities from one origin run intentionally stage the same
154
+ // content-addressed snapshot concurrently. Track leases, not just presence:
155
+ // one failed/finalized caller must not sweep the blob while another caller
156
+ // still owns an uncommitted pin for the same digest.
157
+ const pendingOriginPins = new Map();
121
158
  let poisoned;
159
+ async function withOriginLock(operation) {
160
+ const previous = originTail;
161
+ let release;
162
+ originTail = new Promise((resolve) => { release = resolve; });
163
+ await previous;
164
+ try {
165
+ return await operation();
166
+ }
167
+ finally {
168
+ release();
169
+ }
170
+ }
122
171
  async function locked(operation) {
123
172
  const previous = tail;
124
173
  let release;
@@ -145,11 +194,14 @@ export async function openContinuationStore(stateDir, options = {}) {
145
194
  generation = committedGeneration;
146
195
  replaceRecords(records, draft);
147
196
  await persistManifest(manifestPath, generation, continuationStoreStats(records, policy), now());
197
+ await withOriginLock(async () => {
198
+ await sweepOriginContextBlobs(originContextsDir, referencedOriginContextDigests(records), new Set(pendingOriginPins.keys()));
199
+ });
148
200
  return result;
149
201
  }
150
202
  catch (error) {
151
203
  try {
152
- const recovered = await recoverRecordTransaction(recordsDir, transactionPath);
204
+ const recovered = await recoverRecordTransaction(recordsDir, transactionPath, CONTINUATION_RECORD_STORE_SCHEMA_VERSION);
153
205
  if (recovered !== undefined)
154
206
  generation = recovered;
155
207
  replaceRecords(records, await loadRecordDirectory(recordsDir));
@@ -165,6 +217,86 @@ export async function openContinuationStore(stateDir, options = {}) {
165
217
  release();
166
218
  }
167
219
  }
220
+ async function activateOriginContextGroup(input) {
221
+ if (!requiredString(input.claimFingerprint) || !requiredDate(input.activatedAt)) {
222
+ throw new Error("Continuation origin-context activation has an invalid claim or timestamp.");
223
+ }
224
+ const previous = tail;
225
+ let release;
226
+ tail = new Promise((resolve) => { release = resolve; });
227
+ await previous;
228
+ if (poisoned !== undefined) {
229
+ release();
230
+ throw new Error("Continuation store requires restart after a failed durable transaction.", { cause: poisoned });
231
+ }
232
+ const before = cloneRecords(records);
233
+ const draft = cloneRecords(records);
234
+ let commit;
235
+ let markerPath;
236
+ let published = false;
237
+ try {
238
+ commit = prepareOriginContextGroupCommit(draft, input);
239
+ if (commit === undefined)
240
+ return;
241
+ markerPath = join(originContextGroupsDir, `${commit.groupKey}.json`);
242
+ if (await continuationPathExists(markerPath)) {
243
+ const existing = await loadOriginContextGroupCommit(markerPath);
244
+ if (canonicalContinuationJson(existing) !== canonicalContinuationJson(commit)) {
245
+ throw new Error("Continuation origin-context group activation conflicts with an existing commit marker.");
246
+ }
247
+ }
248
+ else {
249
+ // The marker is the semantic commit point. It is deliberately compact:
250
+ // the member-set digest makes one fsync atomic for groups whose record
251
+ // materialization spans arbitrarily many bounded transaction batches.
252
+ await writeJsonAtomic(markerPath, commit, true, 64 * 1024);
253
+ }
254
+ published = true;
255
+ applyOriginContextGroupCommit(draft, commit);
256
+ replaceRecords(records, draft);
257
+ try {
258
+ const committedGeneration = await persistRecordChanges(recordsDir, transactionPath, before, draft);
259
+ if (committedGeneration !== undefined)
260
+ generation = committedGeneration;
261
+ await persistManifest(manifestPath, generation, continuationStoreStats(records, policy), now());
262
+ await rm(markerPath, { force: true });
263
+ await syncDirectory(originContextGroupsDir);
264
+ }
265
+ catch (materializationError) {
266
+ // Publication already committed. Recover the bounded materialization if
267
+ // possible and keep the group marker as the restart-time source of
268
+ // truth. Never report an ambiguous activation failure to a caller that
269
+ // might then abandon an already-published group.
270
+ try {
271
+ const recovered = await recoverRecordTransaction(recordsDir, transactionPath, CONTINUATION_RECORD_STORE_SCHEMA_VERSION);
272
+ if (recovered !== undefined)
273
+ generation = recovered;
274
+ const recoveredRecords = await loadRecordDirectory(recordsDir);
275
+ normalizeLegacyContinuationRecords(recoveredRecords);
276
+ applyOriginContextGroupCommit(recoveredRecords, commit);
277
+ replaceRecords(records, recoveredRecords);
278
+ }
279
+ catch (recoveryError) {
280
+ poisoned = new AggregateError([materializationError, recoveryError], "Continuation origin-context activation committed but requires restart to recover.");
281
+ }
282
+ poisoned ??= materializationError;
283
+ }
284
+ await withOriginLock(async () => {
285
+ await sweepOriginContextBlobs(originContextsDir, referencedOriginContextDigests(records), new Set(pendingOriginPins.keys()));
286
+ });
287
+ }
288
+ catch (error) {
289
+ if (!published)
290
+ throw error;
291
+ // The fsynced marker means activation is no longer ambiguous. Preserve
292
+ // that success contract and force a restart for any unexpected local
293
+ // maintenance failure after the commit point.
294
+ poisoned ??= error;
295
+ }
296
+ finally {
297
+ release();
298
+ }
299
+ }
168
300
  return {
169
301
  path: manifestPath,
170
302
  async get(id) {
@@ -186,6 +318,74 @@ export async function openContinuationStore(stateDir, options = {}) {
186
318
  await tail;
187
319
  return continuationStoreStats(records, policy);
188
320
  },
321
+ async stageOriginContext(snapshot) {
322
+ assertAgentContinuationOriginContext(snapshot);
323
+ const canonical = canonicalContinuationJson(snapshot);
324
+ const bytes = Buffer.byteLength(canonical, "utf8");
325
+ if (snapshot.messages.length > MAX_CONTINUATION_ORIGIN_CONTEXT_MESSAGES) {
326
+ throw new Error(`Continuation origin context exceeds its ${String(MAX_CONTINUATION_ORIGIN_CONTEXT_MESSAGES)} message limit.`);
327
+ }
328
+ if (snapshot.messages.some((message) => Buffer.byteLength(message.content, "utf8") > MAX_CONTINUATION_ORIGIN_CONTEXT_MESSAGE_BYTES)) {
329
+ throw new Error(`Continuation origin context contains a message over its ${String(MAX_CONTINUATION_ORIGIN_CONTEXT_MESSAGE_BYTES)} byte limit.`);
330
+ }
331
+ if (bytes > MAX_CONTINUATION_ORIGIN_CONTEXT_BYTES) {
332
+ throw new Error(`Continuation origin context exceeds its ${String(MAX_CONTINUATION_ORIGIN_CONTEXT_BYTES)} byte limit.`);
333
+ }
334
+ const digest = originContextDigest(canonical);
335
+ const reference = { schemaVersion: 1, digest, bytes, messageCount: snapshot.messages.length };
336
+ pendingOriginPins.set(digest, (pendingOriginPins.get(digest) ?? 0) + 1);
337
+ try {
338
+ await withOriginLock(async () => {
339
+ const path = join(originContextsDir, `${digest}.json`);
340
+ if (await continuationPathExists(path)) {
341
+ const existing = await readOriginContextCanonical(path, reference);
342
+ if (existing !== canonical)
343
+ throw new Error("Continuation origin context digest collision or content conflict.");
344
+ return;
345
+ }
346
+ const aggregate = await originContextStoreBytes(originContextsDir);
347
+ if (aggregate + bytes > MAX_CONTINUATION_ORIGIN_CONTEXT_STORE_BYTES) {
348
+ throw new Error(`Continuation origin context store exceeds its ${String(MAX_CONTINUATION_ORIGIN_CONTEXT_STORE_BYTES)} byte quota.`);
349
+ }
350
+ await writeTextAtomic(path, canonical, MAX_CONTINUATION_ORIGIN_CONTEXT_BYTES);
351
+ });
352
+ }
353
+ catch (error) {
354
+ releasePendingOriginPin(pendingOriginPins, digest);
355
+ throw error;
356
+ }
357
+ let released = false;
358
+ return {
359
+ reference,
360
+ async release() {
361
+ if (released)
362
+ return;
363
+ released = true;
364
+ releasePendingOriginPin(pendingOriginPins, digest);
365
+ await withOriginLock(async () => {
366
+ await sweepOriginContextBlobs(originContextsDir, referencedOriginContextDigests(records), new Set(pendingOriginPins.keys()));
367
+ });
368
+ },
369
+ };
370
+ },
371
+ async loadOriginContext(reference) {
372
+ if (!isOriginContextReference(reference))
373
+ return undefined;
374
+ return await withOriginLock(async () => {
375
+ const path = join(originContextsDir, `${reference.digest}.json`);
376
+ try {
377
+ const canonical = await readOriginContextCanonical(path, reference);
378
+ return JSON.parse(canonical);
379
+ }
380
+ catch (error) {
381
+ if (isMissing(error) || error instanceof SyntaxError || error instanceof OriginContextCorruptionError) {
382
+ return undefined;
383
+ }
384
+ throw error;
385
+ }
386
+ });
387
+ },
388
+ activateOriginContextGroup,
189
389
  mutate: locked,
190
390
  };
191
391
  }
@@ -273,11 +473,15 @@ async function loadLegacyStore(path) {
273
473
  }
274
474
  return new Map(Object.entries(parsed.records).map(([id, record]) => [id, record]));
275
475
  }
276
- async function loadRecordDirectory(path) {
476
+ async function loadRecordDirectory(path, ignoredEntries = new Set()) {
277
477
  const records = new Map();
278
478
  let removedTemporary = false;
279
479
  for (const entry of await readdir(path, { withFileTypes: true })) {
280
480
  const filePath = join(path, entry.name);
481
+ if (ignoredEntries.has(entry.name)) {
482
+ await assertOwnerOnlyRegularFile(filePath, "Continuation migration guard");
483
+ continue;
484
+ }
281
485
  if (entry.name.startsWith(".") && entry.name.endsWith(".tmp")) {
282
486
  if (!entry.isFile() || entry.isSymbolicLink()) {
283
487
  throw new Error(`Continuation temporary record is not a regular file: ${filePath}`);
@@ -314,6 +518,166 @@ async function loadRecordDirectory(path) {
314
518
  await syncDirectory(path);
315
519
  return records;
316
520
  }
521
+ function mergeMigrationRecords(target, source, label) {
522
+ // Both sides must be normalized before the semantic comparison. Otherwise a
523
+ // crash after persisting defaults (for example synthesisDeferrals=0) makes a
524
+ // restart falsely report a migration conflict against the equivalent v1/v2
525
+ // representation that omitted those fields.
526
+ normalizeLegacyContinuationRecords(target);
527
+ normalizeLegacyContinuationRecords(source);
528
+ for (const [id, record] of source) {
529
+ const current = target.get(id);
530
+ if (current === undefined) {
531
+ target.set(id, structuredClone(record));
532
+ }
533
+ else if (canonicalContinuationJson(current) !== canonicalContinuationJson(record)) {
534
+ throw new Error(`${label} continuation records conflict for id ${id}; refusing lossy migration.`);
535
+ }
536
+ }
537
+ }
538
+ async function assertV3Manifest(path) {
539
+ const raw = await readBoundedOwnerOnlyFile(path, 1024 * 1024, "Continuation v3 manifest");
540
+ let value;
541
+ try {
542
+ value = JSON.parse(raw);
543
+ }
544
+ catch (error) {
545
+ throw new Error(`Continuation v3 manifest contains invalid JSON: ${path}`, { cause: error });
546
+ }
547
+ if (!isObject(value)
548
+ || value.schemaVersion !== CONTINUATION_RECORD_STORE_SCHEMA_VERSION
549
+ || !requiredString(value.generation)
550
+ || !requiredDate(value.updatedAt)
551
+ || !isObject(value.stats)) {
552
+ throw new Error(`Continuation v3 manifest has a malformed schema: ${path}`);
553
+ }
554
+ }
555
+ function prepareOriginContextGroupCommit(records, input) {
556
+ const seeds = [...records.values()].filter((record) => record.claimFingerprint === input.claimFingerprint);
557
+ if (seeds.length === 0)
558
+ return undefined;
559
+ const seed = seeds[0];
560
+ if (seed.originContextState === "detached_latest")
561
+ return undefined;
562
+ if (seed.historyBoundary === undefined) {
563
+ throw new Error("A pinned continuation origin group must have an immutable history boundary.");
564
+ }
565
+ const candidates = [...records.values()].filter((record) => record.originRunId === seed.originRunId
566
+ && record.originConversationId === seed.originConversationId
567
+ && record.historyBoundary === seed.historyBoundary
568
+ && !TERMINAL_CONTINUATION_STATES.has(record.state));
569
+ if (candidates.length === 0)
570
+ return undefined;
571
+ const digests = new Set();
572
+ for (const record of candidates) {
573
+ if ((record.originContextState !== "pending" && record.originContextState !== "pinned")
574
+ || record.originContextRef === undefined
575
+ || record.originContextDigest !== record.originContextRef.digest
576
+ || record.originContextBindingMac === undefined) {
577
+ throw new Error("Continuation origin context was not durably prepared for activation.");
578
+ }
579
+ digests.add(record.originContextRef.digest);
580
+ }
581
+ if (digests.size !== 1) {
582
+ throw new Error("Continuation origin claims were prepared with conflicting snapshots.");
583
+ }
584
+ if (candidates.every((record) => record.originContextState === "pinned"))
585
+ return undefined;
586
+ const snapshotDigest = [...digests][0];
587
+ if (snapshotDigest === undefined)
588
+ throw new Error("Continuation origin group has no snapshot digest.");
589
+ const memberIds = candidates.map((record) => record.continuationId).sort();
590
+ const groupIdentity = {
591
+ originRunId: seed.originRunId,
592
+ originConversationId: seed.originConversationId,
593
+ historyBoundary: seed.historyBoundary,
594
+ };
595
+ return {
596
+ schemaVersion: 1,
597
+ groupKey: continuationDigest(`mono-agent-origin-context-group-v1\0${canonicalContinuationJson(groupIdentity)}`),
598
+ ...groupIdentity,
599
+ snapshotDigest,
600
+ memberCount: memberIds.length,
601
+ memberSetDigest: continuationDigest(`mono-agent-origin-context-members-v1\0${canonicalContinuationJson(memberIds)}`),
602
+ activatedAt: input.activatedAt,
603
+ };
604
+ }
605
+ function applyOriginContextGroupCommit(records, commit) {
606
+ const candidates = [...records.values()].filter((record) => record.originRunId === commit.originRunId
607
+ && record.originConversationId === commit.originConversationId
608
+ && record.historyBoundary === commit.historyBoundary
609
+ && !TERMINAL_CONTINUATION_STATES.has(record.state));
610
+ const memberIds = candidates.map((record) => record.continuationId).sort();
611
+ const memberSetDigest = continuationDigest(`mono-agent-origin-context-members-v1\0${canonicalContinuationJson(memberIds)}`);
612
+ if (candidates.length !== commit.memberCount || memberSetDigest !== commit.memberSetDigest) {
613
+ throw new Error("Continuation origin-context group commit member set does not match durable records.");
614
+ }
615
+ for (const record of candidates) {
616
+ if ((record.originContextState !== "pending" && record.originContextState !== "pinned")
617
+ || record.originContextRef?.digest !== commit.snapshotDigest
618
+ || record.originContextDigest !== commit.snapshotDigest
619
+ || record.originContextBindingMac === undefined) {
620
+ throw new Error("Continuation origin-context group commit does not match its prepared records.");
621
+ }
622
+ }
623
+ for (const record of candidates) {
624
+ if (record.originContextState === "pinned")
625
+ continue;
626
+ record.originContextState = "pinned";
627
+ record.updatedAt = commit.activatedAt;
628
+ if (record.lastError?.code === "origin_context_pending")
629
+ delete record.lastError;
630
+ delete record.nextAttemptAt;
631
+ }
632
+ }
633
+ async function applyOriginContextGroupCommits(directory, records) {
634
+ const applied = [];
635
+ let removedTemporary = false;
636
+ for (const entry of await readdir(directory, { withFileTypes: true })) {
637
+ const path = join(directory, entry.name);
638
+ if (entry.name.startsWith(".") && entry.name.endsWith(".tmp")) {
639
+ if (!entry.isFile() || entry.isSymbolicLink()) {
640
+ throw new Error(`Continuation origin-context group temporary is not a regular file: ${path}`);
641
+ }
642
+ await rm(path, { force: true });
643
+ removedTemporary = true;
644
+ continue;
645
+ }
646
+ if (!/^[a-f0-9]{64}\.json$/u.test(entry.name) || !entry.isFile() || entry.isSymbolicLink()) {
647
+ throw new Error(`Unexpected entry in continuation origin-context group directory: ${path}`);
648
+ }
649
+ const commit = await loadOriginContextGroupCommit(path);
650
+ if (`${commit.groupKey}.json` !== entry.name) {
651
+ throw new Error(`Continuation origin-context group filename does not match its key: ${path}`);
652
+ }
653
+ applyOriginContextGroupCommit(records, commit);
654
+ applied.push(path);
655
+ }
656
+ if (removedTemporary)
657
+ await syncDirectory(directory);
658
+ return applied;
659
+ }
660
+ async function loadOriginContextGroupCommit(path) {
661
+ const raw = await readBoundedOwnerOnlyFile(path, 64 * 1024, "Continuation origin-context group commit");
662
+ let value;
663
+ try {
664
+ value = JSON.parse(raw);
665
+ }
666
+ catch (error) {
667
+ throw new Error(`Continuation origin-context group commit contains invalid JSON: ${path}`, { cause: error });
668
+ }
669
+ if (!isOriginContextGroupCommit(value)) {
670
+ throw new Error(`Continuation origin-context group commit has a malformed schema: ${path}`);
671
+ }
672
+ return value;
673
+ }
674
+ async function removeOriginContextGroupCommits(directory, paths) {
675
+ if (paths.length === 0)
676
+ return;
677
+ for (const path of paths)
678
+ await rm(path, { force: true });
679
+ await syncDirectory(directory);
680
+ }
317
681
  async function persistRecordChanges(recordsDir, transactionPath, before, after) {
318
682
  const writes = [...after.values()].filter((record) => {
319
683
  const prior = before.get(record.continuationId);
@@ -332,7 +696,7 @@ async function persistRecordChanges(recordsDir, transactionPath, before, after)
332
696
  }
333
697
  return generation;
334
698
  }
335
- async function recoverRecordTransaction(recordsDir, transactionPath) {
699
+ async function recoverRecordTransaction(recordsDir, transactionPath, expectedSchemaVersion) {
336
700
  if (!await continuationPathExists(transactionPath))
337
701
  return undefined;
338
702
  const raw = await readBoundedOwnerOnlyFile(transactionPath, MAX_TRANSACTION_BYTES, "Continuation transaction");
@@ -343,7 +707,7 @@ async function recoverRecordTransaction(recordsDir, transactionPath) {
343
707
  catch (error) {
344
708
  throw new Error(`Continuation transaction contains invalid JSON: ${transactionPath}`, { cause: error });
345
709
  }
346
- if (!isRecordTransaction(value)) {
710
+ if (!isRecordTransaction(value, expectedSchemaVersion)) {
347
711
  throw new Error(`Continuation transaction has a malformed schema: ${transactionPath}`);
348
712
  }
349
713
  await applyRecordTransaction(recordsDir, value);
@@ -392,6 +756,142 @@ async function writeJsonAtomic(path, value, syncParent = true, maxBytes = Number
392
756
  await rm(temporary, { force: true }).catch(() => undefined);
393
757
  }
394
758
  }
759
+ async function writeTextAtomic(path, body, maxBytes) {
760
+ if (Buffer.byteLength(body, "utf8") > maxBytes) {
761
+ throw new Error(`Durable continuation file exceeds its ${String(maxBytes)} byte safety limit: ${path}`);
762
+ }
763
+ const temporary = join(dirname(path), `.${continuationDigest(path).slice(0, 12)}-${process.pid}-${randomUUID()}.tmp`);
764
+ let handle;
765
+ try {
766
+ handle = await open(temporary, "wx", 0o600);
767
+ await handle.writeFile(body, "utf8");
768
+ await handle.sync();
769
+ await handle.close();
770
+ handle = undefined;
771
+ await rename(temporary, path);
772
+ if (process.platform !== "win32")
773
+ await chmod(path, 0o600);
774
+ await syncDirectory(dirname(path));
775
+ }
776
+ finally {
777
+ await handle?.close().catch(() => undefined);
778
+ await rm(temporary, { force: true }).catch(() => undefined);
779
+ }
780
+ }
781
+ class OriginContextCorruptionError extends Error {
782
+ }
783
+ function originContextDigest(canonical) {
784
+ return continuationDigest(`mono-agent-origin-context-v1\0${canonical}`);
785
+ }
786
+ async function readOriginContextCanonical(path, reference) {
787
+ let canonical;
788
+ try {
789
+ const loaded = await readBoundedOwnerOnlyFileWithStats(path, MAX_CONTINUATION_ORIGIN_CONTEXT_BYTES, "Continuation origin context");
790
+ if (loaded.bytes !== reference.bytes) {
791
+ throw new OriginContextCorruptionError("Continuation origin context size does not match its reference.");
792
+ }
793
+ canonical = loaded.text;
794
+ }
795
+ catch (error) {
796
+ if (error instanceof OriginContextCorruptionError || isMissing(error))
797
+ throw error;
798
+ throw new OriginContextCorruptionError("Continuation origin context is not a safe owner-only file.", { cause: error });
799
+ }
800
+ if (Buffer.byteLength(canonical, "utf8") !== reference.bytes
801
+ || originContextDigest(canonical) !== reference.digest) {
802
+ throw new OriginContextCorruptionError("Continuation origin context digest does not match its reference.");
803
+ }
804
+ let parsed;
805
+ try {
806
+ parsed = JSON.parse(canonical);
807
+ }
808
+ catch (error) {
809
+ throw new OriginContextCorruptionError("Continuation origin context is not valid JSON.", { cause: error });
810
+ }
811
+ if (canonicalContinuationJson(parsed) !== canonical) {
812
+ throw new OriginContextCorruptionError("Continuation origin context is not canonically encoded.");
813
+ }
814
+ try {
815
+ assertAgentContinuationOriginContext(parsed);
816
+ }
817
+ catch (error) {
818
+ throw new OriginContextCorruptionError("Continuation origin context has an invalid schema.", { cause: error });
819
+ }
820
+ if (parsed.messages.length !== reference.messageCount) {
821
+ throw new OriginContextCorruptionError("Continuation origin context message count does not match its reference.");
822
+ }
823
+ return canonical;
824
+ }
825
+ function referencedOriginContextDigests(records) {
826
+ return new Set([...records.values()].flatMap((record) => record.originContextRef === undefined ? [] : [record.originContextRef.digest]));
827
+ }
828
+ function releasePendingOriginPin(pins, digest) {
829
+ const count = pins.get(digest);
830
+ if (count === undefined)
831
+ return;
832
+ if (count <= 1)
833
+ pins.delete(digest);
834
+ else
835
+ pins.set(digest, count - 1);
836
+ }
837
+ async function sweepOriginContextBlobs(directory, referenced, pending) {
838
+ let changed = false;
839
+ for (const entry of await readdir(directory, { withFileTypes: true })) {
840
+ const path = join(directory, entry.name);
841
+ if (entry.name.startsWith(".") && entry.name.endsWith(".tmp")) {
842
+ if (!entry.isFile() && !entry.isSymbolicLink()) {
843
+ throw new Error(`Continuation origin-context temporary is not a regular file: ${path}`);
844
+ }
845
+ await rm(path, { force: true });
846
+ changed = true;
847
+ continue;
848
+ }
849
+ const match = /^([a-f0-9]{64})\.json$/u.exec(entry.name);
850
+ if (match?.[1] !== undefined && !referenced.has(match[1]) && !pending.has(match[1])) {
851
+ // Once no durable record references a blob, unlink it without opening or
852
+ // following it. This also lets the safe-fallback path clean up a blob
853
+ // whose mode/identity was corrupted instead of poisoning record storage.
854
+ await rm(path, { force: true, recursive: true });
855
+ changed = true;
856
+ continue;
857
+ }
858
+ if (match?.[1] === undefined) {
859
+ throw new Error(`Unexpected entry in continuation origin-context directory: ${path}`);
860
+ }
861
+ // A referenced blob is untrusted payload, not store metadata. Do not let a
862
+ // corrupt mode, hard link, symlink, or non-file poison startup; the
863
+ // descriptor-stable load path will classify it as unavailable and the
864
+ // service can emit its deterministic zero-model fallback.
865
+ if (!entry.isFile() || entry.isSymbolicLink())
866
+ continue;
867
+ try {
868
+ await assertOwnerOnlyRegularFile(path, "Continuation origin context");
869
+ }
870
+ catch {
871
+ continue;
872
+ }
873
+ }
874
+ if (changed)
875
+ await syncDirectory(directory);
876
+ }
877
+ async function originContextStoreBytes(directory) {
878
+ let total = 0;
879
+ for (const entry of await readdir(directory, { withFileTypes: true })) {
880
+ if (entry.name.startsWith(".") && entry.name.endsWith(".tmp"))
881
+ continue;
882
+ if (!/^[a-f0-9]{64}\.json$/u.test(entry.name)) {
883
+ throw new Error(`Unexpected entry in continuation origin-context directory: ${join(directory, entry.name)}`);
884
+ }
885
+ if (!entry.isFile() || entry.isSymbolicLink())
886
+ continue;
887
+ const info = await lstat(join(directory, entry.name));
888
+ total += info.size;
889
+ if (total > MAX_CONTINUATION_ORIGIN_CONTEXT_STORE_BYTES) {
890
+ throw new Error("Continuation origin context store exceeds its aggregate byte quota.");
891
+ }
892
+ }
893
+ return total;
894
+ }
395
895
  async function syncDirectory(path) {
396
896
  let directory;
397
897
  try {
@@ -493,6 +993,13 @@ const SETTLED_TERMINAL_STATES = new Set([
493
993
  "cancelled",
494
994
  "dead_lettered",
495
995
  ]);
996
+ const ORIGIN_CONTEXT_SCRUB_STATES = new Set([
997
+ "delivery_unknown",
998
+ "delivered",
999
+ "expired",
1000
+ "cancelled",
1001
+ "dead_lettered",
1002
+ ]);
496
1003
  function applyRetention(records, policy, now) {
497
1004
  const nowMs = now.getTime();
498
1005
  const captures = [...records.values()]
@@ -504,6 +1011,12 @@ function applyRetention(records, policy, now) {
504
1011
  .slice(0, policy.capturedTextMaxRecords);
505
1012
  const retainedCaptureText = new Set(captures.map((record) => record.continuationId));
506
1013
  for (const record of records.values()) {
1014
+ if (ORIGIN_CONTEXT_SCRUB_STATES.has(record.state) && record.originContextRef !== undefined) {
1015
+ record.originContextDigest ??= record.originContextRef.digest;
1016
+ record.originContextMessageCount ??= record.originContextRef.messageCount;
1017
+ delete record.originContextRef;
1018
+ record.originContextState = "scrubbed";
1019
+ }
507
1020
  if (!SETTLED_TERMINAL_STATES.has(record.state))
508
1021
  continue;
509
1022
  if (record.resultPayload !== undefined) {
@@ -534,7 +1047,7 @@ function newestFirst(left, right) {
534
1047
  function continuationStoreStats(records, policy) {
535
1048
  const values = [...records.values()];
536
1049
  return {
537
- format: "per-record-v2",
1050
+ format: "per-record-v3",
538
1051
  records: values.length,
539
1052
  active: values.filter((record) => !SETTLED_TERMINAL_STATES.has(record.state) && record.state !== "delivery_unknown").length,
540
1053
  unresolvedDelivery: values.filter((record) => record.state === "delivery_unknown").length,
@@ -570,8 +1083,13 @@ async function continuationPathExists(path) {
570
1083
  }
571
1084
  async function assertOwnerOnlyRegularFile(path, label) {
572
1085
  const info = await lstat(path);
1086
+ assertOwnerOnlySingleLinkStats(info, path, label);
1087
+ }
1088
+ function assertOwnerOnlySingleLinkStats(info, path, label) {
573
1089
  if (!info.isFile() || info.isSymbolicLink())
574
1090
  throw new Error(`${label} is not a regular file: ${path}`);
1091
+ if (info.nlink !== 1)
1092
+ throw new Error(`${label} must have exactly one filesystem link: ${path}`);
575
1093
  if (typeof process.getuid === "function" && info.uid !== process.getuid()) {
576
1094
  throw new Error(`${label} is not owned by the current user: ${path}`);
577
1095
  }
@@ -580,15 +1098,40 @@ async function assertOwnerOnlyRegularFile(path, label) {
580
1098
  }
581
1099
  }
582
1100
  async function readBoundedOwnerOnlyFile(path, maxBytes, label) {
583
- await assertOwnerOnlyRegularFile(path, label);
584
- const info = await lstat(path);
585
- if (info.size > maxBytes)
586
- throw new Error(`${label} exceeds its ${String(maxBytes)} byte safety limit: ${path}`);
587
- return await readFile(path, "utf8");
1101
+ return (await readBoundedOwnerOnlyFileWithStats(path, maxBytes, label)).text;
1102
+ }
1103
+ async function readBoundedOwnerOnlyFileWithStats(path, maxBytes, label) {
1104
+ const pathInfo = await lstat(path);
1105
+ assertOwnerOnlySingleLinkStats(pathInfo, path, label);
1106
+ const flags = fsConstants.O_RDONLY
1107
+ | (process.platform === "win32" ? 0 : fsConstants.O_NOFOLLOW);
1108
+ let handle;
1109
+ try {
1110
+ handle = await open(path, flags);
1111
+ const before = await handle.stat();
1112
+ assertOwnerOnlySingleLinkStats(before, path, label);
1113
+ if (before.dev !== pathInfo.dev || before.ino !== pathInfo.ino) {
1114
+ throw new Error(`${label} changed identity while it was opened: ${path}`);
1115
+ }
1116
+ if (before.size > maxBytes) {
1117
+ throw new Error(`${label} exceeds its ${String(maxBytes)} byte safety limit: ${path}`);
1118
+ }
1119
+ const body = await handle.readFile();
1120
+ const after = await handle.stat();
1121
+ assertOwnerOnlySingleLinkStats(after, path, label);
1122
+ if (after.dev !== before.dev || after.ino !== before.ino || after.size !== before.size
1123
+ || body.byteLength !== before.size) {
1124
+ throw new Error(`${label} changed while it was being read: ${path}`);
1125
+ }
1126
+ return { text: body.toString("utf8"), bytes: body.byteLength };
1127
+ }
1128
+ finally {
1129
+ await handle?.close().catch(() => undefined);
1130
+ }
588
1131
  }
589
- function isRecordTransaction(value) {
1132
+ function isRecordTransaction(value, expectedSchemaVersion) {
590
1133
  if (!isObject(value)
591
- || value.schemaVersion !== CONTINUATION_RECORD_STORE_SCHEMA_VERSION
1134
+ || value.schemaVersion !== expectedSchemaVersion
592
1135
  || !requiredString(value.generation)
593
1136
  || !requiredDate(value.createdAt)
594
1137
  || !Array.isArray(value.writes)
@@ -611,6 +1154,17 @@ function isStoreFile(value) {
611
1154
  }
612
1155
  return Object.entries(value.records).every(([id, record]) => id.length > 0 && isRecord(record, id));
613
1156
  }
1157
+ function normalizeLegacyContinuationRecords(records) {
1158
+ for (const record of records.values()) {
1159
+ if (record.originContextState === undefined) {
1160
+ record.originContextState = record.historyBoundary === undefined
1161
+ ? "detached_latest"
1162
+ : "legacy_missing";
1163
+ }
1164
+ if (record.synthesisDeferrals === undefined)
1165
+ record.synthesisDeferrals = 0;
1166
+ }
1167
+ }
614
1168
  function isRecord(value, id) {
615
1169
  if (!isObject(value))
616
1170
  return false;
@@ -620,6 +1174,18 @@ function isRecord(value, id) {
620
1174
  && requiredString(value.originConversationId)
621
1175
  && optionalString(value.replyToConversationId)
622
1176
  && optionalString(value.historyBoundary)
1177
+ && (value.originContextState === undefined || isOriginContextState(value.originContextState))
1178
+ && (value.originContextRef === undefined || isOriginContextReference(value.originContextRef))
1179
+ && (value.originContextDigest === undefined || isSha256(value.originContextDigest))
1180
+ && (value.originContextMessageCount === undefined
1181
+ || (Number.isSafeInteger(value.originContextMessageCount)
1182
+ && Number(value.originContextMessageCount) >= 0
1183
+ && Number(value.originContextMessageCount) <= MAX_CONTINUATION_ORIGIN_CONTEXT_MESSAGES))
1184
+ && (value.originContextFingerprint === undefined || isSha256(value.originContextFingerprint))
1185
+ && (value.originContextBindingMac === undefined || isSha256(value.originContextBindingMac))
1186
+ && (value.completionKind === undefined
1187
+ || value.completionKind === "synthesized"
1188
+ || value.completionKind === "origin_context_unavailable")
623
1189
  && isContinuationMode(value.mode)
624
1190
  && optionalString(value.routeName)
625
1191
  && requiredString(value.taskKey)
@@ -632,6 +1198,8 @@ function isRecord(value, id) {
632
1198
  && isContinuationState(value.state)
633
1199
  && Number.isInteger(value.synthesisAttempts)
634
1200
  && Number(value.synthesisAttempts) >= 0
1201
+ && (value.synthesisDeferrals === undefined
1202
+ || (Number.isSafeInteger(value.synthesisDeferrals) && Number(value.synthesisDeferrals) >= 0))
635
1203
  && Number.isInteger(value.deliveryAttempts)
636
1204
  && Number(value.deliveryAttempts) >= 0
637
1205
  && optionalString(value.resultIdempotencyKey)
@@ -647,6 +1215,48 @@ function isRecord(value, id) {
647
1215
  && (value.lastError === undefined || isLastError(value.lastError))
648
1216
  && (value.receipt === undefined || isReceipt(value.receipt));
649
1217
  }
1218
+ function isOriginContextState(value) {
1219
+ return value === "pending"
1220
+ || value === "pinned"
1221
+ || value === "abandoned"
1222
+ || value === "detached_latest"
1223
+ || value === "legacy_missing"
1224
+ || value === "scrubbed";
1225
+ }
1226
+ function isOriginContextReference(value) {
1227
+ return isObject(value)
1228
+ && value.schemaVersion === 1
1229
+ && isSha256(value.digest)
1230
+ && Number.isSafeInteger(value.bytes)
1231
+ && Number(value.bytes) > 0
1232
+ && Number(value.bytes) <= MAX_CONTINUATION_ORIGIN_CONTEXT_BYTES
1233
+ && Number.isSafeInteger(value.messageCount)
1234
+ && Number(value.messageCount) >= 2
1235
+ && Number(value.messageCount) <= MAX_CONTINUATION_ORIGIN_CONTEXT_MESSAGES;
1236
+ }
1237
+ function isOriginContextGroupCommit(value) {
1238
+ if (!isObject(value)
1239
+ || value.schemaVersion !== 1
1240
+ || !isSha256(value.groupKey)
1241
+ || !requiredString(value.originRunId)
1242
+ || !requiredString(value.originConversationId)
1243
+ || !requiredString(value.historyBoundary)
1244
+ || !isSha256(value.snapshotDigest)
1245
+ || !Number.isSafeInteger(value.memberCount)
1246
+ || Number(value.memberCount) < 1
1247
+ || !isSha256(value.memberSetDigest)
1248
+ || !requiredDate(value.activatedAt))
1249
+ return false;
1250
+ const expectedKey = continuationDigest(`mono-agent-origin-context-group-v1\0${canonicalContinuationJson({
1251
+ originRunId: value.originRunId,
1252
+ originConversationId: value.originConversationId,
1253
+ historyBoundary: value.historyBoundary,
1254
+ })}`);
1255
+ return value.groupKey === expectedKey;
1256
+ }
1257
+ function isSha256(value) {
1258
+ return typeof value === "string" && /^[a-f0-9]{64}$/u.test(value);
1259
+ }
650
1260
  function isLastError(value) {
651
1261
  return isObject(value) && requiredString(value.code) && requiredString(value.reason) && requiredDate(value.at);
652
1262
  }