246 lines
8.6 KiB
JavaScript
246 lines
8.6 KiB
JavaScript
#!/usr/bin/env node
|
|
|
|
/**
|
|
* reserve-report-num.mjs — Atomically reserve the next report number.
|
|
*
|
|
* Fixes the race condition described in #749: when two Claude Code windows
|
|
* (or batch workers) run simultaneously they each compute `max(existing)+1`
|
|
* independently and collide on the same report-number slot.
|
|
*
|
|
* ## How it works
|
|
*
|
|
* Uses `fs.writeFileSync(path, data, { flag: 'wx' })` — which maps to
|
|
* `open(O_CREAT|O_EXCL)` on POSIX and `CreateFile(CREATE_NEW)` on Windows —
|
|
* to create a sentinel file atomically. If two processes try to claim the
|
|
* same number simultaneously only one succeeds; the loser increments and
|
|
* retries. No external lock daemon or advisory file is needed.
|
|
*
|
|
* The sentinel is a zero-byte marker named `NNN-RESERVED.md` inside
|
|
* `reports/`. The caller (mode file or agent) must:
|
|
* 1. Run this script to get a number.
|
|
* 2. Write the real report file `NNN-{slug}-{date}.md`.
|
|
* 3. Delete the sentinel (or let verify-pipeline.mjs GC it on next run).
|
|
*
|
|
* ## Usage
|
|
*
|
|
* node reserve-report-num.mjs
|
|
* # stdout: 035 (zero-padded, 3 digits)
|
|
*
|
|
* node reserve-report-num.mjs --count 8
|
|
* # stdout: 042-049 (reserves a contiguous range — for multi-agent
|
|
* # fan-outs: reserve first, hand each parallel
|
|
* # worker its own number. On collision the whole
|
|
* # range restarts past the taken slot, so skipped
|
|
* # numbers become permanent gaps — expected, not
|
|
* # corruption. Range protection follows the normal
|
|
* # sentinel TTL: reserve right before spawning.)
|
|
*
|
|
* node reserve-report-num.mjs --release 035
|
|
* node reserve-report-num.mjs --release 042-049
|
|
* # Deletes the sentinel(s) (call after writing the real report(s)).
|
|
*
|
|
* node reserve-report-num.mjs --gc
|
|
* # Removes all stale sentinels older than MAX_SENTINEL_AGE_MS.
|
|
* # Called automatically by verify-pipeline.mjs.
|
|
*
|
|
* The script exits with code 0 on success, non-zero on fatal error.
|
|
*/
|
|
|
|
import { readdirSync, writeFileSync, unlinkSync, statSync, existsSync, mkdirSync } from 'fs';
|
|
import { join, dirname } from 'path';
|
|
import { fileURLToPath } from 'url';
|
|
|
|
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
const REPORTS_DIR = process.env.CAREER_OPS_REPORTS_DIR || join(__dirname, 'reports');
|
|
|
|
// Sentinels older than this are considered stale and may be GC'd.
|
|
// 4 hours covers any reasonable interactive or batch session.
|
|
const MAX_SENTINEL_AGE_MS = 4 * 60 * 60 * 1000;
|
|
|
|
// Maximum number of retries before giving up (guards against pathological
|
|
// contention — in practice 2-3 parallel windows will resolve in < 5 tries).
|
|
const MAX_RETRIES = 50;
|
|
|
|
// Maximum range size for --count (guards typos like --count 800).
|
|
const MAX_COUNT = 50;
|
|
|
|
// ── helpers ─────────────────────────────────────────────────────────────────
|
|
|
|
function pad(n) {
|
|
return String(n).padStart(3, '0');
|
|
}
|
|
|
|
/** Return the highest numeric slot currently taken in reports/ (files + sentinels). */
|
|
function maxSlot() {
|
|
if (!existsSync(REPORTS_DIR)) return 0;
|
|
const entries = readdirSync(REPORTS_DIR);
|
|
let max = 0;
|
|
for (const name of entries) {
|
|
const m = name.match(/^(\d+)-/);
|
|
if (m) max = Math.max(max, parseInt(m[1], 10));
|
|
}
|
|
return max;
|
|
}
|
|
|
|
/**
|
|
* One readdir pass → Set of numeric prefixes currently occupying slots
|
|
* (e.g. "042" from "042-acme-2026-07-02.md" or "042-RESERVED.md").
|
|
* Advisory only — real atomicity comes from claimSlot's O_CREAT|O_EXCL write.
|
|
*/
|
|
function takenPrefixes() {
|
|
const taken = new Set();
|
|
if (!existsSync(REPORTS_DIR)) return taken;
|
|
for (const name of readdirSync(REPORTS_DIR)) {
|
|
const m = name.match(/^(\d+)-/);
|
|
if (m) taken.add(m[1]);
|
|
}
|
|
return taken;
|
|
}
|
|
|
|
/**
|
|
* Attempt to atomically claim slot `n`. Returns true on success.
|
|
* `taken` is an optional pre-scanned Set from takenPrefixes(); without it,
|
|
* the occupancy pre-check scans REPORTS_DIR itself. Either way the check is
|
|
* only advisory — the 'wx' write below is what guarantees atomicity.
|
|
*/
|
|
function claimSlot(n, taken = null) {
|
|
// Check if any file (real report or sentinel) already occupies this slot
|
|
const occupied = taken
|
|
? taken.has(pad(n))
|
|
: existsSync(REPORTS_DIR) && readdirSync(REPORTS_DIR).some(name => name.startsWith(`${pad(n)}-`));
|
|
if (occupied) return false;
|
|
|
|
const sentinel = join(REPORTS_DIR, `${pad(n)}-RESERVED.md`);
|
|
try {
|
|
// 'wx' = O_CREAT | O_EXCL — fails if file already exists.
|
|
writeFileSync(sentinel, '', { flag: 'wx' });
|
|
return true;
|
|
} catch (err) {
|
|
if (err.code === 'EEXIST') return false; // another process beat us
|
|
throw err; // unexpected FS error
|
|
}
|
|
}
|
|
|
|
/** Release (delete) the sentinel for slot `n`. */
|
|
function releaseSlot(n) {
|
|
const sentinel = join(REPORTS_DIR, `${pad(n)}-RESERVED.md`);
|
|
if (existsSync(sentinel)) unlinkSync(sentinel);
|
|
}
|
|
|
|
/**
|
|
* Reserve `count` contiguous slots. All-or-nothing per attempt: if any slot
|
|
* in the candidate range is already taken, release the slots claimed so far
|
|
* and restart past the collision. Each slot claim is individually atomic
|
|
* (O_CREAT|O_EXCL), which is all the pipeline needs — contiguity is an
|
|
* ergonomic property, not a correctness one. Skipped numbers become
|
|
* permanent gaps; report numbers are opaque IDs, so gaps are harmless.
|
|
* Returns the array of reserved numbers, or null if MAX_RETRIES attempts
|
|
* were exhausted. Terminates under contention: `base` strictly advances
|
|
* past every collision, so two racing ranges can never livelock.
|
|
*/
|
|
function reserveRange(count) {
|
|
let base = maxSlot() + 1;
|
|
let tries = 0;
|
|
// One directory scan per attempt, shared by every per-slot check;
|
|
// refreshed only after a collision forces a retry.
|
|
let taken = takenPrefixes();
|
|
while (tries < MAX_RETRIES) {
|
|
const claimed = [];
|
|
let failedAt = -1;
|
|
for (let n = base; n < base + count; n++) {
|
|
if (claimSlot(n, taken)) {
|
|
claimed.push(n);
|
|
} else {
|
|
failedAt = n;
|
|
break;
|
|
}
|
|
}
|
|
if (failedAt === -1) return claimed;
|
|
for (const n of claimed) releaseSlot(n);
|
|
base = failedAt + 1;
|
|
tries++;
|
|
taken = takenPrefixes();
|
|
}
|
|
return null;
|
|
}
|
|
|
|
/** GC stale sentinels (no real report was written within MAX_SENTINEL_AGE_MS). */
|
|
function gc() {
|
|
if (!existsSync(REPORTS_DIR)) return;
|
|
const now = Date.now();
|
|
let removed = 0;
|
|
for (const name of readdirSync(REPORTS_DIR)) {
|
|
if (!name.endsWith('-RESERVED.md')) continue;
|
|
const full = join(REPORTS_DIR, name);
|
|
try {
|
|
const { mtimeMs } = statSync(full);
|
|
if (now - mtimeMs > MAX_SENTINEL_AGE_MS) {
|
|
unlinkSync(full);
|
|
removed++;
|
|
process.stderr.write(`reserve-report-num: GC stale sentinel ${name}\n`);
|
|
}
|
|
} catch {
|
|
// Already gone — fine.
|
|
}
|
|
}
|
|
if (removed > 0) {
|
|
process.stderr.write(`reserve-report-num: removed ${removed} stale sentinel(s)\n`);
|
|
}
|
|
}
|
|
|
|
// ── CLI ──────────────────────────────────────────────────────────────────────
|
|
|
|
const [,, cmd, arg] = process.argv;
|
|
|
|
if (cmd === '--release') {
|
|
const m = (arg || '').match(/^(\d+)(?:-(\d+))?$/);
|
|
if (!m) {
|
|
process.stderr.write('Usage: node reserve-report-num.mjs --release <NNN>[-<MMM>]\n');
|
|
process.exit(1);
|
|
}
|
|
const start = parseInt(m[1], 10);
|
|
const end = m[2] ? parseInt(m[2], 10) : start;
|
|
if (end < start) {
|
|
process.stderr.write('reserve-report-num: --release range end must be >= start\n');
|
|
process.exit(1);
|
|
}
|
|
for (let n = start; n <= end; n++) releaseSlot(n);
|
|
process.exit(0);
|
|
}
|
|
|
|
if (cmd === '--gc') {
|
|
gc();
|
|
process.exit(0);
|
|
}
|
|
|
|
// Default (or --count N): reserve the next slot(s).
|
|
let count = 1;
|
|
if (cmd === '--count') {
|
|
if (!/^\d+$/.test(arg || '')) {
|
|
process.stderr.write(`Usage: node reserve-report-num.mjs --count <1-${MAX_COUNT}>\n`);
|
|
process.exit(1);
|
|
}
|
|
count = parseInt(arg, 10);
|
|
if (count < 1 || count > MAX_COUNT) {
|
|
process.stderr.write(`Usage: node reserve-report-num.mjs --count <1-${MAX_COUNT}>\n`);
|
|
process.exit(1);
|
|
}
|
|
}
|
|
// Any other/unknown cmd falls through to a single reserve — unchanged
|
|
// legacy behavior.
|
|
|
|
mkdirSync(REPORTS_DIR, { recursive: true });
|
|
|
|
const nums = reserveRange(count);
|
|
if (!nums) {
|
|
process.stderr.write(`reserve-report-num: could not claim ${count} slot(s) after ${MAX_RETRIES} retries\n`);
|
|
process.exit(1);
|
|
}
|
|
|
|
process.stdout.write(
|
|
count === 1
|
|
? pad(nums[0]) + '\n'
|
|
: `${pad(nums[0])}-${pad(nums[nums.length - 1])}\n`
|
|
);
|
|
process.exit(0);
|