fix(edit-history): bounded image probe, linear path scan (review of #693)

- probeLocalPaths: every agent-printed candidate goes through
  probePathKind() first (no pastCap, bulk scan), sequentially; only a path
  that answered 'file' gets realpath + stat. A path on a mount that stopped
  answering no longer hangs the sync, a queued sync or remove().
- PATH_TOKEN_RE: branch 4 starts only at a token boundary (`:` excluded so
  URLs stay ignored), branch 1 stops at `:`. Linear in the line length
  (30,000 `#` took ~3 s before).
- Load older yields to the event loop between the files it parses.
- GET /edits answers unsupported without loading a store.
- CLAUDE.md paragraph trimmed to the rules; probe and regex rules added to
  architecture-invariants#edit-history-panel.

Tests: stall test through the real sync and probe with a stat that never
settles; time-budget tests with 30,000-character inputs and a 139-result
base64 chunk; route test asserts no store load for unsupported sessions.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
RandalixandClaude Opus 5.5 committed 2026-11-03 09:46:23 +01:00
1 parent bf2a705705
commit de43657676
10 files changed
+229 -35

No files matched your search

+29 -19
View File
@@ -25,6 +25,7 @@
import { open, readdir, realpath, stat } from 'node:fs/promises';
import { homedir } from 'node:os';
import { basename, isAbsolute, join } from 'node:path';
import { probePathKind, type PathProbeKind } from './utils/bounded-path-probe.js';
import { claudeProjectsDir } from './utils/claude-transcript.js';
const ID_PATTERN = /^[A-Za-z0-9._-]+$/;
@@ -207,26 +208,35 @@ export async function readClaudeEditSources(
return { transcriptId, files, metas };
}
/** Per path its resolved path, kind, size and mtime, or null when it does not exist. `~/` means this user's home. */
/**
* Per path its resolved path, kind, size and mtime, or null when it is not a regular file
* (missing, a directory, or on a filesystem that did not answer). `~/` means this user's home.
*
* The paths come from Bash output, so the agent chose them: each one first goes through the
* bounded probe (`probePathKind`, no `pastCap`, this is a bulk scan), and only a path it saw
* as a file is touched by `realpath` + `stat`. A path on a mount that stopped answering
* then costs one stalled stat at most, never a sync that cannot finish (#516 rule).
* Sequential, so one sync holds at most one threadpool worker.
*/
export async function probeLocalPaths(
paths: readonly string[],
home: string = homedir()
home: string = homedir(),
probeKind: (path: string) => Promise<PathProbeKind> = probePathKind
): Promise<Array<PathProbe | null>> {
return Promise.all(
paths.map(async (p) => {
const path = p.startsWith('~/') ? join(home, p.slice(2)) : p;
try {
const real = await realpath(path);
const s = await stat(real);
return {
realPath: real,
kind: s.isFile() ? 'file' : s.isDirectory() ? 'directory' : 'other',
size: s.isFile() ? s.size : 0,
mtimeMs: Math.floor(s.mtimeMs),
} satisfies PathProbe;
} catch {
return null;
}
})
);
const out: Array<PathProbe | null> = [];
for (const p of paths) {
const path = p.startsWith('~/') ? join(home, p.slice(2)) : p;
if (!isAbsolute(path) || (await probeKind(path)) !== 'file') {
out.push(null);
continue;
}
try {
const real = await realpath(path);
const s = await stat(real);
out.push(s.isFile() ? { realPath: real, kind: 'file', size: s.size, mtimeMs: Math.floor(s.mtimeMs) } : null);
} catch {
out.push(null);
}
}
return out;
}
+10 -4
View File
@@ -104,13 +104,19 @@ const TOKEN_CHAR = '[^\\s\'"`<>|;&()=,:*?]';
const PATH_TOKEN_RE = new RegExp(
[
// 1. file:// URL (Codex: `Saved to: file:///…`) — may contain spaces up to the extension.
`file://(/[^\\n]*?\\.${IMAGE_EXT})${EXT_END}`,
// No `:` inside: it keeps the lazy scan from running to the line end from every
// `file://` of a line that repeats it (quadratic otherwise).
`file://(/[^\\n:]*?\\.${IMAGE_EXT})${EXT_END}`,
// 2./3. Quoted paths, spaces allowed.
`"([^"\\n]*\\.${IMAGE_EXT})"`,
`'([^'\\n]*\\.${IMAGE_EXT})'`,
// 4. Unquoted token. The lookbehind keeps a match from starting inside a URL, a glob
// or a longer name; the name before the extension must not be empty.
`(?<![\\w:/.~*?$-])((?:${TOKEN_CHAR}*/)?[^\\s'"\`<>|;&()=,:*?/]+\\.${IMAGE_EXT})${EXT_END}`,
// 4. Unquoted token. A match starts only at a token boundary (line start or a character
// that ends a token), never inside a URL (`:` is not a boundary), a glob or a longer
// name. Anchoring at boundaries keeps this linear: a lookbehind that only excluded
// some characters let a match start at every other character of a long token
// (`####…`, a base64 blob) and rescan the token from each, quadratic in the line
// length. The name before the extension must not be empty.
`(?<=^|[\\s'"\`<>|;&()=,*?])((?:${TOKEN_CHAR}*/)?[^\\s'"\`<>|;&()=,:*?/]+\\.${IMAGE_EXT})${EXT_END}`,
].join('|'),
'gi'
);
+6
View File
@@ -436,8 +436,14 @@ export class EditHistorySync {
this.dropMissing(store, requests, res);
const wanted = new Set(requests.map((r) => r.relPath));
const candidates: ImageCandidate[] = [];
let parsed = false;
for (const f of res.files) {
if (!wanted.has(f.relPath)) continue;
// Each file is up to one budget (8 MiB) of synchronous parsing: let the server breathe
// between them. A store removed meanwhile ignores the rest (it is inert once removed).
if (parsed) await new Promise<void>((resolve) => setImmediate(resolve));
parsed = true;
if (slot.gone) break;
const r = store.applyOlder({
transcriptId: res.transcriptId,
source: f.relPath,
+8 -5
View File
@@ -105,13 +105,16 @@ export function registerEditHistoryRoutes(app: FastifyInstance, ctx: SessionPort
const { sync: doSync } = req.query as { sync?: string };
const { session, view } = sessionOf(id, req);
const why = unsupportedReason(view);
// Never read: answer an empty list without loading (or creating) a store for it.
if (why) {
return {
success: true,
data: { state: 'unsupported', reason: why, backfillTruncated: false, agents: {}, entries: [] },
};
}
// The sync starts in the same tick as the session check: a deletion right after it marks
// the running sync gone (EditHistorySync.remove), so nothing writes a deleted session.
const status: EditHistoryStatus = why
? { state: 'unsupported', reason: why }
: doSync === '1'
? await sync.sync(view)
: sync.getStatus(session.id);
const status: EditHistoryStatus = doSync === '1' ? await sync.sync(view) : sync.getStatus(session.id);
return { success: true, data: listOf(await sync.getStore(session.id), session.workingDir, status) };
});