Skip to content

Commit aaa58f2

Browse files
authored
fix(coding-agents): stop the prompt hook wiping the retain cursor; write state atomically (#3412)
Found porting #3136 (unlocked state read-modify-write on Windows). The race it describes is far less severe here — state is one file per session rather than a dict keyed by session, so a lost update cannot drop other sessions — but looking for it surfaced a worse, unconditional bug underneath. The retain cursor was a FIELD of the session cache, and the prompt hook writes a fresh `{turns, reflectAnswer, pages}` object rather than merging. So every user prompt dropped the cursor the previous Stop had written; the next Stop found none and rewrote the whole document. The incremental write-back added in #3336 therefore never engaged past a session's first turn on ANY hook harness — seven of eleven. Not a race: deterministic, every session, every turn. after Stop : {"retain":{"turns":5,...}} after prompt : {"turns":2,"pages":{...}} <- cursor gone cursor now : undefined No test caught it because the unit tests inject a memoryCursorStore directly and never exercise the file store against a real prompt-hook write. The regression test added here does exactly that interleaving. The cursor now lives in its own file. That makes the invariant structural rather than a convention every future writer has to remember: the two writers have different lifecycles, no longer share a record, and neither can clobber the other — which also leaves the concurrent read-modify-write #3136 measured with nothing to lose here. State writes are also atomic now (temp file + rename). A plain writeFileSync can be observed half-written and leaves a fragment behind if the process is killed mid-write; rename is atomic on POSIX and replaces on Windows. The per-agent plugin's Python writes already had os.replace() — this had no equivalent. Cannot be verified on Windows from here; the atomicity is platform-independent and the clobber fix is verified on macOS.
1 parent 82859af commit aaa58f2

2 files changed

Lines changed: 91 additions & 19 deletions

File tree

hindsight-integrations/coding-agents/src/core/session-cache.test.ts

Lines changed: 35 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,10 @@ const HARNESS = "codex-cursor-test";
1111
const sessions = ["s1", "s2"];
1212

1313
afterEach(() => {
14-
for (const s of sessions) rmSync(sessionCacheFile(HARNESS, s), { force: true });
14+
for (const s of sessions) {
15+
rmSync(sessionCacheFile(HARNESS, s), { force: true });
16+
rmSync(sessionCacheFile(HARNESS, s).replace(/\.json$/, ".retain.json"), { force: true });
17+
}
1518
});
1619

1720
describe("fileCursorStore", () => {
@@ -38,14 +41,39 @@ describe("fileCursorStore", () => {
3841
expect(fileCursorStore(HARNESS).read("s1")).toBeUndefined();
3942
});
4043

41-
it("preserves the rest of the session cache it shares a file with", () => {
44+
it("survives the prompt hook rewriting the session cache", () => {
45+
// The regression this file exists for. The cursor used to be a FIELD of the session cache, and
46+
// the prompt hook writes a fresh {turns, reflectAnswer, pages} object rather than merging — so
47+
// every user prompt dropped it, the next Stop found no cursor, and the incremental write-back
48+
// silently degraded to a full replace on every hook harness after a session's first turn.
49+
const store = fileCursorStore(HARNESS);
50+
store.write("s1", { turns: 5, fingerprint: "f", bank: "b1" });
51+
52+
writeSessionCache(sessionCacheFile(HARNESS, "s1"), {
53+
turns: 2,
54+
pages: { atTurn: 2, list: [] },
55+
});
56+
57+
expect(store.read("s1")).toEqual({ turns: 5, fingerprint: "f", bank: "b1" });
58+
});
59+
60+
it("leaves the session cache alone", () => {
61+
// The inverse direction: writing a cursor must not disturb the recall state either.
4262
const file = sessionCacheFile(HARNESS, "s1");
4363
writeSessionCache(file, { turns: 3, reflectAnswer: "already ran" });
4464
fileCursorStore(HARNESS).write("s1", { turns: 2, fingerprint: "f", bank: "b1" });
45-
expect(readSessionCache(file)).toEqual({
46-
turns: 3,
47-
reflectAnswer: "already ran",
48-
retain: { turns: 2, fingerprint: "f", bank: "b1" },
49-
});
65+
expect(readSessionCache(file)).toEqual({ turns: 3, reflectAnswer: "already ran" });
66+
});
67+
68+
it("never exposes a half-written cursor", () => {
69+
// Writes go through a temp file and a rename, so a reader sees the old value or the new one.
70+
const store = fileCursorStore(HARNESS);
71+
store.write("s1", { turns: 1, fingerprint: "a", bank: "b1" });
72+
for (let i = 2; i <= 30; i++) {
73+
store.write("s1", { turns: i, fingerprint: "a".repeat(i * 200), bank: "b1" });
74+
const seen = store.read("s1");
75+
expect(seen?.turns).toBe(i);
76+
expect(seen?.fingerprint).toHaveLength(i * 200);
77+
}
5078
});
5179
});

hindsight-integrations/coding-agents/src/core/session-cache.ts

Lines changed: 56 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
1+
import { mkdirSync, readFileSync, renameSync, rmSync, writeFileSync } from "node:fs";
22
import { tmpdir } from "node:os";
33
import { dirname, join } from "node:path";
44
import type { PageRef } from "./knowledge-injection";
@@ -13,8 +13,6 @@ export interface SessionCache {
1313
/** SessionStart saw a new/empty bank; consume this on prompt one, then allow reflect. */
1414
deferInitialReflect?: boolean;
1515
pages?: { atTurn: number; list: PageRef[] };
16-
/** How much of this session's transcript is already in its document (core/retain-cursor.ts). */
17-
retain?: RetainCursor;
1816
}
1917

2018
export function sessionCacheFile(harness: string, sessionId: string): string {
@@ -29,28 +27,74 @@ export function readSessionCache(cacheFile: string): SessionCache {
2927
}
3028
}
3129

30+
/**
31+
* Replace a state file atomically: write a sibling temp file, then rename over the target.
32+
*
33+
* A plain writeFileSync is not atomic — a reader can observe a half-written file, and a process
34+
* killed mid-write leaves one behind. rename() is atomic on POSIX and replaces the destination on
35+
* Windows, so a reader sees either the old contents or the new, never a fragment (#3136, which
36+
* measured this class on the per-agent plugin; its Python state writes already had os.replace()).
37+
*/
38+
function writeFileAtomic(path: string, body: string): void {
39+
mkdirSync(dirname(path), { recursive: true });
40+
// The temp name is per-process, so two writers cannot collide on it.
41+
const tmp = `${path}.${process.pid}.tmp`;
42+
try {
43+
writeFileSync(tmp, body);
44+
renameSync(tmp, path);
45+
} catch (e) {
46+
try {
47+
rmSync(tmp, { force: true });
48+
} catch {
49+
/* nothing further to do */
50+
}
51+
throw e;
52+
}
53+
}
54+
3255
export function writeSessionCache(cacheFile: string, cache: SessionCache): void {
3356
try {
34-
mkdirSync(dirname(cacheFile), { recursive: true });
35-
writeFileSync(cacheFile, JSON.stringify(cache));
57+
writeFileAtomic(cacheFile, JSON.stringify(cache));
3658
} catch {
3759
/* session state is best-effort */
3860
}
3961
}
4062

63+
/** The cursor's own file, deliberately NOT the shared session cache — see fileCursorStore. */
64+
function cursorFile(harness: string, sessionId: string): string {
65+
return join(tmpdir(), `hindsight-${harness}`, `${sessionId}.retain.json`);
66+
}
67+
4168
/**
42-
* Retain cursor kept in the per-session temp file, for the hook harnesses: Stop runs in a fresh
43-
* process every time, so "what have I already written" cannot live in memory.
69+
* Retain cursor for the hook harnesses: Stop runs in a fresh process every time, so "what have I
70+
* already written" cannot live in memory.
4471
*
45-
* Losing this file (temp cleanup, reboot) is not a correctness problem — a missing cursor means the
46-
* next retain replaces the whole document, which is exactly what the plugin did before appends.
72+
* It lives in its OWN file rather than a field of the session cache. It shared that file until now,
73+
* and the prompt hook — which writes a fresh `{turns, reflectAnswer, pages}` object rather than
74+
* merging — dropped the cursor on EVERY user prompt. The next Stop then found none and rewrote the
75+
* whole document, so the incremental write-back never engaged past a session's first turn on any
76+
* hook harness. Separate files make that structural: the two writers have different lifecycles and
77+
* no longer share a record, so neither can clobber the other, and the concurrent read-modify-write
78+
* that #3136 measured has nothing left to lose here.
79+
*
80+
* Losing the file (temp cleanup, reboot) is still not a correctness problem — a missing cursor
81+
* means the next retain replaces the whole document, exactly as it did before appends existed.
4782
*/
4883
export function fileCursorStore(harness: string): RetainCursorStore {
4984
return {
50-
read: (sessionId) => readSessionCache(sessionCacheFile(harness, sessionId)).retain,
85+
read: (sessionId) => {
86+
try {
87+
return JSON.parse(readFileSync(cursorFile(harness, sessionId), "utf8")) as RetainCursor;
88+
} catch {
89+
return undefined;
90+
}
91+
},
5192
write: (sessionId, cursor) => {
52-
const file = sessionCacheFile(harness, sessionId);
53-
writeSessionCache(file, { ...readSessionCache(file), retain: cursor });
93+
try {
94+
writeFileAtomic(cursorFile(harness, sessionId), JSON.stringify(cursor));
95+
} catch {
96+
/* best-effort: a cursor that cannot be written costs a replace, never data */
97+
}
5498
},
5599
};
56100
}

0 commit comments

Comments
 (0)