fix(edit-history): review round 2 — whole lines around skipped ones, full-budget reads, orphan sweep

- Forward: after a line longer than the budget, the next line is read from
  its start instead of being skipped with it when it crosses the chunk end.
- Backward: the line before a skipped long line is read whole; a floor that
  turned out to be a line start no longer stalls.
- A chunk the shared forward budget cut short is not applied (it read as one
  over-long line) but read in a later pass, so a first open of a long main
  transcript reaches the subagents in the same sync.
- Load older reads a full budget block of up to three transcripts per click,
  taking turns, instead of splitting one budget (a normal Write longer than
  the share was skipped for good).
- A subagent transcript that vanished loses its cursor, so Load older does not
  stay on forever.
- POST /api/cleanup-state forgets the history of every record it drops; a
  startup sweep deletes edit-history files no session or record owns and
  stale tmp files of other processes.
- load() rebuilds entries from known fields (string diff lines, per-edit cap
  re-applied, ids counted against the byte cap), bounds pending Bash fields
  and agent ids, and does not parse a file over 32 MiB.
- No re-read after Codeman's own keep-alive turn.
- Tests: regression tests for each of the above (they fail on the previous
  commit); the remove-during-write test now meets the write under way; the
  sync and route tests remove their stores before the temp dir, so no late
  save recreates it in /tmp.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
RandalixandClaude Opus 5.5 committed 2026-10-23 15:00:43 +02:00
1 parent f5ce80dce2
commit 321268754a
15 files changed
+540 -89

No files matched your search

