Skip to content

Commit ca01d1e

Browse files
useruser
authored andcommitted
fix(agent-core-v2): harden file-history checkpoints per review
Delta-encode the durable checkpoint events (unchanged entries fold back in from the previous checkpoint) so wire growth stops scaling with turns times tracked files. Make the over-budget diff approximation order-aware (greedy common subsequence) so reordered large files no longer report zero-change stats. Store and read path-keyed entries prototype-free so filenames like __proto__ record correctly.
1 parent fce5feb commit ca01d1e

3 files changed

Lines changed: 50 additions & 22 deletions

File tree

packages/agent-core-v2/src/features/fileHistory/fileHistoryOps.ts

Lines changed: 17 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -67,21 +67,35 @@ export function checkpointPhaseOf(record: {
6767
return record.phase ?? 'start';
6868
}
6969

70+
function cloneEntries(
71+
entries: Readonly<Record<string, FileBackupEntry>>,
72+
): Record<string, FileBackupEntry> {
73+
const clone: Record<string, FileBackupEntry> = Object.create(null) as Record<
74+
string,
75+
FileBackupEntry
76+
>;
77+
for (const [path, entry] of Object.entries(entries)) clone[path] = entry;
78+
return clone;
79+
}
80+
7081
export const fileHistoryKey = defineState(
7182
'fileHistory',
7283
(): FileHistoryState => ({ checkpoints: [], tracked: [] }),
7384
)
7485
.replayable({ schema: z.custom<FileHistoryState>() })
7586
.on(FileHistoryCheckpointed, (s, e) => {
7687
const phase = checkpointPhaseOf(e);
88+
const base = s.checkpoints.at(-1)?.entries;
89+
const merged = cloneEntries(base ?? {});
90+
for (const [path, entry] of Object.entries(e.entries)) merged[path] = { ...entry };
7791
const existing = s.checkpoints.find(
7892
(c) => c.turnId === e.turnId && checkpointPhaseOf(c) === phase,
7993
);
8094
if (existing !== undefined) {
81-
existing.entries = { ...e.entries };
95+
existing.entries = merged;
8296
return;
8397
}
84-
s.checkpoints.push({ turnId: e.turnId, phase, entries: { ...e.entries } });
98+
s.checkpoints.push({ turnId: e.turnId, phase, entries: merged });
8599
if (s.checkpoints.length > FILE_HISTORY_CHECKPOINT_CAP) {
86100
s.checkpoints.splice(0, s.checkpoints.length - FILE_HISTORY_CHECKPOINT_CAP);
87101
}
@@ -95,7 +109,7 @@ export const fileHistoryKey = defineState(
95109
s.checkpoints.push({ turnId: e.turnId, phase: 'start', entries: {} });
96110
checkpoint = s.checkpoints.at(-1);
97111
}
98-
if (checkpoint !== undefined && checkpoint.entries[e.path] === undefined) {
112+
if (checkpoint !== undefined && !Object.hasOwn(checkpoint.entries, e.path)) {
99113
checkpoint.entries[e.path] = { ...e.entry };
100114
}
101115
});

packages/agent-core-v2/src/features/fileHistory/fileHistoryService.ts

Lines changed: 24 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -179,25 +179,21 @@ export class AgentFileHistoryService extends Service implements IAgentFileHistor
179179
return;
180180
}
181181

182-
const entries: Record<string, FileBackupEntry> = {};
182+
const entries: Record<string, FileBackupEntry> = Object.create(null) as Record<
183+
string,
184+
FileBackupEntry
185+
>;
183186
for (const pathKey of state.tracked) {
184187
const latest = latestEntry(state.checkpoints, pathKey);
185188
const nextVersion = maxVersion(state.checkpoints, pathKey) + 1;
186189
const current = await this.readCurrent(pathKey);
187-
if (current === 'unreadable') {
188-
if (latest !== undefined) entries[pathKey] = latest;
189-
continue;
190-
}
190+
if (current === 'unreadable') continue;
191191
if (current === 'missing') {
192-
entries[pathKey] =
193-
latest?.key === null ? latest : { key: null, version: nextVersion };
192+
if (latest?.key !== null) entries[pathKey] = { key: null, version: nextVersion };
194193
continue;
195194
}
196195
const contentHash = sha256(current);
197-
if (latest !== undefined && latest.contentHash === contentHash) {
198-
entries[pathKey] = latest;
199-
continue;
200-
}
196+
if (latest !== undefined && latest.contentHash === contentHash) continue;
201197
entries[pathKey] = await this.backup(pathKey, nextVersion, current, contentHash);
202198
}
203199

@@ -292,8 +288,8 @@ function entryAt(
292288
path: string,
293289
): FileBackupEntry | undefined {
294290
for (let i = index; i >= 0; i -= 1) {
295-
const entry = checkpoints[i]!.entries[path];
296-
if (entry !== undefined) return entry;
291+
const record = checkpoints[i]!.entries;
292+
if (Object.hasOwn(record, path)) return record[path];
297293
}
298294
return undefined;
299295
}
@@ -311,7 +307,7 @@ function maxVersion(
311307
): number {
312308
let max = 0;
313309
for (const checkpoint of checkpoints) {
314-
const entry = checkpoint.entries[path];
310+
const entry = Object.hasOwn(checkpoint.entries, path) ? checkpoint.entries[path] : undefined;
315311
if (entry !== undefined && entry.version > max) max = entry.version;
316312
}
317313
return max;
@@ -430,14 +426,23 @@ const LCS_CELL_BUDGET = 4_000_000;
430426
function lcsLength(a: readonly string[], b: readonly string[]): number {
431427
if (a.length === 0 || b.length === 0) return 0;
432428
if (a.length * b.length > LCS_CELL_BUDGET) {
433-
const remaining = new Map<string, number>();
434-
for (const line of b) remaining.set(line, (remaining.get(line) ?? 0) + 1);
435429
let common = 0;
430+
const positions = new Map<string, number[]>();
431+
for (let j = b.length - 1; j >= 0; j -= 1) {
432+
const line = b[j]!;
433+
const list = positions.get(line);
434+
if (list === undefined) positions.set(line, [j]);
435+
else list.push(j);
436+
}
437+
let cursor = 0;
436438
for (const line of a) {
437-
const left = remaining.get(line) ?? 0;
438-
if (left > 0) {
439+
const list = positions.get(line);
440+
if (list === undefined) continue;
441+
while (list.length > 0 && list[list.length - 1]! < cursor) list.pop();
442+
const match = list.pop();
443+
if (match !== undefined) {
439444
common += 1;
440-
remaining.set(line, left - 1);
445+
cursor = match + 1;
441446
}
442447
}
443448
return common;

packages/agent-core-v2/test/features/fileHistory/fileHistory.test.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -324,6 +324,15 @@ describe('AgentFileHistoryService', () => {
324324
expect(diff.deletions).toBe(901);
325325
});
326326

327+
it('keeps over-budget diff approximations order-aware on reordered files', () => {
328+
const lines = Array.from({ length: 3000 }, (_, i) => `line-${String(i)}`);
329+
const before = [...lines, 'tail-old'].join('\n');
330+
const after = ['head-new', ...lines.toReversed()].join('\n');
331+
const diff = countLineDiff(before, after);
332+
expect(diff.additions).toBeGreaterThan(2000);
333+
expect(diff.deletions).toBeGreaterThan(2000);
334+
});
335+
327336
it('stays inactive on subagents', async () => {
328337
const service = createService('sub-1');
329338
setFile('/ws/a.txt', 'content\n');

0 commit comments

Comments
 (0)