@myagentroam/agent 0.9.116 → 0.9.117

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.
@@ -7,6 +7,7 @@ import { isDeepStrictEqual } from 'node:util';
7
7
  import { MarAgentError } from '../error.js';
8
8
  import { todoItemsFromSnapshot } from '../tools/todo.js';
9
9
  import { contextGcBaseFromRecords, contextGcBaseSequence, contextGcMinimumUsageGrowth, contextGcSourceProjectionHash, latestValidContextGcCheckpoint, normalizeContextGcReplacements, parseContextGcCheckpoint, sameContextGcBase, validateContextGcCheckpoint } from './context-gc-checkpoint.js';
10
+ import { beginSessionStorageTransaction, isUnjournaledTransactionFile, recoverSessionStorageTransaction } from './transaction-recovery.js';
10
11
  const MAX_SEGMENT_BYTES = 16 * 1024 * 1024;
11
12
  const REVERSE_READ_CHUNK_BYTES = 64 * 1024;
12
13
  const MAX_INPUT_IMAGES = 5;
@@ -15,6 +16,7 @@ const MAX_INPUT_IMAGES_BYTES = 10 * 1024 * 1024;
15
16
  const MAX_CONTEXTUAL_WORLD_STATE_BYTES = 8 * 1024 * 1024;
16
17
  const CONTEXTUAL_WORLD_STATE_FILE = 'context-world-state.json';
17
18
  const INPUT_IMAGE_MIME_TYPES = new Set(['image/png', 'image/jpeg', 'image/gif', 'image/webp']);
19
+ /** SDK-owned JSONL history with per-HOME in-process sharing and cross-process file locks. */
18
20
  export class JsonlSessionStore {
19
21
  static #instances = new Map();
20
22
  static #opening = new Map();
@@ -47,17 +49,104 @@ export class JsonlSessionStore {
47
49
  JsonlSessionStore.#opening.delete(root);
48
50
  }
49
51
  }
52
+ /** Create a missing index only without Session directories; recover journals and marked creations. */
50
53
  static async #openExclusive(root) {
51
- await mkdir(join(root, 'sessions'), { recursive: true });
52
- let index = { version: 1, revision: 0, sessions: [] };
54
+ const directory = join(root, 'sessions');
55
+ const path = join(directory, 'index.json');
56
+ await mkdir(directory, { recursive: true });
57
+ const release = await acquireFileLock(join(directory, 'index.lock'));
58
+ let index;
53
59
  try {
54
- index = JSON.parse(await readFile(join(root, 'sessions', 'index.json'), 'utf8'));
60
+ const disk = await readSessionIndex(path);
61
+ if (disk)
62
+ index = disk;
63
+ else {
64
+ const entries = await readdir(directory, { withFileTypes: true });
65
+ if (entries.some((entry) => entry.isDirectory()))
66
+ throw new MarAgentError('MAR_AGENT_SESSION_CORRUPT', 'Session index is missing.');
67
+ index = { version: 1, revision: 0, sessions: [] };
68
+ await persistSessionIndex(path, index);
69
+ }
55
70
  }