+23 -11
View File
@@ -149,22 +149,20 @@ function isRecord(v: unknown): v is Record<string, unknown> {
return typeof v === 'object' && v !== null && !Array.isArray(v);
}
function toHunks(patch: unknown[]): { hunks: EditHunk[]; added: number; removed: number; truncated: boolean } {
/**
* The hunks of `patch` with their string lines, cut once the lines reach {@link MAX_HUNK_BYTES}
* (`truncated`). Also applied to hunks loaded from a store file.
*/
export function capHunks(patch: readonly unknown[]): { hunks: EditHunk[]; truncated: boolean } {
const hunks: EditHunk[] = [];
let added = 0;
let removed = 0;
let budget = MAX_HUNK_BYTES;
let truncated = false;
for (const h of patch) {
if (!isRecord(h) || !Array.isArray(h.lines)) continue;
const lines = h.lines.filter((l): l is string => typeof l === 'string');
for (const l of lines) {
if (l.startsWith('+')) added++;
else if (l.startsWith('-')) removed++;
}
if (truncated) continue;
if (truncated) break;
const kept: string[] = [];
for (const l of lines) {
for (const l of h.lines) {
if (typeof l !== 'string') continue;
const cost = Buffer.byteLength(l) + 3;
if (cost > budget) {
truncated = true;
@@ -181,7 +179,21 @@ function toHunks(patch: unknown[]): { hunks: EditHunk[]; added: number; removed:
lines: kept,
});
}
return { hunks, added, removed, truncated };
return { hunks, truncated };
}
function toHunks(patch: unknown[]): { hunks: EditHunk[]; added: number; removed: number; truncated: boolean } {
let added = 0;
let removed = 0;
for (const h of patch) {
if (!isRecord(h) || !Array.isArray(h.lines)) continue;
for (const l of h.lines) {
if (typeof l !== 'string') continue;
if (l.startsWith('+')) added++;
else if (l.startsWith('-')) removed++;
}
}
return { ...capHunks(patch), added, removed };
}
/** A new file's content as one all-added hunk, in `structuredPatch` form. */
+144 -42
View File
@@ -15,7 +15,10 @@
* A first read of a file starts `budget` bytes before its end (transcripts reach 100 MB+),
* drops the cut first line and leaves `floor` there. A line longer than the budget is
* skipped instead of blocking the cursor, reading forward (`midLine`) and backward
* (`floorMidLine`).
* (`floorMidLine`); the line after it (forward) or before it (backward) is read whole.
* That needs every chunk to be a full budget unless it reaches the file's end (forward) or
* start (older): a chunk with no line end then means one line longer than the budget. The
* caller does not hand over a chunk that a budget shared by several files cut short.
*
* A new conversation (`/clear`, resume) arrives as a new `transcriptId`: cursors and
* pending Bash calls start over, entries stay. Entries are capped at {@link MAX_ENTRIES}
@@ -25,8 +28,10 @@
*
* ⚠️ The file holds secrets: a diff carries whatever the agent wrote, keys and `.env`
* contents included. So, like `intents.json`, it is written 0600 via tmp+rename, bounded,
* deleted with the session (`remove()`), never fed to `/api/search`, and blocked from the
* file routes by `isSensitivePath` (`web/sensitive-path.ts`).
* deleted with the session (`remove()`; `sweep()` at startup removes files no session owns),
* never fed to `/api/search`, and blocked from the file routes by `isSensitivePath`
* (`web/sensitive-path.ts`). `load()` takes the file as untrusted: past
* {@link MAX_FILE_BYTES} it is not parsed, and every part is rebuilt under the same bounds.
*
* Persisted as JSON under `dataPath('edit-history', '<sessionId>.json')`, written debounced.
*
@@ -35,10 +40,11 @@
* @module edit-history-store
*/
import { mkdir, readFile, rename, unlink, writeFile } from 'node:fs/promises';
import { mkdir, readdir, readFile, rename, stat, unlink, writeFile } from 'node:fs/promises';
import { join } from 'node:path';
import { dataPath } from './config/instance.js';
import {
capHunks,
MAX_HUNK_BYTES,
MAX_PENDING_BASH,
MAX_PENDING_COMMAND_CHARS,
@@ -61,6 +67,13 @@ export const MAX_AGENT_META_CHARS = 1024;
export const MAX_AGENTS = MAX_ENTRIES + 1;
/** Bytes read per file and pass (first read, forward step, "older" step). */
export const READ_BUDGET_BYTES = 8 * 1024 * 1024;
/**
* A store file larger than this is not parsed (starts empty). One written within the caps
* above stays far below it; JSON escaping at most doubles ordinary text.
*/
export const MAX_FILE_BYTES = 32 * 1024 * 1024;
/** Longest id, cwd or agent id of a loaded pending Bash call; a longer one is no real one. */
const MAX_LOADED_FIELD_CHARS = 4096;
export const MAIN_SOURCE = 'main';
const SAVE_DEBOUNCE_MS = 1000;
const FILE_VERSION = 1;
@@ -137,10 +150,10 @@ function isObj(v: unknown): v is Record<string, unknown> {
return typeof v === 'object' && v !== null && !Array.isArray(v);
}
/** What an entry costs against {@link MAX_STORE_BYTES}: its path and diff lines, plus a fixed overhead. */
/** What an entry costs against {@link MAX_STORE_BYTES}: its strings and diff lines, plus a fixed overhead. */
export function entryBytes(e: EditHistoryEntry): number {
let n = 128 + Buffer.byteLength(e.path);
for (const h of e.hunks) if (Array.isArray(h?.lines)) for (const l of h.lines) n += Buffer.byteLength(String(l)) + 3;
let n = 128 + Buffer.byteLength(e.id) + Buffer.byteLength(e.path) + Buffer.byteLength(e.agentId ?? '');
for (const h of e.hunks) for (const l of h.lines) n += Buffer.byteLength(l) + 3;
return n;
}
@@ -185,26 +198,47 @@ function cursorFrom(raw: unknown): SourceCursor | null {
return cursor;
}
const loadedField = (v: unknown): v is string => typeof v === 'string' && v.length <= MAX_LOADED_FIELD_CHARS;
function pendingBashFrom(raw: unknown): PendingBash | null {
if (!isObj(raw)) return null;
const { id, ts, command, cwd, agentId } = raw;
if (typeof id !== 'string' || typeof ts !== 'number' || typeof command !== 'string' || typeof cwd !== 'string') {
return null;
}
const pending: PendingBash = { id, ts, command: command.slice(0, MAX_PENDING_COMMAND_CHARS), cwd };
if (typeof agentId === 'string') pending.agentId = agentId;
if (!loadedField(id) || !Number.isFinite(ts) || typeof command !== 'string' || !loadedField(cwd)) return null;
if (agentId !== undefined && !loadedField(agentId)) return null;
const pending: PendingBash = { id, ts: ts as number, command: command.slice(0, MAX_PENDING_COMMAND_CHARS), cwd };
if (agentId) pending.agentId = agentId;
return pending;
}
function isEntry(raw: unknown): raw is EditHistoryEntry {
return (
isObj(raw) &&
typeof raw.id === 'string' &&
typeof raw.path === 'string' &&
typeof raw.ts === 'number' &&
Array.isArray(raw.hunks) &&
raw.hunks.every((h) => isObj(h) && Array.isArray(h.lines))
);
const ENTRY_KINDS = new Set(['code', 'image']);
const ENTRY_TOOLS = new Set(['Edit', 'Write', 'edit', 'bash', 'sent']);
const countFrom = (v: unknown): number => (Number.isSafeInteger(v) && (v as number) >= 0 ? (v as number) : 0);
/**
* A loaded entry rebuilt from its known fields: unknown fields and non-string diff lines are
* dropped and the per-edit {@link MAX_HUNK_BYTES} is applied again; null when it is unusable.
*/
function entryFrom(raw: unknown): EditHistoryEntry | null {
if (!isObj(raw) || typeof raw.id !== 'string' || !raw.id || typeof raw.path !== 'string' || !raw.path) return null;
if (!Number.isFinite(raw.ts) || !ENTRY_KINDS.has(raw.kind as string) || !ENTRY_TOOLS.has(raw.tool as string)) {
return null;
}
if (!Array.isArray(raw.hunks)) return null;
const { hunks, truncated } = capHunks(raw.hunks);
const entry: EditHistoryEntry = {
id: raw.id,
ts: raw.ts as number,
path: raw.path,
kind: raw.kind as EditHistoryEntry['kind'],
tool: raw.tool as EditHistoryTool,
created: raw.created === true,
added: countFrom(raw.added),
removed: countFrom(raw.removed),
hunks,
truncated: truncated || raw.truncated === true,
};
if (typeof raw.agentId === 'string' && raw.agentId) entry.agentId = raw.agentId;
return entry;
}
export class EditHistoryStore {
@@ -229,11 +263,15 @@ export class EditHistoryStore {
this.debounceMs = opts.debounceMs ?? SAVE_DEBOUNCE_MS;
}
/** A store with the persisted state of `sessionId`; empty when the file is missing or unreadable. */
/**
* A store with the persisted state of `sessionId`; empty when the file is missing,
* unreadable or larger than {@link MAX_FILE_BYTES}.
*/
static async load(sessionId: string, opts: EditHistoryStoreOptions = {}): Promise<EditHistoryStore> {
const store = new EditHistoryStore(sessionId, opts);
let data: unknown;
try {
if ((await stat(store.file)).size > MAX_FILE_BYTES) return store;
data = JSON.parse(await readFile(store.file, 'utf-8'));
} catch {
return store;
@@ -252,14 +290,46 @@ export class EditHistoryStore {
.filter((p): p is PendingBash => p !== null)
.slice(-MAX_PENDING_BASH);
}
if (Array.isArray(f.entries)) store.addRecords(f.entries.filter(isEntry));
if (Array.isArray(f.entries)) {
store.addRecords(f.entries.map(entryFrom).filter((e): e is EditHistoryEntry => e !== null));
}
for (const [k, raw] of Object.entries(isObj(f.agents) ? f.agents : {})) {
const meta = agentMetaFrom(raw);
const meta = k.length <= MAX_AGENT_META_CHARS ? agentMetaFrom(raw) : null;
if (meta) store.putAgent(k, meta);
}
return store;
}
/**
* Delete the stored history of every session `keep` rejects, and tmp files other processes
* left behind (startup: a crash between removing a session's record and deleting its file,
* or mid-write; an older build).
* Returns how many sessions' files were deleted.
*/
static async sweep(
keep: (sessionId: string) => boolean,
opts: Pick<EditHistoryStoreOptions, 'dir'> = {}
): Promise<number> {
const dir = opts.dir ?? dataPath('edit-history');
let names: string[];
try {
names = await readdir(dir);
} catch {
return 0;
}
let removed = 0;
for (const name of names) {
const tmp = /^(.+)\.json\.(\d+)\.tmp$/.exec(name);
const stale = tmp
? Number(tmp[2]) !== process.pid
: name.endsWith('.json') && !keep(name.slice(0, -'.json'.length));
if (!stale) continue;
await unlink(join(dir, name)).catch(() => undefined);
if (!tmp) removed++;
}
return removed;
}
getTranscriptId(): string | null {
return this.transcriptId;
}
@@ -338,7 +408,19 @@ export class EditHistoryStore {
return [...this.cursors.entries()].filter(([, c]) => c.floor > 0).map(([s]) => s);
}
/** Apply a forward chunk read at `nextRange`. Returns new entries and Bash image candidates. */
/**
* A source whose file is gone (a deleted subagent transcript): its cursor goes, so it no
* longer offers older lines. Its entries stay.
*/
dropSource(source: string): void {
if (this.removed || !this.cursors.delete(source)) return;
this.scheduleSave();
}
/**
* Apply a forward chunk read at `nextRange`: a full budget unless it reaches the file's end.
* Returns new entries and Bash image candidates.
*/
apply(input: ApplyInput): ApplyResult {
if (this.removed) return { added: 0, candidates: [] };
const { transcriptId, source, startOffset, chunk, size } = input;
@@ -369,11 +451,14 @@ export class EditHistoryStore {
}
const lastNl = chunk.lastIndexOf(NL);
if (lastNl < from) {
if (!atEof && chunk.length > 0) {
if (from > 0) {
// The skipped (or cut first) line ended here; the next one is read from its start.
this.setCursor(source, cursor, startOffset + from, startOffset + from, false);
} else if (!atEof && chunk.length > 0) {
// One line longer than the budget: skip it instead of re-reading it forever.
this.setCursor(source, cursor, startOffset + chunk.length, startOffset + from, true);
this.setCursor(source, cursor, startOffset + chunk.length, startOffset, true);
} else if (!cursor) {
this.setCursor(source, undefined, startOffset + from, startOffset + from, false);
this.setCursor(source, undefined, startOffset, startOffset, false);
}
return { added: 0, candidates: [] };
}
@@ -387,8 +472,9 @@ export class EditHistoryStore {
}
/**
* Apply a chunk read at `olderRange`: it ends at the floor, which is a line start, or with
* `floorMidLine` lies inside a line longer than the budget (its part in this chunk is dropped).
* Apply a chunk read at `olderRange`: a full budget unless it reaches the file's start. It
* ends at the floor, which is a line start, or with `floorMidLine` lies inside a line longer
* than the budget (its part in this chunk is dropped).
*/
applyOlder(input: ApplyInput): ApplyResult {
const { transcriptId, source, startOffset, chunk } = input;
@@ -396,23 +482,39 @@ export class EditHistoryStore {
if (transcriptId !== this.transcriptId || !cursor || startOffset + chunk.length !== cursor.floor) {
return { added: 0, candidates: [] };
}
const end = cursor.floorMidLine ? chunk.lastIndexOf(NL) + 1 : chunk.length;
const none = { added: 0, candidates: [] };
let end = chunk.length;
if (cursor.floorMidLine) {
const lastNl = chunk.lastIndexOf(NL);
if (lastNl === -1 && startOffset > 0) {
// Still inside the long line.
cursor.floor = startOffset;
this.scheduleSave();
return none;
}
// The long line starts at `end`: drop its part.
end = lastNl + 1;
delete cursor.floorMidLine;
}
let from = 0;
let insideLine = false;
if (startOffset > 0) {
// The newline at `end - 1` closes a line that may have started before this chunk.
const nl = chunk.subarray(0, end).indexOf(NL);
insideLine = nl === -1 || nl === end - 1;
from = insideLine ? end : nl + 1;
}
if (insideLine) {
// No line starts in this chunk: it lies inside one line longer than the budget. Skip it.
cursor.floor = startOffset;
cursor.floorMidLine = true;
} else {
cursor.floor = startOffset + from;
delete cursor.floorMidLine;
if (nl === -1 || nl === end - 1) {
if (end < chunk.length) {
// It ends where the long line starts: the next block, ending there, holds it whole.
cursor.floor = startOffset + end;
} else {
// No line starts in this chunk: it lies inside one line longer than the budget. Skip it.
cursor.floor = startOffset;
cursor.floorMidLine = true;
}
this.scheduleSave();
return none;
}
from = nl + 1;
}
cursor.floor = startOffset + from;
// Calls whose results lie in already-read newer lines cannot be paired here.
const parsed = parseClaudeEditLines(chunk.subarray(from, end).toString('utf8'));
const added = this.addRecords(parsed.records);
+59 -12
View File
@@ -8,12 +8,19 @@
* looks at. One sync per session runs at a time; requests during a run are folded into one
* more forward read and/or one more older read afterwards.
*
* HOW MUCH. A forward read shares one budget over all files, main first. A file whose chunk
* the shared budget cut short is not applied (the store would take a chunk with no line end
* for a line longer than the budget) but read in the next pass; up to
* {@link MAX_FORWARD_PASSES} passes per sync, the rest with the next one.
*
* GONE. `remove()` (the session's record is deleted) marks the session gone before it deletes
* the file: a run in flight stops after its current read, its store no longer writes, and a
* later request reads nothing (`unavailable`, reason `removed`) instead of loading a new store.
* `sweep()` deletes, at startup, the stored history of sessions that no longer exist. A
* subagent transcript that vanished loses its cursor (`dropSource`), not its entries.
*
* OLDER. "Load older" splits the budget evenly over the sources with older lines and reads
* only those blocks, so every source moves back with each click.
* OLDER. "Load older" reads a full budget block of up to {@link MAX_OLDER_SOURCES} sources
* with older lines per click; with more sources they take turns.
*
* WHICH SESSIONS. Local claude sessions. Remote, Docker and non-claude sessions answer
* `unsupported` and are never read.
@@ -34,8 +41,10 @@ import { getCli } from '../config/cli-registry/registry.js';
/** Slack around a Bash call's window for the image mtime check. */
export const IMAGE_MTIME_SLACK_MS = 2000;
/** Forward passes per sync when a file grew by more than one budget. */
/** Forward passes per sync when files grew by more than one budget. */
export const MAX_FORWARD_PASSES = 3;
/** Sources read back per "Load older" click, a full budget each. */
export const MAX_OLDER_SOURCES = 3;
/** `reason` when the session has no transcript yet (no prompt yet, or not found). */
export const NO_TRANSCRIPT = 'no-transcript';
/** `reason` once the session's history was deleted. */
@@ -70,6 +79,8 @@ export interface EditHistorySyncDeps {
): Promise<EditSources | null>;
probe(paths: readonly string[]): Promise<Array<PathProbe | null>>;
loadStore(sessionId: string): Promise<EditHistoryStore>;
/** Delete the stored history of every session `keep` rejects; returns how many. */
sweepStores(keep: (sessionId: string) => boolean): Promise<number>;
budget?: number;
}
@@ -135,6 +146,8 @@ interface SessionSlot {
again?: SyncWant;
/** The history was deleted: nothing reads for this session any more. */
gone?: boolean;
/** The last source the previous "Load older" read; the next one starts after it. */
olderAfter?: string;
}
export class EditHistorySync {
@@ -212,6 +225,14 @@ export class EditHistorySync {
await store.remove();
}
/**
* Delete the stored history of sessions that no longer exist (startup: left behind by a
* crash during deletion, or by an older build). A session in use here is kept.
*/
sweep(exists: (sessionId: string) => boolean): Promise<number> {
return this.deps.sweepStores((id) => this.slots.has(id) || exists(id));
}
private slot(sessionId: string): SessionSlot {
let slot = this.slots.get(sessionId);
if (!slot) {
@@ -240,7 +261,7 @@ export class EditHistorySync {
if (!path) {
found = false;
} else if (older) {
found = await this.readOlder(path, store);
found = await this.readOlder(path, store, slot);
} else {
found = true;
for (let pass = 0; pass < MAX_FORWARD_PASSES && found && !slot.gone; pass++) {
@@ -266,20 +287,29 @@ export class EditHistorySync {
}
/**
* One forward read of all sources. `more` = some file has unread bytes after it.
* A transcript other than the stored one (`/clear`, resume) is read like a first read.
* One forward read of all sources. `more` = some file has unread bytes after it, or was cut
* short by the shared budget and left for the next pass. A transcript other than the stored
* one (`/clear`, resume) is read like a first read.
*/
private async readForward(path: string, store: EditHistoryStore): Promise<{ found: boolean; more: boolean }> {
const known = store.getTranscriptId();
const requests = known !== null && path.endsWith(`/${known}.jsonl`) ? this.forwardRequests(store) : [];
const res = await this.deps.readSources(path, requests, this.budget);
if (!res) return { found: false, more: false };
this.dropMissing(store, requests, res);
for (const [agentId, text] of Object.entries(res.metas)) {
if (!store.hasAgentMeta(agentId)) store.setAgentMeta(agentId, text);
}
const requested = new Set(requests.map((r) => r.relPath));
let more = false;
const candidates: ImageCandidate[] = [];
for (const f of res.files) {
// A requested file reads on from its cursor, a new one its tail.
const available = requested.has(f.relPath) ? f.size - f.startOffset : f.size;
if (f.chunk.length < Math.min(this.budget, available)) {
more = true;
continue;
}
const r = store.apply({
transcriptId: res.transcriptId,
source: f.relPath,
@@ -294,20 +324,31 @@ export class EditHistorySync {
return { found: true, more };
}
private async readOlder(path: string, store: EditHistoryStore): Promise<boolean> {
private async readOlder(path: string, store: EditHistoryStore, slot: SessionSlot): Promise<boolean> {
if (!store.backfillTruncated) return true;
const sources = store.sourcesWithOlder();
const sources = store.sourcesWithOlder().sort();
if (sources.length === 0) return true;
const share = Math.max(1, Math.floor(this.budget / sources.length));
// Take turns: start after the last source the previous click read.
const after = slot.olderAfter;
const first =
after === undefined
? 0
: Math.max(
0,
sources.findIndex((s) => s > after)
);
const picked = [...sources.slice(first), ...sources.slice(0, first)].slice(0, MAX_OLDER_SOURCES);
const requests: EditSourceRequest[] = [];
for (const relPath of sources) {
const range = store.olderRange(relPath, share);
for (const relPath of picked) {
const range = store.olderRange(relPath, this.budget);
if (range) requests.push({ relPath, offset: range.offset, len: range.len });
}
if (requests.length === 0) return true;
const res = await this.deps.readSources(path, requests, this.budget, { onlyRequested: true });
slot.olderAfter = picked[picked.length - 1];
const res = await this.deps.readSources(path, requests, this.budget * requests.length, { onlyRequested: true });
if (!res) return false;
if (res.transcriptId !== store.getTranscriptId()) return true;
this.dropMissing(store, requests, res);
const wanted = new Set(requests.map((r) => r.relPath));
const candidates: ImageCandidate[] = [];
for (const f of res.files) {
@@ -325,6 +366,12 @@ export class EditHistorySync {
return true;
}
/** Requested files the reader no longer lists are gone: their cursors go too. */
private dropMissing(store: EditHistoryStore, requests: readonly EditSourceRequest[], res: EditSources): void {
const listed = new Set(res.files.map((f) => f.relPath));
for (const r of requests) if (!listed.has(r.relPath)) store.dropSource(r.relPath);
}
private async addImages(store: EditHistoryStore, candidates: readonly ImageCandidate[]): Promise<void> {
const paths = [...new Set(candidates.flatMap((c) => c.paths))];
if (paths.length === 0) return;
+3 -2
View File
@@ -3518,8 +3518,9 @@ class CodemanApp {
this.sendPendingCtrlL(data.id);
if (data.id === this.activeSessionId) this._updateLocalEchoState();
}
// An open Edit history panel re-reads at the end of its session's turn (edit-history-ui.js).
this.onEditHistorySessionIdle?.(data.id);
// An open Edit history panel re-reads at the end of its session's turn (edit-history-ui.js),
// not after Codeman's own keep-alive turn, which edits nothing.
if (!data.keepAlive) this.onEditHistorySessionIdle?.(data.id);
// Start stuck detection timer (only if no respawn running). Not at the end of Codeman's
// own keep-alive turn (`keepAlive`): the session has been waiting since the last real
// turn, whose timer is still running or has already fired.
+1
View File
@@ -801,6 +801,7 @@ export function registerSystemRoutes(
const lifecycleLog = getLifecycleLog();
for (const s of result.cleaned) {
lifecycleLog.log({ event: 'stale_cleaned', sessionId: s.id, name: s.name });
ctx.forgetEditHistory(s.id);
}
return { cleanedSessions: result.count };
});
+8 -1
View File
@@ -348,6 +348,7 @@ export class WebServer extends EventEmitter {
readSources: readClaudeEditSources,
probe: probeLocalPaths,
loadStore: (sessionId) => EditHistoryStore.load(sessionId),
sweepStores: (keep) => EditHistoryStore.sweep(keep),
});
private respawnControllers: Map<string, RespawnController> = new Map();
private respawnTimers: Map<string, { timer: NodeJS.Timeout; endAt: number; startedAt: number }> = new Map();
@@ -2506,12 +2507,18 @@ export class WebServer extends EventEmitter {
lifecycleLog.log({ event: 'stale_cleaned', sessionId: s.id, name: s.name });
this.forgetEditHistory(s.id);
}
// History files no session owns (a crash between removing a record and deleting its file).
const exists = (id: string) => this.sessions.has(id) || !!this.store.getSession(id);
await this.editHistorySync.sweep(exists).catch((err) => {
console.warn('[EditHistory] Sweeping orphaned histories failed:', getErrorMessage(err));
});
return result.count;
}
/**
* Delete a session's edit history (diffs can hold secrets) once its record is gone: deleted
* live (`cleanupSession`), deleted as a record only (DELETE route), or cleaned as stale.
* live (`cleanupSession`), deleted as a record only (DELETE route), or cleaned as stale
* (startup, `POST /api/cleanup-state`).
*/
private forgetEditHistory(sessionId: string): void {
void this.editHistorySync.remove(sessionId).catch((err) => {