Skip to content

Commit 1f39da2

Browse files
authored
Merge pull request #65 from itsjling/codex/issue-034-cache-concurrency
Make cached review state bounded and concurrency-safe
2 parents 9a4c953 + 423168f commit 1f39da2

12 files changed

Lines changed: 617 additions & 20 deletions

docs/content/agent-notes.mdx

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -76,6 +76,28 @@ Without `XDG_CACHE_HOME`, macOS uses
7676
and range targets each get a distinct keyed file. Pass `--summaries FILE` to
7777
use another path.
7878

79+
## Cache control
80+
81+
Saved notes use a short-lived lease while an agent writes them. A reader can
82+
still open the last complete notes. If a writer stops, its lease expires and a
83+
later run can take over. A writer that loses its lease cannot publish or remove
84+
the newer writer's lease.
85+
86+
See cache location, total size, oldest entry, and active targets:
87+
88+
```sh
89+
npx diffsplain cache status
90+
```
91+
92+
Prune inactive notes by age in days or by total size in bytes. `clear --yes`
93+
removes inactive notes only; it keeps notes under an active lease.
94+
95+
```sh
96+
npx diffsplain cache prune --age 30
97+
npx diffsplain cache prune --size 104857600
98+
npx diffsplain cache clear --yes
99+
```
100+
79101
The file has one change note and notes keyed by file path:
80102

