@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.
- package/dist/index.d.ts +3 -7
- package/dist/index.js +1 -6
- package/dist/model/transport-test.d.ts +3 -0
- package/dist/model/transport-test.js +23 -0
- package/dist/sdk/agent.d.ts +9 -0
- package/dist/sdk/agent.js +27 -20
- package/dist/sdk/types.d.ts +7 -3
- package/dist/session/catalog.d.ts +1 -1
- package/dist/session/catalog.js +3 -2
- package/dist/session/jsonl-store.d.ts +10 -19
- package/dist/session/jsonl-store.js +504 -103
- package/dist/session/transaction-recovery.d.ts +16 -0
- package/dist/session/transaction-recovery.js +135 -0
- package/dist/session/types.d.ts +18 -0
- package/dist/session/types.js +1 -0
- package/dist/tools/agent-cancel.d.ts +1 -1
- package/dist/tools/agent-wait.d.ts +1 -1
- package/dist/tools/exec.js +1 -1
- package/package.json +1 -1
|
@@ -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
|
-
|
|
52
|
-
|
|
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
|
-
|
|
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
|
-
|
|
57
|
-
|
|
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
|
-
|
|
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
|
-
|
|
77
|
-
|
|
78
|
-
|
|
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
|
-
|
|
87
|
-
|
|
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
|
-
|
|
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
|
|
97
|
-
|
|
98
|
-
|
|
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
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
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
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
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
|
-
|
|
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
|
|
270
|
+
const publication = await this.#beginUnpublishedSession(id);
|
|
139
271
|
this.#shared.segments.set(id, 1);
|
|
140
|
-
this.#shared.
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
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
|
-
|
|
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
|
|
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.
|
|
380
|
-
|
|
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.
|
|
397
|
-
|
|
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
|
-
|
|
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.#
|
|
674
|
+
this.#markIndexDirty(sessionId);
|
|
468
675
|
await this.#saveIndex();
|
|
469
676
|
committed = true;
|
|
470
|
-
|
|
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.#
|
|
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.#
|
|
813
|
+
this.#markIndexDirty(sessionId);
|
|
590
814
|
await this.#saveIndex();
|
|
591
815
|
committed = true;
|
|
592
|
-
|
|
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.#
|
|
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
|
-
|
|
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.#
|
|
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.#
|
|
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
|
-
|
|
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
|
|
1368
|
+
const changed = meta.nextSequence !== last.sequence + 1 ||
|
|
1104
1369
|
known?.bytes !== bytes ||
|
|
1105
1370
|
(recoveredTitle !== undefined && meta.title !== recoveredTitle);
|
|
1106
|
-
meta.nextSequence =
|
|
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.#
|
|
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
|
-
|
|
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
|
}
|