Files
santifer--career-ops/reserve-report-num.mjs
T
wehub-resource-sync d083df1fdb
CodeQL Analysis / Analyze (javascript-typescript) (push) Failing after 2s
Web CI / web typecheck + build (push) Failing after 1s
Release Please / release-please (push) Failing after 1s
CodeQL Analysis / Analyze (go) (push) Failing after 16s
chore: import upstream snapshot with attribution
2026-07-13 12:02:43 +08:00

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);