81103
```json

package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@
2929
"dist",
3030
"scripts/build-diff-data.mjs",
3131
"scripts/cli-args.mjs",
32+
"scripts/cache.mjs",
3233
"scripts/coding-agents.mjs",
3334
"scripts/doctor.mjs",
3435
"scripts/generate-summaries.mjs",

scripts/build-diff-data.mjs

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -119,9 +119,11 @@ const repoPath = (file) => {
119119
const path = relative(repo, file).replaceAll('\\', '/');
120120
return path && path !== '..' && !path.startsWith('../') ? path : undefined;
121121
};
122+
const summariesRepoPath = repoPath(summariesPath);
122123
const excludedPaths = new Set(
123124
[
124-
repoPath(summariesPath),
125+
summariesRepoPath,
126+
summariesRepoPath ? `${summariesRepoPath}.lock` : undefined,
125127
repoPath(output),
126128
excludedOutput ? repoPath(resolve(excludedOutput)) : undefined,
127129
].filter(Boolean),

scripts/cache.mjs

Lines changed: 320 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,320 @@
1+
import { randomUUID } from 'node:crypto';
2+
import {
3+
chmodSync,
4+
closeSync,
5+
linkSync,
6+
mkdirSync,
7+
openSync,
8+
readFileSync,
9+
readdirSync,
10+
renameSync,
11+
rmSync,
12+
statSync,
13+
unlinkSync,
14+
utimesSync,
15+
writeFileSync,
16+
} from 'node:fs';
17+
import { hostname } from 'node:os';
18+
import { dirname, join } from 'node:path';
19+
import { defaultCacheRoot } from './summary-path.mjs';
20+
21+
export const leaseDurationMs = 5 * 60_000;
22+
23+
function files(root) {
24+
try {
25+
return readdirSync(root, { withFileTypes: true }).flatMap((entry) => {
26+
const path = join(root, entry.name);
27+
return entry.isDirectory() ? files(path) : [path];
28+
});
29+
} catch {
30+
return [];
31+
}
32+
}
33+
34+
function leaseRecord(path) {
35+
try {
36+
const value = JSON.parse(readFileSync(path, 'utf8'));
37+
return typeof value?.token === 'string' ? value : undefined;
38+
} catch {
39+
return undefined;
40+
}
41+
}
42+
43+
function leaseOwnerIsActive(record) {
44+
return (
45+
record?.hostname === hostname() &&
46+
Number.isSafeInteger(record.pid) &&
47+
processIsAlive(record.pid)
48+
);
49+
}
50+
51+
function leaseIsActive(path, now = Date.now(), duration = leaseDurationMs) {
52+
try {
53+
if (leaseOwnerIsActive(leaseRecord(path))) return true;
54+
return now - statSync(path).mtimeMs < duration;
55+
} catch {
56+
return false;
57+
}
58+
}
59+
60+
function processIsAlive(pid) {
61+
try {
62+
process.kill(pid, 0);
63+
return true;
64+
} catch (error) {
65+
return error?.code === 'EPERM';
66+
}
67+
}
68+
69+
function createLease(path, record, duration) {
70+
const descriptor = openSync(path, 'wx', 0o600);
71+
writeFileSync(descriptor, `${JSON.stringify(record)}\n`);
72+
closeSync(descriptor);
73+
return { path, token: record.token, duration };
74+
}
75+
76+
function renameLease(path, stalePath) {
77+
try {
78+
renameSync(path, stalePath);
79+
return true;
80+
} catch (error) {
81+
if (error?.code !== 'ENOENT') throw error;
82+
return false;
83+
}
84+
}
85+
86+
function restoreLease(path, stalePath) {
87+
try {
88+
linkSync(stalePath, path);
89+
} catch (error) {
90+
if (error?.code !== 'EEXIST') throw error;
91+
}
92+
rmSync(stalePath, { force: true });
93+
}
94+
95+
export function removeStaleLease(path, observedToken) {
96+
const stalePath = `${path}.stale-${randomUUID()}`;
97+
if (!renameLease(path, stalePath)) return false;
98+
if (leaseRecord(stalePath)?.token === observedToken) {
99+
rmSync(stalePath, { force: true });
100+
return true;
101+
}
102+
restoreLease(path, stalePath);
103+
return false;
104+
}
105+
106+
function rejectNonConflict(error) {
107+
if (error?.code !== 'EEXIST') throw error;
108+
}
109+
110+
function rejectActiveLease(active, path) {
111+
if (active) {
112+
throw new Error(`Notes for this target are already being generated: ${path}`);
113+
}
114+
}
115+
116+
function handleLeaseConflict(error, path, now, duration) {
117+
rejectNonConflict(error);
118+
const observed = leaseRecord(path);
119+
rejectActiveLease(leaseOwnerIsActive(observed), path);
120+
rejectActiveLease(leaseIsActive(path, now, duration), path);
121+
removeStaleLease(path, observed?.token);
122+
}
123+
124+
export function acquireLease(path, {
125+
token = randomUUID(),
126+
now = Date.now(),
127+
duration = leaseDurationMs,
128+
pid = process.pid,
129+
leaseHostname = hostname(),
130+
} = {}) {
131+
mkdirSync(dirname(path), { recursive: true, mode: 0o700 });
132+
for (;;) {
133+
try {
134+
return createLease(
135+
path,
136+
{ token, startedAt: now, pid, hostname: leaseHostname },
137+
duration,
138+
);
139+
} catch (error) {
140+
handleLeaseConflict(error, path, now, duration);
141+
}
142+
}
143+
}
144+
145+
function assertLease(lease) {
146+
if (leaseRecord(lease.path)?.token !== lease.token) {
147+
throw new Error('This process no longer owns the note cache');
148+
}
149+
}
150+
151+
export function refreshLease(lease, now = Date.now()) {
152+
assertLease(lease);
153+
utimesSync(lease.path, new Date(now), new Date(now));
154+
assertLease(lease);
155+
}
156+
157+
export function releaseLease(lease) {
158+
assertLease(lease);
159+
unlinkSync(lease.path);
160+
}
161+
162+
export function writePrivateFile(path, value) {
163+
writeFileAtomic(path, value, 0o600);
164+
}
165+
166+
function existingFileMode(path) {
167+
try {
168+
return statSync(path).mode & 0o777;
169+
} catch (error) {
170+
if (error?.code !== 'ENOENT') throw error;
171+
return undefined;
172+
}
173+
}
174+
175+
function chmodIfSet(path, mode) {
176+
if (mode !== undefined) chmodSync(path, mode);
177+
}
178+
179+
function writeFileAtomic(path, value, mode) {
180+
mkdirSync(dirname(path), { recursive: true, mode: 0o700 });
181+
const temporary = `${path}.${process.pid}.${randomUUID()}.tmp`;
182+
const outputMode = mode === undefined ? existingFileMode(path) : mode;
183+
writeFileSync(
184+
temporary,
185+
value,
186+
outputMode === undefined ? undefined : { mode: outputMode },
187+
);
188+
chmodIfSet(temporary, outputMode);
189+
renameSync(temporary, path);
190+
chmodIfSet(path, outputMode);
191+
}
192+
193+
export function publishLeaseFile(
194+
lease,
195+
path,
196+
value,
197+
{ privateFile = true } = {},
198+
) {
199+
assertLease(lease);
200+
refreshLease(lease);
201+
if (privateFile) writePrivateFile(path, value);
202+
else writeFileAtomic(path, value);
203+
assertLease(lease);
204+
}
205+
206+
function active(path, now) {
207+
return leaseIsActive(`${path}.lock`, now);
208+
}
209+
210+
function sameEntry(left, right) {
211+
return (
212+
left.dev === right.dev &&
213+
left.ino === right.ino &&
214+
left.size === right.size &&
215+
left.mtimeMs === right.mtimeMs
216+
);
217+
}
218+
219+
function fileSize(path) {
220+
try {
221+
return statSync(path).size;
222+
} catch (error) {
223+
if (error?.code !== 'ENOENT') throw error;
224+
return undefined;
225+
}
226+
}
227+
228+
function removedCacheEntry() {
229+
return { removed: true, retainedActive: false, size: 0 };
230+
}
231+
232+
function cacheEntryState(entry, removedPath, now) {
233+
return {
234+
changed: !sameEntry(entry, statSync(removedPath)),
235+
retainedActive: active(entry.path, now),
236+
replaced: typeof fileSize(entry.path) === 'number',
237+
};
238+
}
239+
240+
function restoreCacheEntry(entry, removedPath, retainedActive) {
241+
restoreLease(entry.path, removedPath);
242+
return {
243+
removed: false,
244+
retainedActive,
245+
size: fileSize(entry.path) ?? 0,
246+
};
247+
}
248+
249+
export function removeCacheEntry(entry, now = Date.now()) {
250+
const removedPath = `${entry.path}.remove-${randomUUID()}`;
251+
if (!renameLease(entry.path, removedPath)) return removedCacheEntry();
252+
const state = cacheEntryState(entry, removedPath, now);
253+
if ([state.changed, state.retainedActive, state.replaced].includes(true)) {
254+
return restoreCacheEntry(entry, removedPath, state.retainedActive);
255+
}
256+
rmSync(removedPath, { force: true });
257+
return removedCacheEntry();
258+
}
259+
260+
function entries(cacheRoot) {
261+
return files(join(cacheRoot, 'summaries'))
262+
.filter((path) => path.endsWith('.json'))
263+
.map((path) => ({ path, ...statSync(path) }));
264+
}
265+
266+
function activeLeases(cacheRoot, now) {
267+
return files(join(cacheRoot, 'summaries')).filter(
268+
(path) => path.endsWith('.json.lock') && leaseIsActive(path, now),
269+
);
270+
}
271+
272+
export function cacheStatus({ cacheRoot = defaultCacheRoot(), now = Date.now() } = {}) {
273+
const cached = entries(cacheRoot);
274+
const bytes = cached.reduce((sum, entry) => sum + entry.size, 0);
275+
const ages = cached.map((entry) => now - entry.mtimeMs);
276+
return {
277+
location: cacheRoot,
278+
entries: cached.length,
279+
bytes,
280+
ageMs: ages.length ? Math.max(...ages) : 0,
281+
active: activeLeases(cacheRoot, now).length,
282+
};
283+
}
284+
285+
function shouldPrune(entry, { maxAgeMs, maxBytes, now, bytes }) {
286+
const overAge =
287+
maxAgeMs !== undefined && now - entry.mtimeMs > maxAgeMs;
288+
const overSize = maxBytes !== undefined && bytes > maxBytes;
289+
return overAge || overSize;
290+
}
291+
292+
function applyPruneResult(entry, result, removed, retainedActive) {
293+
if (result.removed) {
294+
removed.push(entry.path);
295+
return -entry.size;
296+
}
297+
if (result.retainedActive) retainedActive.push(entry.path);
298+
return result.size - entry.size;
299+
}
300+
301+
export function pruneCache({ cacheRoot = defaultCacheRoot(), maxAgeMs, maxBytes, now = Date.now() } = {}) {
302+
const cached = entries(cacheRoot).sort((a, b) => a.mtimeMs - b.mtimeMs);
303+
let bytes = cached.reduce((sum, entry) => sum + entry.size, 0);
304+
const removed = [];
305+
const retainedActive = [];
306+
for (const entry of cached) {
307+
if (!shouldPrune(entry, { maxAgeMs, maxBytes, now, bytes })) continue;
308+
const result = removeCacheEntry(entry, now);
309+
bytes += applyPruneResult(entry, result, removed, retainedActive);
310+
}
311+
return { removed, retainedActive, bytes };
312+
}
313+
314+
export function clearCache(options) {
315+
return pruneCache({ ...options, maxAgeMs: 0 });
316+
}
317+
318+
export function formatCacheStatus(status) {
319+
return `Location: ${status.location}\nSize: ${status.bytes} bytes\nOldest entry: ${Math.floor(status.ageMs / 1000)} seconds\nActive use: ${status.active} target${status.active === 1 ? '' : 's'}`;
320+
}

scripts/check.mjs

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,7 @@ const requiredPackageFiles = [
6363
'package.json',
6464
'dist/index.html',
6565
'scripts/build-diff-data.mjs',
66+
'scripts/cache.mjs',
6667
'scripts/cli-args.mjs',
6768
'scripts/coding-agents.mjs',
6869
'scripts/doctor.mjs',
@@ -71,7 +72,7 @@ const requiredPackageFiles = [
7172
'scripts/serve-built.mjs',
7273
'scripts/summary-path.mjs',
7374
];
74-
const allowedPackageFile = /^(README(?:\.md)?|LICENSE(?:\.md)?|NOTICE(?:\.md)?|package\.json|dist\/.+|scripts\/(?:build-diff-data|cli-args|coding-agents|doctor|generate-summaries|present|serve-built|summary-path)\.mjs)$/;
75+
const allowedPackageFile = /^(README(?:\.md)?|LICENSE(?:\.md)?|NOTICE(?:\.md)?|package\.json|dist\/.+|scripts\/(?:build-diff-data|cache|cli-args|coding-agents|doctor|generate-summaries|present|serve-built|summary-path)\.mjs)$/;
7576
const privatePackageFile = /(^|\/)(?:\.env|\.npmrc|\.git|\.github|\.agents|\.codex)(?:\/|$)|\.(?:pem|key)$/i;
7677

7778
export function validatePackageManifest(pack) {

0 commit comments

Comments
 (0)