mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-11-15 10:59:32 +01:00
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:
1 parent
f5ce80dce2
commit
321268754a
15 files changed
+540
-89
No files matched your search
+23
-11
@@ -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
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
@@ -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) => {
|
||||
|
||||
Reference in new issue
Block a user