@myagentroam/agent 0.11.55 → 0.11.57
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/runtime/context/gc-candidates-code.js +5 -6
- package/dist/runtime/context/gc-candidates-exec.js +2 -2
- package/dist/runtime/context/gc-candidates-read-patch.js +3 -3
- package/dist/runtime/context/gc-candidates-web.js +6 -8
- package/dist/runtime/context/gc-economics.d.ts +13 -19
- package/dist/runtime/context/gc-economics.js +18 -66
- package/dist/runtime/context/gc.d.ts +1 -1
- package/dist/runtime/context/gc.js +5 -91
- package/dist/session/context-gc-checkpoint.js +12 -12
- package/dist/session/context-gc-contract.d.ts +5 -4
- package/dist/session/context-gc-protection.d.ts +5 -0
- package/dist/session/context-gc-protection.js +4 -0
- package/dist/session/context-gc-replacements.js +4 -4
- package/dist/session/store/jsonl/fork-session.js +38 -3
- package/dist/session/store/jsonl/history-replacement.d.ts +2 -1
- package/dist/session/store/jsonl/history-replacement.js +16 -5
- package/dist/session/store/jsonl/history.d.ts +5 -0
- package/dist/session/store/jsonl/history.js +84 -24
- package/dist/session/store/jsonl/jsonl-store.js +25 -8
- package/dist/session/store/jsonl/transaction-files.d.ts +2 -0
- package/dist/session/store/jsonl/transaction-files.js +22 -0
- package/dist/session/store/jsonl/transaction-recovery.js +11 -14
- package/dist/session/tool-result-resources.d.ts +10 -1
- package/dist/session/tool-result-resources.js +109 -38
- package/package.json +1 -1
|
@@ -1,4 +1,8 @@
|
|
|
1
1
|
import { completedNativeThinkingIdentity } from '../model/native-context.js';
|
|
2
|
+
export const CONTEXT_GC_TEXT_PROTECTION_ROLLOUTS = 10;
|
|
3
|
+
export function contextGcProtectionMature(count) {
|
|
4
|
+
return count.rollouts >= CONTEXT_GC_TEXT_PROTECTION_ROLLOUTS && count.thinking >= 3;
|
|
5
|
+
}
|
|
2
6
|
/** One immutable boundary index, shared by all candidate anchors in this evaluation. */
|
|
3
7
|
export function buildContextGcProtection(records, modelId, contextWindowTokens) {
|
|
4
8
|
const configurations = new Map();
|
|
@@ -24,7 +24,7 @@ export function contextGcCodeModeInputReplacement(input, retainedInputs = []) {
|
|
|
24
24
|
originalBytes: Buffer.byteLength(input.code),
|
|
25
25
|
lines: lineCount(input.code),
|
|
26
26
|
inputMode: input.inputMode,
|
|
27
|
-
contextGc: 'Completed Code Mode source was reclaimed after
|
|
27
|
+
contextGc: 'Completed Code Mode source was reclaimed after the protection period. The paired output, images, completion evidence, and nested tool side effects remain in history.',
|
|
28
28
|
retainedInputs
|
|
29
29
|
});
|
|
30
30
|
return input.inputMode === 'freeform' ? content : { code: content };
|
|
@@ -37,7 +37,7 @@ export function contextGcCodeModeOutputReplacement(originalContent, truncated, v
|
|
|
37
37
|
truncated,
|
|
38
38
|
preview,
|
|
39
39
|
previewComplete: !truncated && preview === originalContent,
|
|
40
|
-
contextGc: 'Detailed Code Mode output was reclaimed after
|
|
40
|
+
contextGc: 'Detailed Code Mode output was reclaimed after the protection period. An incomplete preview is not the complete cell output. The source was not replayed and nested tool side effects were not changed.'
|
|
41
41
|
});
|
|
42
42
|
}
|
|
43
43
|
export function contextGcPatchResultFromCompletedPayload(payload) {
|
|
@@ -111,7 +111,7 @@ export function contextGcWebOutputReplacement(toolName, originalContent, truncat
|
|
|
111
111
|
truncated,
|
|
112
112
|
preview,
|
|
113
113
|
previewComplete: preview === originalContent,
|
|
114
|
-
contextGc: 'Detailed web tool output was reclaimed after
|
|
114
|
+
contextGc: 'Detailed web tool output was reclaimed after the protection period. An incomplete preview is not the complete search result or page, and GC did not access the network.'
|
|
115
115
|
});
|
|
116
116
|
}
|
|
117
117
|
/** Tools with their own evidence and lifecycle policies are never handled as generic output. */
|
|
@@ -142,7 +142,7 @@ export function contextGcToolOutputReplacement(toolName, originalContent, trunca
|
|
|
142
142
|
truncated,
|
|
143
143
|
preview,
|
|
144
144
|
previewComplete: !truncated && preview === originalContent,
|
|
145
|
-
contextGc: 'Detailed tool output was reclaimed after
|
|
145
|
+
contextGc: 'Detailed tool output was reclaimed after the protection period. An incomplete preview is not the complete result; the tool was not replayed.'
|
|
146
146
|
});
|
|
147
147
|
}
|
|
148
148
|
export function contextGcSkillOutputReplacement(name, originalContent, successorCallId) {
|
|
@@ -3,7 +3,7 @@ import { mkdir, rename, rm } from 'node:fs/promises';
|
|
|
3
3
|
import { join } from 'node:path';
|
|
4
4
|
import { AgentError } from '../../../error.js';
|
|
5
5
|
import { inlineMediaHistory } from '../../inline-media.js';
|
|
6
|
-
import {
|
|
6
|
+
import { prepareToolResultResources, writeToolResultResourceIndex } from '../../tool-result-resources.js';
|
|
7
7
|
import { syncJsonlDirectory } from './fs-durability.js';
|
|
8
8
|
import { forkedPayload, stageSessionSegments } from './history.js';
|
|
9
9
|
import { copyGeneratedImages, generatedImageFileNames } from './images.js';
|
|
@@ -63,14 +63,49 @@ export async function forkSessionOperation(source, records, input, sourceRoot, s
|
|
|
63
63
|
}
|
|
64
64
|
];
|
|
65
65
|
const retained = input.boundaryTurnId === null ? [] : records.slice(1, boundaryIndex + 1);
|
|
66
|
+
const resourcesByOwner = new Map();
|
|
67
|
+
for (const [resourceRef, { owner, bytes }] of await prepareToolResultResources(sourceDirectory, retained)) {
|
|
68
|
+
const resources = resourcesByOwner.get(owner) ?? [];
|
|
69
|
+
resources.push({ resourceRef, bytes });
|
|
70
|
+
resourcesByOwner.set(owner, resources);
|
|
71
|
+
}
|
|
72
|
+
const resourceLocators = new Map();
|
|
66
73
|
const inlineState = inlineMediaHistory(retained).state;
|
|
67
74
|
const stateAnchor = inlineState &&
|
|
68
75
|
retained.findLast((record) => record.type === 'resource.window' || record.type === 'compact.completed');
|
|
69
76
|
const forkIdentities = new Map();
|
|
70
77
|
for (const record of retained) {
|
|
71
|
-
if (record.type === 'context.gc.completed' ||
|
|
78
|
+
if (record.type === 'context.gc.completed' ||
|
|
79
|
+
record.type === 'responses.continuation' ||
|
|
80
|
+
record.type === 'tool.resource')
|
|
72
81
|
continue;
|
|
82
|
+
const pendingLocators = [];
|
|
83
|
+
for (const { resourceRef, bytes } of resourcesByOwner.get(record.recordId) ?? []) {
|
|
84
|
+
const bodySequences = [];
|
|
85
|
+
// Small private records share normal segment durability and rollback boundaries.
|
|
86
|
+
for (let offset = 0; offset < Math.max(1, bytes.length); offset += 1024 * 1024) {
|
|
87
|
+
const sequence = copied.length + 1;
|
|
88
|
+
bodySequences.push(sequence);
|
|
89
|
+
copied.push({
|
|
90
|
+
recordId: randomUUID(),
|
|
91
|
+
sequence,
|
|
92
|
+
sessionId: target.id,
|
|
93
|
+
type: 'tool.resource',
|
|
94
|
+
payload: {
|
|
95
|
+
resourceRef,
|
|
96
|
+
dataBase64: bytes.subarray(offset, offset + 1024 * 1024).toString('base64')
|
|
97
|
+
},
|
|
98
|
+
...(record.executionId === undefined ? {} : { executionId: record.executionId }),
|
|
99
|
+
...(record.turnId === undefined ? {} : { turnId: record.turnId }),
|
|
100
|
+
timestamp: record.timestamp
|
|
101
|
+
});
|
|
102
|
+
}
|
|
103
|
+
pendingLocators.push({ resourceRef, bodySequences });
|
|
104
|
+
}
|
|
105
|
+
resourcesByOwner.delete(record.recordId);
|
|
73
106
|
const identity = { recordId: randomUUID(), sequence: copied.length + 1 };
|
|
107
|
+
for (const { resourceRef, bodySequences } of pendingLocators)
|
|
108
|
+
resourceLocators.set(resourceRef, { ...identity, bodySequences });
|
|
74
109
|
forkIdentities.set(record.recordId, identity);
|
|
75
110
|
copied.push({
|
|
76
111
|
...identity,
|
|
@@ -95,7 +130,7 @@ export async function forkSessionOperation(source, records, input, sourceRoot, s
|
|
|
95
130
|
await rename(file.temporaryPath, file.path);
|
|
96
131
|
target.segments = staged.segments;
|
|
97
132
|
target.nextSequence = copied.length + 1;
|
|
98
|
-
await
|
|
133
|
+
await writeToolResultResourceIndex(targetDirectory, resourceLocators);
|
|
99
134
|
await copyGeneratedImages(sourceRoot, publication.directory, generatedImageFileNames(retained));
|
|
100
135
|
await copyResources(sourceDirectory, targetDirectory, retained);
|
|
101
136
|
for (const path of [
|
|
@@ -39,8 +39,9 @@ export type HistoryIndexOutcome = {
|
|
|
39
39
|
readonly error: unknown;
|
|
40
40
|
};
|
|
41
41
|
/** Execute one rollback/clear transaction while its caller holds maintenance. */
|
|
42
|
-
export declare function replaceSessionHistory(meta: SessionMeta,
|
|
42
|
+
export declare function replaceSessionHistory(meta: SessionMeta, prefix: readonly SessionRecord[], operation: {
|
|
43
43
|
kind: 'rollback';
|
|
44
|
+
reuseSegments: boolean;
|
|
44
45
|
} | {
|
|
45
46
|
kind: 'clear';
|
|
46
47
|
clearedAt: string;
|
|
@@ -4,17 +4,26 @@ import { basename, join } from 'node:path';
|
|
|
4
4
|
import { toolResultResourceFiles } from '../../tool-result-resources.js';
|
|
5
5
|
import { beginSessionStorageTransaction, retiredSessionTrashPath, recoverSessionStorageTransaction } from './transaction-recovery.js';
|
|
6
6
|
import { deriveExecutionState } from '../execution-state.js';
|
|
7
|
-
import { renameIfExists, retainedSessionTitle, stageSessionSegments, stageUnreferencedImages } from './history.js';
|
|
7
|
+
import { renameIfExists, retainedSessionTitle, stageRollbackSegments, stageSessionSegments, stageUnreferencedImages } from './history.js';
|
|
8
8
|
import { generatedImageFileNames } from './images.js';
|
|
9
9
|
import { CONTEXTUAL_WORLD_STATE_FILE } from './open-recovery.js';
|
|
10
10
|
import { resourceFiles } from './resources.js';
|
|
11
11
|
import { syncJsonlDirectory } from './fs-durability.js';
|
|
12
12
|
/** Execute one rollback/clear transaction while its caller holds maintenance. */
|
|
13
|
-
export async function replaceSessionHistory(meta,
|
|
13
|
+
export async function replaceSessionHistory(meta, prefix, operation, context) {
|
|
14
|
+
const retained = operation.kind === 'rollback'
|
|
15
|
+
? prefix.filter((record) => record.type !== 'responses.continuation')
|
|
16
|
+
: prefix;
|
|
14
17
|
const targetDirectory = context.targetGeneration === undefined
|
|
15
18
|
? context.directory
|
|
16
19
|
: join(context.sessionDirectory, 'v2', context.targetGeneration);
|
|
17
|
-
const
|
|
20
|
+
const incremental = operation.kind === 'rollback' &&
|
|
21
|
+
operation.reuseSegments &&
|
|
22
|
+
meta.storage?.encoding === 2 &&
|
|
23
|
+
meta.segments !== undefined;
|
|
24
|
+
const staged = incremental
|
|
25
|
+
? await stageRollbackSegments(targetDirectory, prefix, meta.segments)
|
|
26
|
+
: await stageSessionSegments(targetDirectory, retained);
|
|
18
27
|
const transactionId = randomUUID();
|
|
19
28
|
const targetNextSequence = operation.kind === 'clear' ? 2 : (retained.at(-1)?.sequence ?? 0) + 1;
|
|
20
29
|
const contextualWorldStatePath = join(context.directory, CONTEXTUAL_WORLD_STATE_FILE);
|
|
@@ -22,7 +31,9 @@ export async function replaceSessionHistory(meta, retained, operation, context)
|
|
|
22
31
|
const originalTransactionId = meta.storageTransactionId;
|
|
23
32
|
const originalPaths = await context.segmentPaths();
|
|
24
33
|
const retiredPaths = await retiredSessionPaths(context.sessionDirectory, context.directory, targetDirectory);
|
|
25
|
-
const backups = originalPaths
|
|
34
|
+
const backups = originalPaths
|
|
35
|
+
.filter((path) => !staged.reused?.has(basename(path)))
|
|
36
|
+
.map((path) => ({
|
|
26
37
|
path,
|
|
27
38
|
backup: `${path}.${transactionId}.${operation.kind}-backup`
|
|
28
39
|
}));
|
|
@@ -30,7 +41,7 @@ export async function replaceSessionHistory(meta, retained, operation, context)
|
|
|
30
41
|
version: 1,
|
|
31
42
|
id: transactionId,
|
|
32
43
|
kind: operation.kind,
|
|
33
|
-
originalSegments:
|
|
44
|
+
originalSegments: backups.map((item) => basename(item.path)),
|
|
34
45
|
stagedSegments: staged.files.map((item) => ({
|
|
35
46
|
path: basename(item.path),
|
|
36
47
|
temporary: basename(item.temporaryPath)
|
|
@@ -6,8 +6,13 @@ interface StagedSessionSegments {
|
|
|
6
6
|
readonly temporaryPath: string;
|
|
7
7
|
}[];
|
|
8
8
|
readonly segments: NonNullable<SessionMeta['segments']>;
|
|
9
|
+
readonly reused?: ReadonlySet<string>;
|
|
9
10
|
}
|
|
10
11
|
export declare function stageSessionSegments(sessionDirectory: string, records: readonly SessionRecord[], operation?: 'rollback' | 'fork'): Promise<StagedSessionSegments>;
|
|
12
|
+
/** Reuse intact files in ordered v2 histories; journal only replacements and removals. */
|
|
13
|
+
export declare function stageRollbackSegments(directory: string, prefix: readonly SessionRecord[], originals: NonNullable<SessionMeta['segments']>): Promise<StagedSessionSegments & {
|
|
14
|
+
readonly reused: ReadonlySet<string>;
|
|
15
|
+
}>;
|
|
11
16
|
export declare function stageUnreferencedImages(sessionDirectory: string, directoryName: 'generated_images' | 'tool_results' | 'resources', retained: ReadonlySet<string>, transactionId: string): Promise<Array<{
|
|
12
17
|
from: string;
|
|
13
18
|
to: string;
|
|
@@ -4,6 +4,7 @@ import { join } from 'node:path';
|
|
|
4
4
|
import { AgentError } from '../../../error.js';
|
|
5
5
|
import { parseInlineMediaState } from '../../inline-media.js';
|
|
6
6
|
import { MAX_SEGMENT_BYTES } from './records.js';
|
|
7
|
+
import { forEachTransactionFile } from './transaction-files.js';
|
|
7
8
|
export async function renameIfExists(from, to) {
|
|
8
9
|
try {
|
|
9
10
|
await rename(from, to);
|
|
@@ -26,23 +27,9 @@ export async function stageSessionSegments(sessionDirectory, records, operation
|
|
|
26
27
|
if (group === undefined)
|
|
27
28
|
return;
|
|
28
29
|
const number = segments.length + 1;
|
|
29
|
-
const
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
files.push({ path, temporaryPath });
|
|
33
|
-
try {
|
|
34
|
-
await handle.writeFile(group.lines.join(''));
|
|
35
|
-
await handle.sync();
|
|
36
|
-
}
|
|
37
|
-
finally {
|
|
38
|
-
await handle.close();
|
|
39
|
-
}
|
|
40
|
-
segments.push({
|
|
41
|
-
number,
|
|
42
|
-
firstSequence: group.firstSequence,
|
|
43
|
-
lastSequence: group.lastSequence,
|
|
44
|
-
bytes: group.bytes
|
|
45
|
-
});
|
|
30
|
+
const staged = await writeStagedSegment(sessionDirectory, group, number, transactionId, operation);
|
|
31
|
+
files.push(staged.file);
|
|
32
|
+
segments.push(staged.segment);
|
|
46
33
|
group = undefined;
|
|
47
34
|
};
|
|
48
35
|
try {
|
|
@@ -67,10 +54,86 @@ export async function stageSessionSegments(sessionDirectory, records, operation
|
|
|
67
54
|
return { files, segments };
|
|
68
55
|
}
|
|
69
56
|
catch (error) {
|
|
70
|
-
await
|
|
57
|
+
await forEachTransactionFile(files, (file) => rm(file.temporaryPath, { force: true }).catch(() => undefined));
|
|
58
|
+
throw error;
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
/** Reuse intact files in ordered v2 histories; journal only replacements and removals. */
|
|
62
|
+
export async function stageRollbackSegments(directory, prefix, originals) {
|
|
63
|
+
const files = [];
|
|
64
|
+
const segments = [];
|
|
65
|
+
const reused = new Set();
|
|
66
|
+
const transactionId = randomUUID();
|
|
67
|
+
let offset = 0;
|
|
68
|
+
try {
|
|
69
|
+
for (const original of originals) {
|
|
70
|
+
const start = offset;
|
|
71
|
+
while (offset < prefix.length && prefix[offset].sequence <= original.lastSequence)
|
|
72
|
+
offset++;
|
|
73
|
+
const source = prefix.slice(start, offset);
|
|
74
|
+
if (source.length === 0)
|
|
75
|
+
break;
|
|
76
|
+
const retained = source.filter((record) => record.type !== 'responses.continuation');
|
|
77
|
+
if (retained.length === source.length && source.at(-1).sequence === original.lastSequence) {
|
|
78
|
+
reused.add(`events-${String(original.number).padStart(6, '0')}.jsonl`);
|
|
79
|
+
segments.push({ ...original });
|
|
80
|
+
}
|
|
81
|
+
else if (retained.length > 0) {
|
|
82
|
+
const lines = retained.map((record) => `${JSON.stringify(record)}\n`);
|
|
83
|
+
const staged = await writeStagedSegment(directory, {
|
|
84
|
+
lines,
|
|
85
|
+
bytes: lines.reduce((total, line) => total + Buffer.byteLength(line), 0),
|
|
86
|
+
firstSequence: retained[0].sequence,
|
|
87
|
+
lastSequence: retained.at(-1).sequence
|
|
88
|
+
}, original.number, transactionId, 'rollback');
|
|
89
|
+
files.push(staged.file);
|
|
90
|
+
segments.push(staged.segment);
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
return { files, segments, reused };
|
|
94
|
+
}
|
|
95
|
+
catch (error) {
|
|
96
|
+
await forEachTransactionFile(files, (file) => rm(file.temporaryPath, { force: true }).catch(() => undefined));
|
|
71
97
|
throw error;
|
|
72
98
|
}
|
|
73
99
|
}
|
|
100
|
+
async function writeStagedSegment(directory, group, number, transactionId, operation) {
|
|
101
|
+
const path = join(directory, `events-${String(number).padStart(6, '0')}.jsonl`);
|
|
102
|
+
const temporaryPath = `${path}.${transactionId}.${operation}-tmp`;
|
|
103
|
+
const handle = await open(temporaryPath, 'wx', 0o600);
|
|
104
|
+
let failed = false;
|
|
105
|
+
let failure;
|
|
106
|
+
try {
|
|
107
|
+
await handle.writeFile(group.lines.join(''));
|
|
108
|
+
await handle.sync();
|
|
109
|
+
}
|
|
110
|
+
catch (error) {
|
|
111
|
+
failed = true;
|
|
112
|
+
failure = error;
|
|
113
|
+
}
|
|
114
|
+
try {
|
|
115
|
+
await handle.close();
|
|
116
|
+
}
|
|
117
|
+
catch (error) {
|
|
118
|
+
if (!failed) {
|
|
119
|
+
failed = true;
|
|
120
|
+
failure = error;
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
if (failed) {
|
|
124
|
+
await rm(temporaryPath, { force: true }).catch(() => undefined);
|
|
125
|
+
throw failure;
|
|
126
|
+
}
|
|
127
|
+
return {
|
|
128
|
+
file: { path, temporaryPath },
|
|
129
|
+
segment: {
|
|
130
|
+
number,
|
|
131
|
+
firstSequence: group.firstSequence,
|
|
132
|
+
lastSequence: group.lastSequence,
|
|
133
|
+
bytes: group.bytes
|
|
134
|
+
}
|
|
135
|
+
};
|
|
136
|
+
}
|
|
74
137
|
export async function stageUnreferencedImages(sessionDirectory, directoryName, retained, transactionId) {
|
|
75
138
|
const directory = join(sessionDirectory, directoryName);
|
|
76
139
|
let names;
|
|
@@ -84,14 +147,12 @@ export async function stageUnreferencedImages(sessionDirectory, directoryName, r
|
|
|
84
147
|
}
|
|
85
148
|
const moved = [];
|
|
86
149
|
try {
|
|
87
|
-
|
|
88
|
-
if (retained.has(name))
|
|
89
|
-
continue;
|
|
150
|
+
await forEachTransactionFile(names.filter((name) => !retained.has(name)), async (name) => {
|
|
90
151
|
const from = join(directory, name);
|
|
91
152
|
const trash = join(directory, `.${name}.${transactionId}.rollback-trash`);
|
|
92
153
|
await rename(from, trash);
|
|
93
154
|
moved.push({ from: trash, to: from, trash });
|
|
94
|
-
}
|
|
155
|
+
});
|
|
95
156
|
return moved;
|
|
96
157
|
}
|
|
97
158
|
catch (error) {
|
|
@@ -100,8 +161,7 @@ export async function stageUnreferencedImages(sessionDirectory, directoryName, r
|
|
|
100
161
|
}
|
|
101
162
|
}
|
|
102
163
|
export async function restoreRenamedFiles(files) {
|
|
103
|
-
|
|
104
|
-
await rename(file.from, file.to).catch(() => undefined);
|
|
164
|
+
await forEachTransactionFile(files, (file) => rename(file.from, file.to).catch(() => undefined));
|
|
105
165
|
}
|
|
106
166
|
export function retainedSessionTitle(records, fallback) {
|
|
107
167
|
let title = fallback;
|
|
@@ -698,14 +698,28 @@ export class JsonlSessionStore {
|
|
|
698
698
|
const meta = this.#shared.index.sessions.find((item) => item.id === sessionId);
|
|
699
699
|
if (!meta)
|
|
700
700
|
throw new AgentError('MAR_AGENT_SESSION_NOT_FOUND', 'Session was not found.');
|
|
701
|
-
const
|
|
701
|
+
const indexedSegments = meta.segments;
|
|
702
|
+
let segmentIndex = 0;
|
|
703
|
+
let matchingSegments = indexedSegments !== undefined;
|
|
704
|
+
// Check optional cache bounds during the history read already required by rollback.
|
|
705
|
+
const records = await this.#serializeRecordOperation(sessionId, () => this.#readRecords(sessionId, undefined, (path, segment) => {
|
|
706
|
+
const indexed = indexedSegments?.[segmentIndex];
|
|
707
|
+
segmentIndex++;
|
|
708
|
+
matchingSegments &&=
|
|
709
|
+
indexed !== undefined &&
|
|
710
|
+
path === this.#segmentPath(sessionId, indexed.number, meta) &&
|
|
711
|
+
segment[0]?.sequence === indexed.firstSequence &&
|
|
712
|
+
segment.at(-1)?.sequence === indexed.lastSequence;
|
|
713
|
+
}));
|
|
702
714
|
if (records[0]?.type !== 'session.created')
|
|
703
715
|
throw new AgentError('MAR_AGENT_SESSION_CORRUPT', 'Session history is corrupt.');
|
|
704
716
|
const boundaryIndex = rollbackBoundaryIndex(records, input);
|
|
705
|
-
const retained = records
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
|
|
717
|
+
const retained = records.slice(0, boundaryIndex + 1);
|
|
718
|
+
// Migrated v1 histories may preserve duplicate or non-increasing sequences.
|
|
719
|
+
const reuseSegments = matchingSegments &&
|
|
720
|
+
segmentIndex === indexedSegments?.length &&
|
|
721
|
+
records.every((record, index) => index === 0 || record.sequence > records[index - 1].sequence);
|
|
722
|
+
return await this.#replaceHistory(meta, retained, { kind: 'rollback', reuseSegments });
|
|
709
723
|
}
|
|
710
724
|
catch (error) {
|
|
711
725
|
failed = true;
|
|
@@ -1110,7 +1124,7 @@ export class JsonlSessionStore {
|
|
|
1110
1124
|
async read(sessionId) {
|
|
1111
1125
|
return this.#serializeRecordOperation(sessionId, () => this.#readRecords(sessionId));
|
|
1112
1126
|
}
|
|
1113
|
-
async #readRecords(sessionId, selectedMeta) {
|
|
1127
|
+
async #readRecords(sessionId, selectedMeta, onSegment) {
|
|
1114
1128
|
return withSessionReadPin(this.#sessionRoot(sessionId), async () => {
|
|
1115
1129
|
this.#assertOpen();
|
|
1116
1130
|
if (!selectedMeta)
|
|
@@ -1125,8 +1139,11 @@ export class JsonlSessionStore {
|
|
|
1125
1139
|
const records = [];
|
|
1126
1140
|
const meta = selectedMeta ?? this.#shared.index.sessions.find((item) => item.id === sessionId);
|
|
1127
1141
|
const encoding = meta?.storage?.encoding ?? 1;
|
|
1128
|
-
for (const path of await this.#segmentPaths(sessionId, meta))
|
|
1129
|
-
|
|
1142
|
+
for (const path of await this.#segmentPaths(sessionId, meta)) {
|
|
1143
|
+
const segment = await readRecords(path, encoding);
|
|
1144
|
+
onSegment?.(path, segment);
|
|
1145
|
+
records.push(...segment);
|
|
1146
|
+
}
|
|
1130
1147
|
return records;
|
|
1131
1148
|
}
|
|
1132
1149
|
catch (cause) {
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
/** Stop scheduling on the first failure, then drain all in-flight filesystem operations. */
|
|
2
|
+
export async function forEachTransactionFile(files, operation) {
|
|
3
|
+
let next = 0;
|
|
4
|
+
let failed = false;
|
|
5
|
+
let failure;
|
|
6
|
+
await Promise.all(Array.from({ length: Math.min(8, files.length) }, async () => {
|
|
7
|
+
while (!failed && next < files.length) {
|
|
8
|
+
const file = files[next++];
|
|
9
|
+
try {
|
|
10
|
+
await operation(file);
|
|
11
|
+
}
|
|
12
|
+
catch (error) {
|
|
13
|
+
if (!failed) {
|
|
14
|
+
failed = true;
|
|
15
|
+
failure = error;
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
}));
|
|
20
|
+
if (failed)
|
|
21
|
+
throw failure;
|
|
22
|
+
}
|
|
@@ -2,6 +2,7 @@ import { randomUUID } from 'node:crypto';
|
|
|
2
2
|
import { lstat, mkdir, readFile, readdir, rename, rm, writeFile } from 'node:fs/promises';
|
|
3
3
|
import { basename, dirname, join } from 'node:path';
|
|
4
4
|
import { AgentError } from '../../../error.js';
|
|
5
|
+
import { forEachTransactionFile } from './transaction-files.js';
|
|
5
6
|
const JOURNAL = 'storage-transaction.json';
|
|
6
7
|
const SEGMENT = /^events-\d{6}\.jsonl$/u;
|
|
7
8
|
const TRANSACTION_ID = /^[0-9a-f-]{36}$/iu;
|
|
@@ -291,24 +292,24 @@ export async function recoverSessionStorageTransaction(directory, committedTrans
|
|
|
291
292
|
? directory
|
|
292
293
|
: join(sessionDirectory, 'v2', transaction.targetGeneration);
|
|
293
294
|
const originals = new Set(transaction.originalSegments);
|
|
294
|
-
|
|
295
|
+
await forEachTransactionFile([...originals], async (name) => {
|
|
295
296
|
const canonical = join(directory, name);
|
|
296
297
|
const backup = `${canonical}.${transaction.id}.${transaction.kind}-backup`;
|
|
297
|
-
if (!(await exists(backup)))
|
|
298
|
-
continue;
|
|
299
298
|
if (committed)
|
|
300
299
|
await rm(backup, { force: true });
|
|
301
300
|
else {
|
|
301
|
+
if (!(await exists(backup)))
|
|
302
|
+
return;
|
|
302
303
|
await rm(canonical, { force: true });
|
|
303
304
|
await rename(backup, canonical);
|
|
304
305
|
}
|
|
305
|
-
}
|
|
306
|
+
});
|
|
306
307
|
if (!committed)
|
|
307
|
-
|
|
308
|
+
await forEachTransactionFile(transaction.stagedSegments, async (entry) => {
|
|
308
309
|
if (stagedDirectory !== directory || !originals.has(entry.path))
|
|
309
310
|
await rm(join(stagedDirectory, entry.path), { force: true });
|
|
310
|
-
|
|
311
|
-
|
|
311
|
+
});
|
|
312
|
+
await forEachTransactionFile(transaction.stagedSegments, (entry) => rm(join(stagedDirectory, entry.temporary), { force: true }));
|
|
312
313
|
if (!committed && transaction.targetGeneration !== undefined)
|
|
313
314
|
await rm(stagedDirectory, { recursive: true, force: true });
|
|
314
315
|
const sidecar = join(directory, 'context-world-state.json');
|
|
@@ -333,15 +334,13 @@ export async function recoverSessionStorageTransaction(directory, committedTrans
|
|
|
333
334
|
throw error;
|
|
334
335
|
}
|
|
335
336
|
const suffix = `.${transaction.id}.rollback-trash`;
|
|
336
|
-
|
|
337
|
-
if (!name.startsWith('.') || !name.endsWith(suffix))
|
|
338
|
-
continue;
|
|
337
|
+
await forEachTransactionFile(names.filter((name) => name.startsWith('.') && name.endsWith(suffix)), async (name) => {
|
|
339
338
|
const trash = join(path, name);
|
|
340
339
|
if (committed)
|
|
341
340
|
await rm(trash, { force: true });
|
|
342
341
|
else
|
|
343
342
|
await rename(trash, join(path, name.slice(1, -suffix.length)));
|
|
344
|
-
}
|
|
343
|
+
});
|
|
345
344
|
}
|
|
346
345
|
for (const path of transaction.retiredPaths ?? []) {
|
|
347
346
|
const source = join(sessionDirectory, path);
|
|
@@ -357,9 +356,7 @@ export async function recoverSessionStorageTransaction(directory, committedTrans
|
|
|
357
356
|
await removeUnjournaledTransactionFiles(directory);
|
|
358
357
|
}
|
|
359
358
|
async function removeUnjournaledTransactionFiles(directory) {
|
|
360
|
-
|
|
361
|
-
if (isUnjournaledTransactionFile(name))
|
|
362
|
-
await rm(join(directory, name), { force: true });
|
|
359
|
+
await forEachTransactionFile((await readdir(directory)).filter(isUnjournaledTransactionFile), (name) => rm(join(directory, name), { force: true }));
|
|
363
360
|
}
|
|
364
361
|
async function exists(path) {
|
|
365
362
|
try {
|
|
@@ -20,7 +20,16 @@ export declare function recordToolResources(record: Pick<SessionRecord, 'type' |
|
|
|
20
20
|
/** A locator is only a hint: reads must verify its exact committed record identity. */
|
|
21
21
|
export declare function bindToolResultResources(directory: string, record: SessionRecord, readRecord?: (sequence: number) => Promise<SessionRecord | undefined>, degrade?: boolean): Promise<void>;
|
|
22
22
|
export declare function toolResultResourceFiles(records: readonly SessionRecord[]): Set<string>;
|
|
23
|
-
export
|
|
23
|
+
export interface ToolResourceLocator {
|
|
24
|
+
sequence: number;
|
|
25
|
+
recordId: string;
|
|
26
|
+
bodySequences?: number[];
|
|
27
|
+
}
|
|
28
|
+
export declare function prepareToolResultResources(source: string, records: readonly SessionRecord[]): Promise<Map<string, {
|
|
29
|
+
owner: string;
|
|
30
|
+
bytes: Buffer;
|
|
31
|
+
}>>;
|
|
32
|
+
export declare function writeToolResultResourceIndex(target: string, locators: ReadonlyMap<string, ToolResourceLocator>): Promise<void>;
|
|
24
33
|
export declare function completeToolResultResources(directory: string, record: SessionRecord): Promise<void>;
|
|
25
34
|
export declare function cleanupUnreferencedToolResources(directory: string, readRecords: () => Promise<readonly SessionRecord[]>): Promise<void>;
|
|
26
35
|
/** Caller must hold a stable history and resource generation for the entire read. */
|