56
- catch {
57
- // A missing index starts empty; JSONL remains authoritative for explicit session reads.
71
+ finally {
72
+ await release();
73
+ }
74
+ for (const session of index.sessions) {
75
+ const directory = join(root, 'sessions', session.id);
76
+ let names;
77
+ try {
78
+ names = await readdir(directory);
79
+ }
80
+ catch (error) {
81
+ if (error.code === 'ENOENT')
82
+ continue;
83
+ throw error;
84
+ }
85
+ if (!names.includes('storage-transaction.json') &&
86
+ !names.some(isUnjournaledTransactionFile) &&
87
+ !names.some((name) => /^context-world-state\.json\.\d+\.[0-9a-f-]+\.(?:rollback|clear)-backup$/u.test(name)))
88
+ continue;
89
+ let releaseSession;
90
+ try {
91
+ releaseSession = await acquireFileLock(join(directory, 'lock'), 'Session already has a writer.', 0);
92
+ }
93
+ catch (error) {
94
+ if (error instanceof MarAgentError && error.code === 'MAR_AGENT_SESSION_LOCKED')
95
+ continue;
96
+ throw error;
97
+ }
98
+ try {
99
+ // The index may have changed while waiting for the Session lock.
100
+ const latest = await readSessionIndex(path);
101
+ const current = latest?.sessions.find((item) => item.id === session.id);
102
+ if (!current)
103
+ continue;
104
+ await recoverSessionStorageTransaction(directory, current.storageTransactionId);
105
+ await recoverContextualWorldStateTransactions(root, current);
106
+ }
107
+ finally {
108
+ await releaseSession();
109
+ }
110
+ }
111
+ // A creation marker distinguishes our unpublished directories from unrelated files.
112
+ for (const entry of await readdir(directory, { withFileTypes: true })) {
113
+ if (!entry.isDirectory() || !/^[0-9a-f-]{36}$/iu.test(entry.name))
114
+ continue;
115
+ const candidate = join(directory, entry.name);
116
+ const marker = join(candidate, 'publication-pending');
117
+ try {
118
+ if (!(await lstat(marker)).isFile())
119
+ continue;
120
+ }
121
+ catch (error) {
122
+ if (error.code === 'ENOENT')
123
+ continue;
124
+ throw error;
125
+ }
126
+ let releaseSession;
127
+ try {
128
+ releaseSession = await acquireFileLock(join(candidate, 'lock'), 'Session already has a writer.', 0);
129
+ }
130
+ catch (error) {
131
+ if (error instanceof MarAgentError && error.code === 'MAR_AGENT_SESSION_LOCKED')
132
+ continue;
133
+ throw error;
134
+ }
135
+ let unpublished = false;
136
+ try {
137
+ const latest = await readSessionIndex(path);
138
+ if (!latest)
139
+ throw new MarAgentError('MAR_AGENT_SESSION_CORRUPT', 'Session index is missing.');
140
+ unpublished = !latest.sessions.some((session) => session.id === entry.name);
141
+ if (!unpublished)
142
+ await rm(marker, { force: true });
143
+ }
144
+ finally {
145
+ await releaseSession();
146
+ }
147
+ if (unpublished)
148
+ await rm(candidate, { recursive: true, force: true });
58
149
  }
59
- for (const session of index.sessions)
60
- await recoverContextualWorldStateTransactions(root, session);
61
150
  return {
62
151
  root,
63
152
  index,
@@ -65,60 +154,103 @@ export class JsonlSessionStore {
65
154
  references: 1,
66
155
  closed: false,
67
156
  indexDirty: false,
157
+ dirtySessionVersions: new Map(),
158
+ pendingPublishIds: new Set(),
68
159
  segments: new Map()
69
160
  };
70
161
  }
71
- async #saveIndex(deletedIds) {
162
+ /** Merge dirty metadata with disk membership; only createdIds publish locally new entries. */
163
+ async #saveIndex(deletedIds, createdIds = []) {
72
164
  const release = await acquireFileLock(join(this.#shared.root, 'sessions', 'index.lock'));
73
165
  const path = join(this.#shared.root, 'sessions', 'index.json');
74
- const temporary = `${path}.${randomUUID()}.tmp`;
75
166
  try {
76
- let disk = { version: 1, revision: 0, sessions: [] };
77
- try {
78
- disk = JSON.parse(await readFile(path, 'utf8'));
79
- }
80
- catch {
81
- // The first index transaction starts from an empty disk snapshot.
82
- }
167
+ const disk = await readSessionIndex(path);
168
+ if (!disk)
169
+ throw new MarAgentError('MAR_AGENT_SESSION_CORRUPT', 'Session index is missing.');
170
+ const dirty = new Map(this.#shared.dirtySessionVersions);
83
171
  const sessions = new Map(disk.sessions.map((session) => [session.id, session]));
172
+ const created = new Set(createdIds);
84
173
  for (const session of this.#shared.index.sessions) {
85
174
  const current = sessions.get(session.id);
86
- if (!current || current.lastActivityAt <= session.lastActivityAt)
87
- sessions.set(session.id, session);
175
+ // Disk membership is authoritative: a stale in-memory snapshot cannot revive a deletion.
176
+ if (created.has(session.id) || (current && dirty.has(session.id)))
177
+ sessions.set(session.id, structuredClone(session));
88
178
  }
89
179
  for (const deletedId of typeof deletedIds === 'string' ? [deletedIds] : (deletedIds ?? []))
90
180
  sessions.delete(deletedId);
91
- this.#shared.index = {
181
+ const updated = {
92
182
  version: 1,
93
183
  revision: Math.max(disk.revision, this.#shared.index.revision) + 1,
94
184
  sessions: [...sessions.values()]
95
185
  };
96
- await writeFile(temporary, JSON.stringify(this.#shared.index), { mode: 0o600 });
97
- await rename(temporary, path);
98
- this.#shared.indexDirty = false;
186
+ await persistSessionIndex(path, updated);
187
+ for (const [id, version] of dirty) {
188
+ // A different creation still needs its own index transaction to publish its directory.
189
+ if (this.#shared.pendingPublishIds.has(id) && !created.has(id))
190
+ continue;
191
+ if (this.#shared.dirtySessionVersions.get(id) === version)
192
+ this.#shared.dirtySessionVersions.delete(id);
193
+ }
194
+ for (const id of created)
195
+ this.#shared.pendingPublishIds.delete(id);
196
+ const pending = this.#shared.index.sessions.filter((session) => this.#shared.dirtySessionVersions.has(session.id) &&
197
+ (sessions.has(session.id) || this.#shared.pendingPublishIds.has(session.id)));
198
+ const replacements = new Map(pending.map((session) => [session.id, session]));
199
+ const published = new Set(updated.sessions.map((session) => session.id));
200
+ this.#shared.index = {
201
+ ...updated,
202
+ sessions: [
203
+ ...updated.sessions.map((session) => replacements.get(session.id) ?? session),
204
+ ...pending.filter((session) => !published.has(session.id))
205
+ ]
206
+ };
207
+ this.#shared.indexDirty = this.#shared.dirtySessionVersions.size > 0;
99
208
  }
100
209
  finally {
101
- await rm(temporary, { force: true });
102
210
  await release();
103
211
  }
104
212
  }
105
213
  async #refreshIndex() {
106
- try {
107
- const disk = JSON.parse(await readFile(join(this.#shared.root, 'sessions', 'index.json'), 'utf8'));
108
- if (disk.revision <= this.#shared.index.revision)
109
- return;
110
- const local = new Map(this.#shared.index.sessions.map((session) => [session.id, session]));
111
- for (const session of disk.sessions) {
214
+ const disk = await readSessionIndex(join(this.#shared.root, 'sessions', 'index.json'));
215
+ if (!disk)
216
+ throw new MarAgentError('MAR_AGENT_SESSION_CORRUPT', 'Session index is missing.');
217
+ // A local unflushed revision may exceed another writer's committed revision.
218
+ // Always refresh membership from disk, then overlay only our pending changes.
219
+ const local = new Map(this.#shared.index.sessions.map((session) => [session.id, session]));
220
+ const published = new Set(disk.sessions.map((session) => session.id));
221
+ this.#shared.index = {
222
+ ...disk,
223
+ revision: this.#shared.indexDirty || this.#shared.pendingPublishIds.size > 0
224
+ ? Math.max(disk.revision, this.#shared.index.revision)
225
+ : disk.revision,
226
+ sessions: disk.sessions
227
+ .map((session) => {
112
228
  const current = local.get(session.id);
113
- if (!current || current.lastActivityAt < session.lastActivityAt)
114
- local.set(session.id, session);
115
- }
116
- this.#shared.index = { ...disk, sessions: [...local.values()] };
229
+ return current && this.#shared.dirtySessionVersions.has(session.id) ? current : session;
230
+ })
231
+ .concat(this.#shared.index.sessions.filter((session) => this.#shared.pendingPublishIds.has(session.id) && !published.has(session.id)))
232
+ };
233
+ }
234
+ /** Hold the new Session lock and mark its directory until JSONL and index publication finish. */
235
+ async #beginUnpublishedSession(sessionId) {
236
+ const directory = join(this.#shared.root, 'sessions', sessionId);
237
+ await mkdir(directory);
238
+ const release = await acquireFileLock(join(directory, 'lock'), 'Session already has a writer.', 0);
239
+ try {
240
+ await writeFile(join(directory, 'publication-pending'), '1', {
241
+ mode: 0o600,
242
+ flag: 'wx',
243
+ flush: true
244
+ });
117
245
  }
118
- catch {
119
- // A concurrent atomic replace can only leave the previous valid in-memory snapshot visible.
246
+ catch (error) {
247
+ await release();
248
+ await rm(directory, { recursive: true, force: true });
249
+ throw error;
120
250
  }
251
+ return { directory, release };
121
252
  }
253
+ /** Append the durable creation record before making this Session visible in the index. */
122
254
  async createSession(input) {
123
255
  this.#assertOpen();
124
256
  const id = randomUUID();
@@ -135,24 +267,66 @@ export class JsonlSessionStore {
135
267
  resume: { version: 1, todoRevision: 0 },
136
268
  segments: []
137
269
  };
138
- await mkdir(join(this.#shared.root, 'sessions', id), { recursive: true });
270
+ const publication = await this.#beginUnpublishedSession(id);
139
271
  this.#shared.segments.set(id, 1);
140
- this.#shared.index = {
141
- version: 1,
142
- revision: this.#shared.index.revision + 1,
143
- sessions: [meta, ...this.#shared.index.sessions]
144
- };
145
- await this.#saveIndex();
146
- await this.append(id, {
147
- type: 'session.created',
148
- payload: {
149
- title: meta.title,
150
- workspace: meta.workspace,
151
- automaticTitle: input.title === undefined,
152
- source: meta.source
272
+ this.#shared.pendingSessions.set(id, meta);
273
+ let discard = false;
274
+ try {
275
+ try {
276
+ await this.append(id, {
277
+ type: 'session.created',
278
+ payload: {
279
+ title: meta.title,
280
+ workspace: meta.workspace,
281
+ automaticTitle: input.title === undefined,
282
+ source: meta.source
283
+ }
284
+ });
153
285
  }
154
- });
155
- return meta;
286
+ catch (error) {
287
+ this.#shared.pendingSessions.delete(id);
288
+ this.#shared.segments.delete(id);
289
+ discard = true;
290
+ throw error;
291
+ }
292
+ this.#shared.pendingSessions.delete(id);
293
+ this.#shared.index.sessions.unshift(meta);
294
+ this.#shared.pendingPublishIds.add(id);
295
+ this.#markIndexDirty(id);
296
+ try {
297
+ await this.#saveIndex(undefined, [id]);
298
+ }
299
+ catch (error) {
300
+ // A failed lock release can follow a committed index rename; do not unpublish that Session.
301
+ // If the index cannot be read, leave the disk marker for recovery and keep the original error.
302
+ const disk = await readSessionIndex(join(this.#shared.root, 'sessions', 'index.json')).catch(() => undefined);
303
+ if (disk?.sessions.some((session) => session.id === id)) {
304
+ this.#shared.pendingPublishIds.delete(id);
305
+ this.#shared.dirtySessionVersions.delete(id);
306
+ this.#shared.indexDirty = this.#shared.dirtySessionVersions.size > 0;
307
+ }
308
+ else {
309
+ // Keep the marker until disk membership can be checked during recovery.
310
+ this.#shared.index.sessions = this.#shared.index.sessions.filter((item) => item.id !== id);
311
+ this.#shared.segments.delete(id);
312
+ this.#shared.pendingPublishIds.delete(id);
313
+ this.#shared.dirtySessionVersions.delete(id);
314
+ this.#shared.indexDirty = this.#shared.dirtySessionVersions.size > 0;
315
+ throw error;
316
+ }
317
+ }
318
+ await rm(join(publication.directory, 'publication-pending'), { force: true });
319
+ return meta;
320
+ }
321
+ finally {
322
+ try {
323
+ await publication.release();
324
+ }
325
+ finally {
326
+ if (discard)
327
+ await rm(publication.directory, { recursive: true, force: true });
328
+ }
329
+ }
156
330
  }
157
331
  sessionDirectory(sessionId) {
158
332
  this.#assertOpen();
@@ -212,6 +386,7 @@ export class JsonlSessionStore {
212
386
  await rm(temporary, { force: true });
213
387
  }
214
388
  }
389
+ /** Mark each image pending before publication; turn.user or lease recovery resolves ownership. */
215
390
  async persistInputImages(sessionId, images) {
216
391
  this.#assertOpen();
217
392
  if (images.length === 0)
@@ -221,6 +396,7 @@ export class JsonlSessionStore {
221
396
  const directory = join(this.sessionDirectory(sessionId), 'input_images');
222
397
  await mkdir(directory, { recursive: true, mode: 0o700 });
223
398
  const written = [];
399
+ const pending = [];
224
400
  let temporary;
225
401
  let totalBytes = 0;
226
402
  try {
@@ -244,6 +420,9 @@ export class JsonlSessionStore {
244
420
  finally {
245
421
  await handle.close();
246
422
  }
423
+ const marker = join(directory, `.${fileName}.pending`);
424
+ await writeFile(marker, '', { mode: 0o600, flag: 'wx', flush: true });
425
+ pending.push(marker);
247
426
  await rename(temporary, join(directory, fileName));
248
427
  temporary = undefined;
249
428
  written.push(fileName);
@@ -260,6 +439,7 @@ export class JsonlSessionStore {
260
439
  catch (error) {
261
440
  if (temporary !== undefined)
262
441
  await rm(temporary, { force: true }).catch(() => undefined);
442
+ await Promise.all(pending.map((marker) => rm(marker, { force: true })));
263
443
  await Promise.all(written.map((fileName) => rm(join(directory, fileName), { force: true }).catch(() => undefined)));
264
444
  throw error;
265
445
  }
@@ -309,13 +489,6 @@ export class JsonlSessionStore {
309
489
  dataBase64: data.toString('base64')
310
490
  };
311
491
  }
312
- async removeInputImages(sessionId, images) {
313
- this.#assertOpen();
314
- const directory = join(this.sessionDirectory(sessionId), 'input_images');
315
- await Promise.all(images.flatMap((image) => validInputImageFileName(image.fileName)
316
- ? [rm(join(directory, image.fileName), { force: true })]
317
- : []));
318
- }
319
492
  async forkSession(sourceSessionId, input) {
320
493
  this.#assertOpen();
321
494
  const release = await this.acquireSessionLease(sourceSessionId);
@@ -342,9 +515,10 @@ export class JsonlSessionStore {
342
515
  resume: { version: 1, todoRevision: 0 },
343
516
  segments: []
344
517
  };
345
- await mkdir(join(this.#shared.root, 'sessions', target.id), { recursive: true });
518
+ const publication = await this.#beginUnpublishedSession(target.id);
346
519
  this.#shared.segments.set(target.id, 1);
347
520
  this.#shared.pendingSessions.set(target.id, target);
521
+ let discard = false;
348
522
  try {
349
523
  await this.append(target.id, {
350
524
  type: 'session.created',
@@ -376,8 +550,10 @@ export class JsonlSessionStore {
376
550
  revision: this.#shared.index.revision + 1,
377
551
  sessions: [target, ...this.#shared.index.sessions]
378
552
  };
379
- this.#shared.indexDirty = true;
380
- await this.#saveIndex();
553
+ this.#shared.pendingPublishIds.add(target.id);
554
+ this.#markIndexDirty(target.id);
555
+ await this.#saveIndex(undefined, [target.id]);
556
+ await rm(join(publication.directory, 'publication-pending'), { force: true });
381
557
  return {
382
558
  id: target.id,
383
559
  title: target.title,
@@ -389,25 +565,43 @@ export class JsonlSessionStore {
389
565
  }
390
566
  catch (error) {
391
567
  this.#shared.pendingSessions.delete(target.id);
568
+ const disk = await readSessionIndex(join(this.#shared.root, 'sessions', 'index.json'));
569
+ if (disk?.sessions.some((session) => session.id === target.id))
570
+ return {
571
+ id: target.id,
572
+ title: target.title,
573
+ workspace: target.workspace,
574
+ createdAt: target.createdAt,
575
+ lastActivityAt: target.createdAt,
576
+ source: target.source
577
+ };
392
578
  const publishedIndex = this.#shared.index.sessions.findIndex((item) => item.id === target.id);
393
579
  if (publishedIndex >= 0) {
394
580
  this.#shared.index.sessions.splice(publishedIndex, 1);
395
581
  this.#shared.index.revision++;
396
- this.#shared.indexDirty = true;
397
- await this.#saveIndex([target.id]).catch(() => undefined);
582
+ this.#shared.pendingPublishIds.delete(target.id);
583
+ this.#shared.dirtySessionVersions.delete(target.id);
584
+ this.#shared.indexDirty = this.#shared.dirtySessionVersions.size > 0;
398
585
  }
399
586
  this.#shared.segments.delete(target.id);
400
- await rm(join(this.#shared.root, 'sessions', target.id), {
401
- recursive: true,
402
- force: true
403
- }).catch(() => undefined);
587
+ discard = true;
404
588
  throw error;
405
589
  }
590
+ finally {
591
+ try {
592
+ await publication.release();
593
+ }
594
+ finally {
595
+ if (discard)
596
+ await rm(publication.directory, { recursive: true, force: true });
597
+ }
598
+ }
406
599
  }
407
600
  finally {
408
601
  await release();
409
602
  }
410
603
  }
604
+ /** Replace history under a journal; the indexed transaction ID decides the commit side. */
411
605
  async rollbackSession(sessionId, input) {
412
606
  this.#assertOpen();
413
607
  const release = await this.acquireSessionLease(sessionId);
@@ -442,9 +636,21 @@ export class JsonlSessionStore {
442
636
  path,
443
637
  backup: `${path}.${transactionId}.rollback-backup`
444
638
  }));
639
+ const transaction = {
640
+ version: 1,
641
+ id: transactionId,
642
+ kind: 'rollback',
643
+ originalSegments: originalPaths.map((path) => basename(path)),
644
+ stagedSegments: staged.files.map((item) => ({
645
+ path: basename(item.path),
646
+ temporary: basename(item.temporaryPath)
647
+ })),
648
+ sidecarBackup: basename(contextualWorldStateBackup)
649
+ };
445
650
  const installedPaths = [];
446
651
  let committed = false;
447
652
  try {
653
+ await beginSessionStorageTransaction(this.sessionDirectory(sessionId), transaction);
448
654
  contextualWorldStateStaged = await renameIfExists(contextualWorldStatePath, contextualWorldStateBackup);
449
655
  generatedTrash = await stageUnreferencedImages(this.sessionDirectory(sessionId), 'generated_images', new Set(generatedImageFileNames(retained)), transactionId);
450
656
  inputTrash = await stageUnreferencedImages(this.sessionDirectory(sessionId), 'input_images', new Set(inputImageReferencesFromRecords(retained).map((image) => image.fileName)), transactionId);
@@ -459,21 +665,16 @@ export class JsonlSessionStore {
459
665
  meta.title = retainedSessionTitle(retained, meta.title);
460
666
  meta.lastActivityAt = completedAt;
461
667
  meta.nextSequence = targetNextSequence;
668
+ meta.storageTransactionId = transactionId;
462
669
  meta.automaticTitlePending = state.automaticTitlePending;
463
670
  meta.resume = state.resume;
464
671
  meta.segments = staged.segments;
465
672
  this.#shared.segments.set(sessionId, staged.segments.at(-1)?.number ?? 1);
466
673
  this.#shared.index.revision++;
467
- this.#shared.indexDirty = true;
674
+ this.#markIndexDirty(sessionId);
468
675
  await this.#saveIndex();
469
676
  committed = true;
470
- if (contextualWorldStateStaged)
471
- await rm(contextualWorldStateBackup, { force: true });
472
- await Promise.all([
473
- ...backups.map((item) => rm(item.backup, { force: true }).catch(() => undefined)),
474
- ...generatedTrash.map((item) => rm(item.trash, { force: true }).catch(() => undefined)),
475
- ...inputTrash.map((item) => rm(item.trash, { force: true }).catch(() => undefined))
476
- ]).catch(() => undefined);
677
+ await recoverSessionStorageTransaction(this.sessionDirectory(sessionId), transactionId);
477
678
  return {
478
679
  id: meta.id,
479
680
  title: meta.title,
@@ -484,6 +685,14 @@ export class JsonlSessionStore {
484
685
  };
485
686
  }
486
687
  catch (error) {
688
+ if (!committed) {
689
+ committed = await this.#storageTransactionCommitted(sessionId, transactionId);
690
+ if (committed) {
691
+ this.#shared.dirtySessionVersions.delete(sessionId);
692
+ this.#shared.indexDirty = this.#shared.dirtySessionVersions.size > 0;
693
+ await recoverSessionStorageTransaction(this.sessionDirectory(sessionId), transactionId);
694
+ }
695
+ }
487
696
  if (!committed) {
488
697
  await Promise.all(installedPaths.map((path) => rm(path, { force: true }))).catch(() => undefined);
489
698
  await restoreRenamedFiles(backups.map((item) => ({ from: item.backup, to: item.path })));
@@ -498,7 +707,8 @@ export class JsonlSessionStore {
498
707
  this.#shared.segments.delete(sessionId);
499
708
  else
500
709
  this.#shared.segments.set(sessionId, originalSegment);
501
- this.#shared.indexDirty = true;
710
+ this.#markIndexDirty(sessionId);
711
+ await recoverSessionStorageTransaction(this.sessionDirectory(sessionId), originalMeta.storageTransactionId);
502
712
  }
503
713
  throw error;
504
714
  }
@@ -526,6 +736,7 @@ export class JsonlSessionStore {
526
736
  });
527
737
  meta.title = normalized;
528
738
  }
739
+ /** Reset history with the same recovery journal while preserving the Session identity. */
529
740
  async clearSession(sessionId) {
530
741
  this.#assertOpen();
531
742
  const release = await this.acquireSessionLease(sessionId);
@@ -568,9 +779,21 @@ export class JsonlSessionStore {
568
779
  path,
569
780
  backup: `${path}.${transactionId}.clear-backup`
570
781
  }));
782
+ const transaction = {
783
+ version: 1,
784
+ id: transactionId,
785
+ kind: 'clear',
786
+ originalSegments: originalPaths.map((path) => basename(path)),
787
+ stagedSegments: staged.files.map((item) => ({
788
+ path: basename(item.path),
789
+ temporary: basename(item.temporaryPath)
790
+ })),
791
+ sidecarBackup: basename(contextualWorldStateBackup)
792
+ };
571
793
  const installedPaths = [];
572
794
  let committed = false;
573
795
  try {
796
+ await beginSessionStorageTransaction(this.sessionDirectory(sessionId), transaction);
574
797
  contextualWorldStateStaged = await renameIfExists(contextualWorldStatePath, contextualWorldStateBackup);
575
798
  generatedTrash = await stageUnreferencedImages(this.sessionDirectory(sessionId), 'generated_images', new Set(), transactionId);
576
799
  inputTrash = await stageUnreferencedImages(this.sessionDirectory(sessionId), 'input_images', new Set(), transactionId);
@@ -582,20 +805,15 @@ export class JsonlSessionStore {
582
805
  }
583
806
  meta.lastActivityAt = clearedAt;
584
807
  meta.nextSequence = 2;
808
+ meta.storageTransactionId = transactionId;
585
809
  meta.resume = { version: 1, todoRevision: 0 };
586
810
  meta.segments = staged.segments;
587
811
  this.#shared.segments.set(sessionId, 1);
588
812
  this.#shared.index.revision++;
589
- this.#shared.indexDirty = true;
813
+ this.#markIndexDirty(sessionId);
590
814
  await this.#saveIndex();
591
815
  committed = true;
592
- if (contextualWorldStateStaged)
593
- await rm(contextualWorldStateBackup, { force: true });
594
- await Promise.all([
595
- ...backups.map((item) => rm(item.backup, { force: true }).catch(() => undefined)),
596
- ...generatedTrash.map((item) => rm(item.trash, { force: true }).catch(() => undefined)),
597
- ...inputTrash.map((item) => rm(item.trash, { force: true }).catch(() => undefined))
598
- ]).catch(() => undefined);
816
+ await recoverSessionStorageTransaction(this.sessionDirectory(sessionId), transactionId);
599
817
  return {
600
818
  id: meta.id,
601
819
  title: meta.title,
@@ -606,6 +824,14 @@ export class JsonlSessionStore {
606
824
  };
607
825
  }
608
826
  catch (error) {
827
+ if (!committed) {
828
+ committed = await this.#storageTransactionCommitted(sessionId, transactionId);
829
+ if (committed) {
830
+ this.#shared.dirtySessionVersions.delete(sessionId);
831
+ this.#shared.indexDirty = this.#shared.dirtySessionVersions.size > 0;
832
+ await recoverSessionStorageTransaction(this.sessionDirectory(sessionId), transactionId);
833
+ }
834
+ }
609
835
  if (!committed) {
610
836
  await Promise.all(installedPaths.map((path) => rm(path, { force: true }))).catch(() => undefined);
611
837
  await restoreRenamedFiles(backups.map((item) => ({ from: item.backup, to: item.path })));
@@ -620,7 +846,8 @@ export class JsonlSessionStore {
620
846
  this.#shared.segments.delete(sessionId);
621
847
  else
622
848
  this.#shared.segments.set(sessionId, originalSegment);
623
- this.#shared.indexDirty = true;
849
+ this.#markIndexDirty(sessionId);
850
+ await recoverSessionStorageTransaction(this.sessionDirectory(sessionId), originalMeta.storageTransactionId);
624
851
  }
625
852
  throw error;
626
853
  }
@@ -657,7 +884,7 @@ export class JsonlSessionStore {
657
884
  const record = {
658
885
  schemaVersion: 1,
659
886
  recordId: randomUUID(),
660
- sequence: meta.nextSequence++,
887
+ sequence: meta.nextSequence,
661
888
  timestamp: input.timestamp ?? new Date().toISOString(),
662
889
  sessionId,
663
890
  ...(input.executionId ? { executionId: input.executionId } : {}),
@@ -681,12 +908,28 @@ export class JsonlSessionStore {
681
908
  }
682
909
  const handle = await open(path, 'a', 0o600);
683
910
  try {
684
- await handle.write(serialized);
911
+ const bytes = Buffer.from(serialized);
912
+ let offset = 0;
913
+ while (offset < bytes.length) {
914
+ const { bytesWritten } = await handle.write(bytes, offset, bytes.length - offset);
915
+ if (bytesWritten === 0)
916
+ throw new MarAgentError('MAR_AGENT_SESSION_CORRUPT', 'Session append is incomplete.');
917
+ offset += bytesWritten;
918
+ }
685
919
  await handle.sync();
686
920
  }
687
921
  finally {
688
922
  await handle.close();
689
923
  }
924
+ meta.nextSequence++;
925
+ if (record.type === 'turn.user' &&
926
+ isObject(record.payload) &&
927
+ Array.isArray(record.payload.images))
928
+ for (const image of record.payload.images)
929
+ if (isObject(image) &&
930
+ typeof image.fileName === 'string' &&
931
+ validInputImageFileName(image.fileName))
932
+ await rm(join(this.sessionDirectory(sessionId), 'input_images', `.${image.fileName}.pending`), { force: true }).catch(() => undefined);
690
933
  const segmentMetadata = meta.segments ?? (meta.segments = []);
691
934
  const known = segmentMetadata.find((item) => item.number === segment);
692
935
  if (known) {
@@ -704,7 +947,7 @@ export class JsonlSessionStore {
704
947
  this.#applyRecordToResumeIndex(meta, record);
705
948
  if (published) {
706
949
  this.#shared.index.revision++;
707
- this.#shared.indexDirty = true;
950
+ this.#markIndexDirty(sessionId);
708
951
  }
709
952
  return record;
710
953
  }
@@ -760,6 +1003,7 @@ export class JsonlSessionStore {
760
1003
  throw new MarAgentError('MAR_AGENT_SESSION_CORRUPT', 'Context GC projection boundary is invalid.');
761
1004
  return hash;
762
1005
  }
1006
+ /** Recheck a planned GC checkpoint against authoritative records before appending it. */
763
1007
  async commitContextGc(sessionId, draft) {
764
1008
  this.#assertOpen();
765
1009
  const meta = this.#shared.index.sessions.find((item) => item.id === sessionId);
@@ -873,6 +1117,7 @@ export class JsonlSessionStore {
873
1117
  ...(input.turnId ? { turnId: input.turnId } : {})
874
1118
  });
875
1119
  }
1120
+ /** Recover files under the sole writer lock; flush dirty index state before releasing it. */
876
1121
  async acquireSessionLease(sessionId) {
877
1122
  this.#assertOpen();
878
1123
  await this.#refreshIndex();
@@ -881,6 +1126,15 @@ export class JsonlSessionStore {
881
1126
  const path = join(this.#shared.root, 'sessions', sessionId, 'lock');
882
1127
  const release = await acquireFileLock(path, 'Session already has a writer.', 0);
883
1128
  try {
1129
+ const disk = await readSessionIndex(join(this.#shared.root, 'sessions', 'index.json'));
1130
+ if (!disk)
1131
+ throw new MarAgentError('MAR_AGENT_SESSION_CORRUPT', 'Session index is missing.');
1132
+ const current = disk.sessions.find((item) => item.id === sessionId);
1133
+ if (!current)
1134
+ throw new MarAgentError('MAR_AGENT_SESSION_NOT_FOUND', 'Session was not found.');
1135
+ await recoverSessionStorageTransaction(this.sessionDirectory(sessionId), current.storageTransactionId);
1136
+ await recoverContextualWorldStateTransactions(this.#shared.root, current);
1137
+ await this.#refreshIndex();
884
1138
  await this.#reconcileTail(sessionId);
885
1139
  }
886
1140
  catch (error) {
@@ -1011,7 +1265,7 @@ export class JsonlSessionStore {
1011
1265
  meta.automaticTitlePending = state.automaticTitlePending;
1012
1266
  meta.resume = state.resume;
1013
1267
  this.#shared.index.revision++;
1014
- this.#shared.indexDirty = true;
1268
+ this.#markIndexDirty(meta.id);
1015
1269
  }
1016
1270
  return {
1017
1271
  automaticTitlePending: state.automaticTitlePending,
@@ -1055,6 +1309,14 @@ export class JsonlSessionStore {
1055
1309
  if (this.#leaseClosed || this.#shared.closed)
1056
1310
  throw new MarAgentError('MAR_AGENT_DISPOSED', 'Session store is closed.');
1057
1311
  }
1312
+ #markIndexDirty(sessionId) {
1313
+ this.#shared.indexDirty = true;
1314
+ this.#shared.dirtySessionVersions.set(sessionId, (this.#shared.dirtySessionVersions.get(sessionId) ?? 0) + 1);
1315
+ }
1316
+ async #storageTransactionCommitted(sessionId, transactionId) {
1317
+ const disk = await readSessionIndex(join(this.#shared.root, 'sessions', 'index.json'));
1318
+ return (disk?.sessions.some((session) => session.id === sessionId && session.storageTransactionId === transactionId) ?? false);
1319
+ }
1058
1320
  #segmentPath(sessionId, segment) {
1059
1321
  return join(this.#shared.root, 'sessions', sessionId, `events-${String(segment).padStart(6, '0')}.jsonl`);
1060
1322
  }
@@ -1083,27 +1345,30 @@ export class JsonlSessionStore {
1083
1345
  this.#shared.segments.set(sessionId, segment);
1084
1346
  return segment;
1085
1347
  }
1348
+ /** Repair the last segment and pending images under the lease, then sync sequence metadata. */
1086
1349
  async #reconcileTail(sessionId) {
1087
1350
  const meta = this.#shared.index.sessions.find((item) => item.id === sessionId);
1088
1351
  const paths = await this.#segmentPaths(sessionId);
1089
1352
  const latestPath = paths.at(-1);
1353
+ await repairSegmentTail(latestPath);
1090
1354
  const records = await readRecords(latestPath);
1355
+ await recoverPendingInputImages(this.sessionDirectory(sessionId), () => this.read(sessionId));
1091
1356
  const last = records.at(-1);
1092
1357
  const match = /events-(\d{6})\.jsonl$/.exec(latestPath);
1093
1358
  const segment = Number(match?.[1] ?? 1);
1094
1359
  this.#shared.segments.set(sessionId, segment);
1095
1360
  if (last === undefined)
1096
- return;
1361
+ throw new MarAgentError('MAR_AGENT_SESSION_CORRUPT', 'Session history is corrupt.');
1097
1362
  const bytes = (await stat(latestPath)).size;
1098
1363
  const firstSequence = records[0].sequence;
1099
1364
  const known = meta.segments?.find((item) => item.number === segment);
1100
1365
  const titleRecord = records.findLast((record) => record.type === 'session.title.updated' &&
1101
1366
  typeof record.payload.title === 'string');
1102
1367
  const recoveredTitle = titleRecord?.payload?.title;
1103
- const changed = meta.nextSequence <= last.sequence ||
1368
+ const changed = meta.nextSequence !== last.sequence + 1 ||
1104
1369
  known?.bytes !== bytes ||
1105
1370
  (recoveredTitle !== undefined && meta.title !== recoveredTitle);
1106
- meta.nextSequence = Math.max(meta.nextSequence, last.sequence + 1);
1371
+ meta.nextSequence = last.sequence + 1;
1107
1372
  if (recoveredTitle !== undefined)
1108
1373
  meta.title = recoveredTitle;
1109
1374
  meta.lastActivityAt =
@@ -1121,7 +1386,7 @@ export class JsonlSessionStore {
1121
1386
  delete meta.automaticTitlePending;
1122
1387
  delete meta.resume;
1123
1388
  this.#shared.index.revision++;
1124
- this.#shared.indexDirty = true;
1389
+ this.#markIndexDirty(sessionId);
1125
1390
  }
1126
1391
  }
1127
1392
  async close() {
@@ -1137,6 +1402,47 @@ export class JsonlSessionStore {
1137
1402
  JsonlSessionStore.#instances.delete(this.#shared.root);
1138
1403
  }
1139
1404
  }
1405
+ async function readSessionIndex(path) {
1406
+ let raw;
1407
+ try {
1408
+ raw = await readFile(path, 'utf8');
1409
+ }
1410
+ catch (cause) {
1411
+ if (cause.code === 'ENOENT')
1412
+ return undefined;
1413
+ throw cause;
1414
+ }
1415
+ try {
1416
+ const value = JSON.parse(raw);
1417
+ if (value?.version !== 1 ||
1418
+ !Number.isSafeInteger(value.revision) ||
1419
+ value.revision < 0 ||
1420
+ !Array.isArray(value.sessions) ||
1421
+ value.sessions.some((session) => typeof session?.id !== 'string' ||
1422
+ !/^[0-9a-f-]{36}$/iu.test(session.id) ||
1423
+ typeof session.title !== 'string' ||
1424
+ typeof session.workspace !== 'string' ||
1425
+ typeof session.createdAt !== 'string' ||
1426
+ typeof session.lastActivityAt !== 'string' ||
1427
+ !Number.isSafeInteger(session.nextSequence) ||
1428
+ session.nextSequence < 1))
1429
+ throw new Error('invalid index shape');
1430
+ return value;
1431
+ }
1432
+ catch (cause) {
1433
+ throw new MarAgentError('MAR_AGENT_SESSION_CORRUPT', 'Session index is corrupt.', { cause });
1434
+ }
1435
+ }
1436
+ async function persistSessionIndex(path, index) {
1437
+ const temporary = `${path}.${randomUUID()}.tmp`;
1438
+ try {
1439
+ await writeFile(temporary, JSON.stringify(index), { mode: 0o600, flag: 'wx', flush: true });
1440
+ await rename(temporary, path);
1441
+ }
1442
+ finally {
1443
+ await rm(temporary, { force: true });
1444
+ }
1445
+ }
1140
1446
  async function sessionDirectoryExists(path) {
1141
1447
  try {
1142
1448
  await lstat(path);
@@ -1668,6 +1974,67 @@ async function readRecords(path) {
1668
1974
  }
1669
1975
  return records;
1670
1976
  }
1977
+ /** A pending marker is removable only after the committed JSONL references are checked. */
1978
+ async function recoverPendingInputImages(sessionDirectory, readHistory) {
1979
+ const directory = join(sessionDirectory, 'input_images');
1980
+ let names;
1981
+ try {
1982
+ names = await readdir(directory);
1983
+ }
1984
+ catch (error) {
1985
+ if (error.code === 'ENOENT')
1986
+ return;
1987
+ throw error;
1988
+ }
1989
+ const pending = names.filter((name) => /^\.[0-9a-f-]{36}\.bin\.pending$/iu.test(name));
1990
+ const temporary = names.filter((name) => /^\.[0-9a-f-]{36}\.bin\.[0-9a-f-]{36}\.tmp$/iu.test(name));
1991
+ if (pending.length === 0 && temporary.length === 0)
1992
+ return;
1993
+ const referenced = pending.length === 0
1994
+ ? new Set()
1995
+ : new Set(inputImageReferencesFromRecords(await readHistory()).map((image) => image.fileName));
1996
+ for (const name of pending) {
1997
+ const fileName = name.slice(1, -'.pending'.length);
1998
+ if (!referenced.has(fileName))
1999
+ await rm(join(directory, fileName), { force: true });
2000
+ await rm(join(directory, name), { force: true });
2001
+ }
2002
+ for (const name of temporary)
2003
+ await rm(join(directory, name), { force: true });
2004
+ }
2005
+ /** Repair only the final record under the Session writer lock; never skip middle damage. */
2006
+ async function repairSegmentTail(path) {
2007
+ const data = await readFile(path);
2008
+ if (data.length === 0 || data.at(-1) === 10)
2009
+ return;
2010
+ const boundary = data.lastIndexOf(10);
2011
+ let complete = false;
2012
+ try {
2013
+ const record = JSON.parse(data.subarray(boundary + 1).toString('utf8'));
2014
+ complete =
2015
+ record?.schemaVersion === 1 &&
2016
+ Number.isSafeInteger(record.sequence) &&
2017
+ typeof record.sessionId === 'string' &&
2018
+ typeof record.type === 'string';
2019
+ }
2020
+ catch {
2021
+ // An interrupted final line is discarded before another record can be appended.
2022
+ }
2023
+ const handle = await open(path, 'r+');
2024
+ try {
2025
+ if (complete) {
2026
+ const { bytesWritten } = await handle.write(Buffer.from('\n'), 0, 1, data.length);
2027
+ if (bytesWritten !== 1)
2028
+ throw new MarAgentError('MAR_AGENT_SESSION_CORRUPT', 'Session tail repair is incomplete.');
2029
+ }
2030
+ else
2031
+ await handle.truncate(boundary + 1);
2032
+ await handle.sync();
2033
+ }
2034
+ finally {
2035
+ await handle.close();
2036
+ }
2037
+ }
1671
2038
  async function readRecordPageFromTail(path, beforeSequence, limit) {
1672
2039
  const handle = await open(path, 'r');
1673
2040
  try {
@@ -1750,28 +2117,54 @@ function segmentNumberFromPath(path) {
1750
2117
  async function acquireFileLock(path, message = 'Session index is busy.', waitMs = 5000) {
1751
2118
  const deadline = Date.now() + waitMs;
1752
2119
  for (;;) {
2120
+ let handle;
1753
2121
  try {
1754
- const handle = await open(path, 'wx', 0o600);
1755
- await handle.writeFile(JSON.stringify({ pid: process.pid, createdAt: new Date().toISOString() }));
1756
- let released = false;
1757
- return async () => {
1758
- if (released)
1759
- return;
1760
- released = true;
1761
- await handle.close();
1762
- await rm(path, { force: true });
1763
- };
2122
+ handle = await open(path, 'wx', 0o600);
1764
2123
  }
1765
2124
  catch (cause) {
2125
+ if (cause.code !== 'EEXIST')
2126
+ throw cause;
1766
2127
  if (await removeDeadOwner(path))
1767
2128
  continue;
1768
2129
  if (Date.now() >= deadline)
1769
2130
  throw new MarAgentError('MAR_AGENT_SESSION_LOCKED', message, { cause });
1770
2131
  await delay(25 + Math.floor(Math.random() * 25));
2132
+ continue;
2133
+ }
2134
+ try {
2135
+ await handle.writeFile(JSON.stringify({ pid: process.pid, createdAt: new Date().toISOString() }));
2136
+ }
2137
+ catch (error) {
2138
+ try {
2139
+ await handle.close();
2140
+ }
2141
+ finally {
2142
+ await rm(path, { force: true });
2143
+ }
2144
+ throw error;
1771
2145
  }
2146
+ let released = false;
2147
+ return async () => {
2148
+ if (released)
2149
+ return;
2150
+ released = true;
2151
+ await handle.close();
2152
+ await rm(path, { force: true });
2153
+ };
1772
2154
  }
1773
2155
  }
1774
2156
  async function removeDeadOwner(path) {
2157
+ const reclaimerPath = `${path}.reclaim`;
2158
+ let reclaimer;
2159
+ try {
2160
+ // Only one process may unlink a dead owner's path; all other contenders wait.
2161
+ reclaimer = await open(reclaimerPath, 'wx', 0o600);
2162
+ }
2163
+ catch (error) {
2164
+ if (error.code === 'EEXIST')
2165
+ return false;
2166
+ throw error;
2167
+ }
1775
2168
  try {
1776
2169
  const owner = JSON.parse(await readFile(path, 'utf8'));
1777
2170
  if (typeof owner.pid !== 'number' || !Number.isSafeInteger(owner.pid))
@@ -1790,4 +2183,12 @@ async function removeDeadOwner(path) {
1790
2183
  catch {
1791
2184
  return false;
1792
2185
  }
2186
+ finally {
2187
+ try {
2188
+ await reclaimer.close();
2189
+ }
2190
+ finally {
2191
+ await rm(reclaimerPath, { force: true });
2192
+ }
2193
+ }
1793
2194
  }