* test(#3780): regression tests for parallel ledger-writer loss * fix(#3780): serialize WINDOWS.md mutations on a cross-process ledger lock * fix(#3780): keep the ledger-unavailable degrade contract intact under the lock wrapper * chore(#3780): backfill changeset PR number (4681) --------- Co-authored-by: sim <sim@local>
This commit is contained in:
5
.changeset/steady-tunas-tumble.md
Normal file
5
.changeset/steady-tunas-tumble.md
Normal file
@@ -0,0 +1,5 @@
|
||||
---
|
||||
type: Fixed
|
||||
pr: 4681
|
||||
---
|
||||
**Parallel ledger writers no longer silently lose windows entries** — two concurrent `gsd_run windows append` (or waive/fixed) invocations both reported success while one entry vanished from `WINDOWS.md`, false-greening the /gsd-ship gate; the mutating commands now serialize on a cross-process ledger lock and refuse with a typed `windows_ledger_lock` error only when a live writer holds it past the retry budget. (#3780)
|
||||
File diff suppressed because one or more lines are too long
@@ -6,7 +6,10 @@
|
||||
* When `workflow.windows_enforce` is true, `/gsd-ship` blocks while any entry is
|
||||
* `open`; an entry can be `waived` only with a recorded reason or `fixed`.
|
||||
*
|
||||
* LEAF MODULE — imports ONLY: node:fs, node:path. No other src/ imports.
|
||||
* LEAF MODULE — imports node:fs + node:path, plus two compiled sibling lib
|
||||
* modules require()d at runtime: workstream-inventory.cjs (the #4487
|
||||
* milestone stamp) and capability-lock.cjs (the #3780 cross-process ledger
|
||||
* lock). No other src/ imports.
|
||||
*
|
||||
* Storage format (`.planning/WINDOWS.md`):
|
||||
* ---
|
||||
@@ -76,6 +79,11 @@ export const REASON = Object.freeze({
|
||||
// sole source of truth) at the pre-write seam — refuse rather than silently
|
||||
// reconcile by overwriting the operator's hand-edit or dropping a row.
|
||||
WINDOWS_LEDGER_TABLE_DRIFT: 'windows_ledger_table_drift',
|
||||
// #3780: the read-compute-write cycle is serialized on a cross-process
|
||||
// ledger lock; this fires only when another writer held the lock past the
|
||||
// whole bounded retry budget — a typed, actionable refusal instead of a
|
||||
// silently-lost mutation reported as success.
|
||||
WINDOWS_LEDGER_LOCK: 'windows_ledger_lock',
|
||||
});
|
||||
|
||||
/** Allowed window kinds. Aligned with the issue's enumerated sources. */
|
||||
@@ -289,12 +297,14 @@ function nextId(entries: WindowEntry[]): number {
|
||||
* Append a window to the ledger. Assigns the next dense id (max+1), sets
|
||||
* status=open, timestamps via opts.now.
|
||||
*
|
||||
* Concurrency (issue #1950 review L2): NOT safe for concurrent writers. Two
|
||||
* parallel `gsd_run windows append` invocations both read the same snapshot,
|
||||
* both compute the same nextId, both write — the second atomic rename wins
|
||||
* and the first append (and the entry it added) is silently lost. This is
|
||||
* acceptable in the current single-executor-per-phase model; document if the
|
||||
* executor ever gains parallel wave-level append.
|
||||
* Concurrency (issue #1950 review L2, superseded by #3780): as a PURE
|
||||
* function this operates on whatever ledger snapshot it is passed and cannot
|
||||
* see concurrent writers — serialization is the CALLER's job. The I/O entry
|
||||
* points below (cmdWindowsAppend/Waive/MarkFixed) now discharge that duty by
|
||||
* holding the cross-process ledger lock across their whole
|
||||
* read-compute-write cycle, so the previously-documented loss (two parallel
|
||||
* writers, second rename wins, first entry silently gone) can no longer
|
||||
* occur through the CLI.
|
||||
*/
|
||||
export function appendWindow(
|
||||
ledger: Ledger,
|
||||
@@ -878,6 +888,98 @@ function ledgerPath(cwd: string): string {
|
||||
return path.join(cwd, '.planning', LEDGER_FILE_NAME);
|
||||
}
|
||||
|
||||
// ─── #3780: cross-process ledger mutation lock ─────────────────────────────
|
||||
|
||||
interface LedgerLockHandle { path: string; token: string; dev: number | null; ino: number | null }
|
||||
|
||||
interface LockModule {
|
||||
acquireLock: (
|
||||
lockPath: string,
|
||||
opts?: { maxAttempts?: number; waitForFresh?: boolean },
|
||||
) => LedgerLockHandle | null;
|
||||
releaseLock: (handle: LedgerLockHandle | null) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* The SHARED hardened cross-process lock primitive (single source of truth
|
||||
* for capability-lifecycle + capability-consent, extracted so locks cannot
|
||||
* diverge). Required LAZILY: capability-lock captures the process start time
|
||||
* at module load — a `ps` subprocess on macOS, PowerShell on win32 — and
|
||||
* this module is loaded by lock-free readers (`windows status`, the
|
||||
* /gsd-ship gate) that must not pay that per-invocation cost (#3780
|
||||
* review). `require` is cached, so writers pay it once per process.
|
||||
*/
|
||||
let _lockMod: LockModule | null = null;
|
||||
function ledgerLock(): LockModule {
|
||||
if (_lockMod === null) {
|
||||
/* eslint-disable @typescript-eslint/no-require-imports */
|
||||
_lockMod = require('./capability-lock.cjs') as LockModule;
|
||||
/* eslint-enable @typescript-eslint/no-require-imports */
|
||||
}
|
||||
return _lockMod;
|
||||
}
|
||||
|
||||
/**
|
||||
* Budget mirrors capability-consent's CONSENT_LOCK_MAX_ATTEMPTS: two
|
||||
* genuinely-racing writers must SERIALIZE, not fail. The ledger's critical
|
||||
* section is sub-millisecond and the primitive backs off ~25-50ms per
|
||||
* attempt, so 50 attempts is orders of magnitude beyond any real contention
|
||||
* while keeping the worst case (a holder that never releases until the
|
||||
* primitive's own liveness/deadman protocol reclaims it) bounded at ~2s
|
||||
* before the typed refusal below.
|
||||
*/
|
||||
const LEDGER_LOCK_MAX_ATTEMPTS = 50;
|
||||
|
||||
function ledgerLockPath(cwd: string): string {
|
||||
return path.join(cwd, '.planning', '.WINDOWS.lock');
|
||||
}
|
||||
|
||||
function acquireLedgerLock(cwd: string): LedgerLockHandle | null {
|
||||
return ledgerLock().acquireLock(ledgerLockPath(cwd), {
|
||||
maxAttempts: LEDGER_LOCK_MAX_ATTEMPTS,
|
||||
// A contended fresh/live holder is WAITED FOR (back off + retry), not
|
||||
// failed-fast — racing wave-level executors serialize (issue #3780).
|
||||
waitForFresh: true,
|
||||
});
|
||||
}
|
||||
|
||||
function releaseLedgerLock(handle: LedgerLockHandle | null): void {
|
||||
ledgerLock().releaseLock(handle);
|
||||
}
|
||||
|
||||
/**
|
||||
* Run `fn` (a full ledger read-compute-write cycle) while holding the
|
||||
* cross-process ledger lock. Throws a typed WindowsError — never falls back
|
||||
* to an unlocked mutation — when the lock cannot be acquired within the
|
||||
* budget, mirroring capability-consent finding 3: a locked store must refuse
|
||||
* the write rather than silently race for it. Readers (cmdWindowsStatus, the
|
||||
* ship gate) deliberately do NOT take this lock: the atomic rename already
|
||||
* gives them a whole-file snapshot.
|
||||
*
|
||||
* EXPORTED (#3780) because `withLedgerLock` is the ONE serialization seam
|
||||
* for WINDOWS.md: every writer of the ledger — the cmd* entry points here
|
||||
* and any sibling module with its own read-compute-write cycle on the same
|
||||
* file (refactor-trigger-command-router's strict-window record/resolve) —
|
||||
* must hold this lock, or the lost-update race #3780 fixed survives on that
|
||||
* path.
|
||||
*/
|
||||
export function withLedgerLock<T>(cwd: string, fn: () => T): T {
|
||||
const handle = acquireLedgerLock(cwd);
|
||||
if (!handle) {
|
||||
throw new WindowsError(
|
||||
REASON.WINDOWS_LEDGER_LOCK,
|
||||
`Another writer holds the ledger lock at ${ledgerLockPath(cwd)}; WINDOWS.md ` +
|
||||
'mutations are serialized per project. Re-run the command once the other ' +
|
||||
'writer finishes — the lock is reclaimed automatically if its holder died.',
|
||||
);
|
||||
}
|
||||
try {
|
||||
return fn();
|
||||
} finally {
|
||||
releaseLedgerLock(handle);
|
||||
}
|
||||
}
|
||||
|
||||
function readLedgerOrNull(cwd: string): Ledger | null {
|
||||
const p = ledgerPath(cwd);
|
||||
let raw: string;
|
||||
@@ -1129,37 +1231,43 @@ export function cmdWindowsAppend(
|
||||
required: ['--kind', '--phase', '--description'],
|
||||
});
|
||||
|
||||
let ledger: Ledger;
|
||||
try {
|
||||
ledger = readLedgerOrNull(cwd) ?? emptyLedger(nowIso());
|
||||
} catch (e) {
|
||||
if (e instanceof WindowsError) throw e;
|
||||
throw new WindowsError(REASON.WINDOWS_LEDGER_MALFORMED, (e as Error).message);
|
||||
}
|
||||
// #3780: the whole read-compute-write cycle — snapshot, milestone stamp,
|
||||
// id allocation, atomic rename — holds the ledger lock, so two parallel
|
||||
// invocations can no longer compute the same nextId from the same snapshot
|
||||
// and silently lose the first append to the second rename.
|
||||
withLedgerLock(cwd, () => {
|
||||
let ledger: Ledger;
|
||||
try {
|
||||
ledger = readLedgerOrNull(cwd) ?? emptyLedger(nowIso());
|
||||
} catch (e) {
|
||||
if (e instanceof WindowsError) throw e;
|
||||
throw new WindowsError(REASON.WINDOWS_LEDGER_MALFORMED, (e as Error).message);
|
||||
}
|
||||
|
||||
// #4487: stamp the workstream's resolved milestone at record time -- the
|
||||
// same STATE.md-first, ROADMAP-fallback resolution workstream-inventory.cts
|
||||
// already uses. Best-effort: an unreadable/missing STATE.md or ROADMAP.md
|
||||
// resolves to null, same as an entry recorded before this field existed.
|
||||
const milestone = workstreamInventory.readCurrentMilestoneVersion(
|
||||
path.join(cwd, '.planning', 'STATE.md'),
|
||||
path.join(cwd, '.planning', 'ROADMAP.md'),
|
||||
);
|
||||
// #4487: stamp the workstream's resolved milestone at record time -- the
|
||||
// same STATE.md-first, ROADMAP-fallback resolution workstream-inventory.cts
|
||||
// already uses. Best-effort: an unreadable/missing STATE.md or ROADMAP.md
|
||||
// resolves to null, same as an entry recorded before this field existed.
|
||||
const milestone = workstreamInventory.readCurrentMilestoneVersion(
|
||||
path.join(cwd, '.planning', 'STATE.md'),
|
||||
path.join(cwd, '.planning', 'ROADMAP.md'),
|
||||
);
|
||||
|
||||
const result = appendWindow(
|
||||
ledger,
|
||||
{
|
||||
kind: parsed.values['--kind'] as WindowKind,
|
||||
phase: parsed.values['--phase'] ?? '',
|
||||
file: parsed.values['--file'] ?? '',
|
||||
line: parsed.values['--line'] == null ? null : Number(parsed.values['--line']),
|
||||
description: parsed.values['--description'] ?? '',
|
||||
milestone,
|
||||
},
|
||||
{ now: nowIso() },
|
||||
);
|
||||
writeLedgerAtomic(cwd, result.ledger);
|
||||
emit({ ok: true, ledger: result.ledger, entry: result.entry });
|
||||
const result = appendWindow(
|
||||
ledger,
|
||||
{
|
||||
kind: parsed.values['--kind'] as WindowKind,
|
||||
phase: parsed.values['--phase'] ?? '',
|
||||
file: parsed.values['--file'] ?? '',
|
||||
line: parsed.values['--line'] == null ? null : Number(parsed.values['--line']),
|
||||
description: parsed.values['--description'] ?? '',
|
||||
milestone,
|
||||
},
|
||||
{ now: nowIso() },
|
||||
);
|
||||
writeLedgerAtomic(cwd, result.ledger);
|
||||
emit({ ok: true, ledger: result.ledger, entry: result.entry });
|
||||
});
|
||||
}
|
||||
|
||||
/** `gsd-tools windows waive <id> "<reason>"`. */
|
||||
@@ -1175,17 +1283,21 @@ export function cmdWindowsWaive(
|
||||
|
||||
const id = parseIdOrThrow(idStr);
|
||||
|
||||
let ledger: Ledger;
|
||||
try {
|
||||
ledger = readLedgerOrNull(cwd) ?? emptyLedger(nowIso());
|
||||
} catch (e) {
|
||||
if (e instanceof WindowsError) throw e;
|
||||
throw new WindowsError(REASON.WINDOWS_LEDGER_MALFORMED, (e as Error).message);
|
||||
}
|
||||
// #3780: same serialization as append — a concurrent append holding a stale
|
||||
// snapshot would otherwise overwrite the waive and resurrect the entry.
|
||||
withLedgerLock(cwd, () => {
|
||||
let ledger: Ledger;
|
||||
try {
|
||||
ledger = readLedgerOrNull(cwd) ?? emptyLedger(nowIso());
|
||||
} catch (e) {
|
||||
if (e instanceof WindowsError) throw e;
|
||||
throw new WindowsError(REASON.WINDOWS_LEDGER_MALFORMED, (e as Error).message);
|
||||
}
|
||||
|
||||
const updated = markWaived(ledger, id, reason ?? '', { now: nowIso() });
|
||||
writeLedgerAtomic(cwd, updated);
|
||||
emit({ ok: true, ledger: updated });
|
||||
const updated = markWaived(ledger, id, reason ?? '', { now: nowIso() });
|
||||
writeLedgerAtomic(cwd, updated);
|
||||
emit({ ok: true, ledger: updated });
|
||||
});
|
||||
}
|
||||
|
||||
/** `gsd-tools windows fixed <id>`. */
|
||||
@@ -1198,17 +1310,21 @@ export function cmdWindowsMarkFixed(
|
||||
const { positionals } = parseArgs(args, { flags: [], required: [], positionals: 1 });
|
||||
const id = parseIdOrThrow(positionals[0]);
|
||||
|
||||
let ledger: Ledger;
|
||||
try {
|
||||
ledger = readLedgerOrNull(cwd) ?? emptyLedger(nowIso());
|
||||
} catch (e) {
|
||||
if (e instanceof WindowsError) throw e;
|
||||
throw new WindowsError(REASON.WINDOWS_LEDGER_MALFORMED, (e as Error).message);
|
||||
}
|
||||
// #3780: same serialization as append — a concurrent writer holding a
|
||||
// stale snapshot would otherwise overwrite the resolved status.
|
||||
withLedgerLock(cwd, () => {
|
||||
let ledger: Ledger;
|
||||
try {
|
||||
ledger = readLedgerOrNull(cwd) ?? emptyLedger(nowIso());
|
||||
} catch (e) {
|
||||
if (e instanceof WindowsError) throw e;
|
||||
throw new WindowsError(REASON.WINDOWS_LEDGER_MALFORMED, (e as Error).message);
|
||||
}
|
||||
|
||||
const updated = markFixed(ledger, id, { now: nowIso() });
|
||||
writeLedgerAtomic(cwd, updated);
|
||||
emit({ ok: true, ledger: updated });
|
||||
const updated = markFixed(ledger, id, { now: nowIso() });
|
||||
writeLedgerAtomic(cwd, updated);
|
||||
emit({ ok: true, ledger: updated });
|
||||
});
|
||||
}
|
||||
|
||||
function parseIdOrThrow(raw: string | undefined): number {
|
||||
|
||||
@@ -406,6 +406,36 @@ function loadWindowsOrDegrade(
|
||||
return { ok: true, windows, ledger };
|
||||
}
|
||||
|
||||
/**
|
||||
* #3780: lazy (module-load cost, see file header) require of the ledger's
|
||||
* serialization seam — the same `.planning/.WINDOWS.lock` the windows cmd*
|
||||
* writers hold, so this router's own read-compute-write cycles on
|
||||
* WINDOWS.md cannot lose updates against them. Required from the REAL
|
||||
* compiled module, never through the injectable `windowsOverride` seam:
|
||||
* the lock is infrastructure, not a parser stand-in.
|
||||
*/
|
||||
function withLedgerLock<T>(cwd: string, fn: () => T): T {
|
||||
let lockMod: {
|
||||
withLedgerLock: (cwd: string, fn: () => T) => T;
|
||||
};
|
||||
try {
|
||||
/* eslint-disable @typescript-eslint/no-require-imports */
|
||||
lockMod = require('./broken-windows.cjs') as {
|
||||
withLedgerLock: (cwd: string, fn: () => T) => T;
|
||||
};
|
||||
/* eslint-enable @typescript-eslint/no-require-imports */
|
||||
} catch {
|
||||
// Ledger module unavailable (the #1953 degrade world — see the row-86
|
||||
// notesLedgerUnavailableWithoutBrokenWindows contract): no lock-holding
|
||||
// writer can exist either, because every WINDOWS.md writer requires this
|
||||
// same module. Run the body unlocked and let loadWindowsOrDegrade
|
||||
// produce the canonical degrade note — wrapping that world in lock
|
||||
// ceremony would only rewrite the note the degrade contract pins.
|
||||
return fn();
|
||||
}
|
||||
return lockMod.withLedgerLock(cwd, fn);
|
||||
}
|
||||
|
||||
/**
|
||||
* Strict-mode window append (step 9 of `evaluate`). Degrades to
|
||||
* `{ recorded: false, note }` per `loadWindowsOrDegrade` — never an error,
|
||||
@@ -418,6 +448,28 @@ function recordStrictWindow(
|
||||
padded: string,
|
||||
target: Candidate,
|
||||
windowsOverride: WindowsModule | undefined,
|
||||
): { recorded: boolean; note?: string } {
|
||||
// #3780: hold the same cross-process ledger lock the windows cmd* writers
|
||||
// hold — this site's read-compute-write on WINDOWS.md is otherwise the
|
||||
// same lost-update race: a concurrent `gsd_run windows append` (or another
|
||||
// evaluator) could silently overwrite this entry, or be overwritten by
|
||||
// it. The lock comes from the real ledger module, not the injectable
|
||||
// `windowsOverride` seam — it is infrastructure, not a parser stand-in.
|
||||
// The wrapper keeps the #1953-defect-2 degrade contract: the lock's typed
|
||||
// refusal degrades to `{ recorded: false, note }` like every other
|
||||
// failure, it never throws out of this function.
|
||||
try {
|
||||
return withLedgerLock(cwd, () => recordStrictWindowLocked(cwd, padded, target, windowsOverride));
|
||||
} catch (e) {
|
||||
return { recorded: false, note: `failed to record broken-windows entry: ${e instanceof Error ? e.message : String(e)}` };
|
||||
}
|
||||
}
|
||||
|
||||
function recordStrictWindowLocked(
|
||||
cwd: string,
|
||||
padded: string,
|
||||
target: Candidate,
|
||||
windowsOverride: WindowsModule | undefined,
|
||||
): { recorded: boolean; note?: string } {
|
||||
const loaded = loadWindowsOrDegrade(cwd, windowsOverride);
|
||||
if (!loaded.ok) return { recorded: false, note: loaded.note };
|
||||
@@ -460,6 +512,26 @@ function resolveLedgerWindow(
|
||||
kind: 'accept' | 'decline',
|
||||
reasonText: string,
|
||||
windowsOverride: WindowsModule | undefined,
|
||||
): { resolved: boolean; note?: string } {
|
||||
// #3780: same serialization as recordStrictWindow — a concurrent ledger
|
||||
// writer holding a stale snapshot would silently revert this resolve (or
|
||||
// lose its own write to this one). Same degrade contract: the lock's
|
||||
// typed refusal degrades, never throws.
|
||||
try {
|
||||
return withLedgerLock(cwd, () => resolveLedgerWindowLocked(cwd, padded, file, line, kind, reasonText, windowsOverride));
|
||||
} catch (e) {
|
||||
return { resolved: false, note: `failed to resolve broken-windows entry: ${e instanceof Error ? e.message : String(e)}` };
|
||||
}
|
||||
}
|
||||
|
||||
function resolveLedgerWindowLocked(
|
||||
cwd: string,
|
||||
padded: string,
|
||||
file: string,
|
||||
line: number,
|
||||
kind: 'accept' | 'decline',
|
||||
reasonText: string,
|
||||
windowsOverride: WindowsModule | undefined,
|
||||
): { resolved: boolean; note?: string } {
|
||||
const loaded = loadWindowsOrDegrade(cwd, windowsOverride);
|
||||
if (!loaded.ok) return { resolved: false, note: loaded.note };
|
||||
|
||||
@@ -24,11 +24,20 @@ const { describe, test } = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
const { spawn } = require('node:child_process');
|
||||
|
||||
const { createTempDir, cleanup, runGsdTools } = require('./helpers.cjs');
|
||||
const {
|
||||
createTempDir,
|
||||
cleanup,
|
||||
runGsdTools,
|
||||
TOOLS_PATH,
|
||||
TEST_ENV_BASE,
|
||||
} = require('./helpers.cjs');
|
||||
const { PROBE_TIMEOUT_MS } = require('./helpers/timeouts.cjs');
|
||||
const fc = require('./helpers/fast-check-setup.cjs');
|
||||
|
||||
const brokenWindowsLib = require('../gsd-core/bin/lib/broken-windows.cjs');
|
||||
const lockMod = require('../gsd-core/bin/lib/capability-lock.cjs');
|
||||
const {
|
||||
REASON,
|
||||
WindowsError,
|
||||
@@ -1773,3 +1782,223 @@ describe('#1950-H2 / #3689: writeLedgerAtomic pre-image read failure', () => {
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// #3780: parallel writers must not silently lose ledger mutations
|
||||
//
|
||||
// cmdWindowsAppend/Waive/MarkFixed each ran an unlocked read-compute-write
|
||||
// cycle ending in an atomic rename: two parallel invocations read the same
|
||||
// snapshot, computed the same nextId, and the second rename won — the first
|
||||
// mutation was silently lost while its invocation still reported ok:true
|
||||
// (a false-green /gsd-ship gate, since the ship decision reads open_count).
|
||||
// The fix serializes the mutating commands on a `.planning/.WINDOWS.lock`
|
||||
// ledger lock backed by the shared capability-lock primitive (the
|
||||
// capability-consent precedent: waitForFresh + raised budget, typed throw
|
||||
// when the lock cannot be acquired). Readers stay lock-free.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('#3780: parallel writers serialize on the ledger lock', () => {
|
||||
const lockRelPath = path.join('.planning', '.WINDOWS.lock');
|
||||
|
||||
/**
|
||||
* The sync process seam cannot interleave two writers in one thread, and a
|
||||
* concurrency regression needs genuinely concurrent children. Async spawn,
|
||||
* bounded by the CLI-probe class timeout, env built exactly the way
|
||||
* runGsdTools builds it (process env + TEST_ENV_BASE).
|
||||
*/
|
||||
function spawnAppend(tmp, description) {
|
||||
return new Promise((resolve) => {
|
||||
const child = spawn(
|
||||
process.execPath,
|
||||
[TOOLS_PATH, 'windows', 'append', '--kind', 'todo', '--phase', '1', '--description', description],
|
||||
{ cwd: tmp, env: { ...process.env, ...TEST_ENV_BASE } },
|
||||
);
|
||||
let stdout = '';
|
||||
let stderr = '';
|
||||
child.stdout.on('data', (d) => { stdout += d; });
|
||||
child.stderr.on('data', (d) => { stderr += d; });
|
||||
const timer = setTimeout(() => child.kill('SIGKILL'), PROBE_TIMEOUT_MS);
|
||||
child.on('close', (code) => {
|
||||
clearTimeout(timer);
|
||||
resolve({ code, stdout, stderr });
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function readEntries(tmp) {
|
||||
const raw = fs.readFileSync(path.join(tmp, '.planning', LEDGER_FILE_NAME), 'utf8');
|
||||
return parseLedger(raw).entries;
|
||||
}
|
||||
|
||||
/** Acquire the ledger lock as a stand-in live writer (same-host, never stolen). */
|
||||
function acquireHeldLock(tmp) {
|
||||
const handle = lockMod.acquireLock(path.join(tmp, lockRelPath), { maxAttempts: 1 });
|
||||
assert.ok(handle, 'test setup: the test process must be able to acquire the ledger lock');
|
||||
return handle;
|
||||
}
|
||||
|
||||
test('two concurrent gsd-tools append processes both land their entries', async (t) => {
|
||||
const tmp = createTempDir('bw-3780-race-');
|
||||
t.after(() => cleanup(tmp));
|
||||
|
||||
const [a, b] = await Promise.all([
|
||||
spawnAppend(tmp, 'writer-A'),
|
||||
spawnAppend(tmp, 'writer-B'),
|
||||
]);
|
||||
|
||||
assert.equal(a.code, 0, `writer-A must exit 0, stderr: ${a.stderr}`);
|
||||
assert.equal(b.code, 0, `writer-B must exit 0, stderr: ${b.stderr}`);
|
||||
assert.equal(JSON.parse(a.stdout).ok, true, 'writer-A must observe success');
|
||||
assert.equal(JSON.parse(b.stdout).ok, true, 'writer-B must observe success');
|
||||
|
||||
const entries = readEntries(tmp);
|
||||
assert.equal(entries.length, 2, 'both appends must be present in the ledger');
|
||||
assert.deepEqual(entries.map((e) => e.id).sort(), [1, 2], 'ids must be distinct — no shared nextId');
|
||||
assert.deepEqual(
|
||||
entries.map((e) => e.description).sort(),
|
||||
['writer-A', 'writer-B'],
|
||||
'neither description may be lost',
|
||||
);
|
||||
});
|
||||
|
||||
test('append refuses typed while another writer holds the ledger lock, and proceeds after release', (t) => {
|
||||
const tmp = createTempDir('bw-3780-held-append-');
|
||||
t.after(() => cleanup(tmp));
|
||||
const handle = acquireHeldLock(tmp);
|
||||
t.after(() => lockMod.releaseLock(handle));
|
||||
|
||||
const res = runGsdTools(
|
||||
['windows', 'append', '--kind', 'todo', '--phase', '1', '--description', 'blocked writer'],
|
||||
tmp,
|
||||
{ GSD_JSON_ERRORS: '1' },
|
||||
);
|
||||
assert.equal(res.success, false, 'append must refuse while the ledger lock is held by a live writer');
|
||||
const parsed = JSON.parse(res.error);
|
||||
// String literal, not REASON.WINDOWS_LEDGER_LOCK — the constant does not
|
||||
// exist on the pre-fix module and `undefined === undefined` would pass
|
||||
// vacuously (the #3689 precedent at the table-drift assertion).
|
||||
assert.equal(parsed.reason, 'windows_ledger_lock', `expected typed lock reason, got: ${res.error}`);
|
||||
assert.equal(
|
||||
fs.existsSync(path.join(tmp, '.planning', LEDGER_FILE_NAME)),
|
||||
false,
|
||||
'a refused append must not write the ledger',
|
||||
);
|
||||
|
||||
// After release the same mutation proceeds — the refusal was contention,
|
||||
// not corruption.
|
||||
lockMod.releaseLock(handle);
|
||||
const res2 = runGsdTools(
|
||||
['windows', 'append', '--kind', 'todo', '--phase', '1', '--description', 'after release'],
|
||||
tmp,
|
||||
);
|
||||
assert.equal(res2.success, true, `post-release append must succeed: ${res2.error || ''}`);
|
||||
assert.equal(JSON.parse(res2.output).entry.description, 'after release');
|
||||
});
|
||||
|
||||
test('append releases the ledger lock after success', (t) => {
|
||||
const tmp = createTempDir('bw-3780-release-ok-');
|
||||
t.after(() => cleanup(tmp));
|
||||
const res = runGsdTools(
|
||||
['windows', 'append', '--kind', 'todo', '--phase', '1', '--description', 'solo'],
|
||||
tmp,
|
||||
);
|
||||
assert.equal(res.success, true, `stderr: ${res.error || ''}`);
|
||||
assert.equal(
|
||||
fs.existsSync(path.join(tmp, lockRelPath)),
|
||||
false,
|
||||
'no lock file may survive a successful append',
|
||||
);
|
||||
});
|
||||
|
||||
test('append releases the ledger lock even when the append itself fails', (t) => {
|
||||
const tmp = createTempDir('bw-3780-release-fail-');
|
||||
t.after(() => cleanup(tmp));
|
||||
const bad = runGsdTools(
|
||||
['windows', 'append', '--kind', 'no-such-kind', '--phase', '1', '--description', 'x'],
|
||||
tmp,
|
||||
);
|
||||
assert.equal(bad.success, false, 'invalid kind must fail as before');
|
||||
assert.equal(
|
||||
fs.existsSync(path.join(tmp, lockRelPath)),
|
||||
false,
|
||||
'no lock file may survive a failed append',
|
||||
);
|
||||
const good = runGsdTools(
|
||||
['windows', 'append', '--kind', 'todo', '--phase', '1', '--description', 'retry'],
|
||||
tmp,
|
||||
);
|
||||
assert.equal(good.success, true, `a valid append after a failed one must proceed: ${good.error || ''}`);
|
||||
});
|
||||
|
||||
test('waive refuses typed while the ledger lock is held and proceeds after release', (t) => {
|
||||
const tmp = createTempDir('bw-3780-held-waive-');
|
||||
t.after(() => cleanup(tmp));
|
||||
const seed = runGsdTools(
|
||||
['windows', 'append', '--kind', 'todo', '--phase', '1', '--description', 'entry'],
|
||||
tmp,
|
||||
);
|
||||
assert.equal(seed.success, true, `seed append failed: ${seed.error || ''}`);
|
||||
const handle = acquireHeldLock(tmp);
|
||||
t.after(() => lockMod.releaseLock(handle));
|
||||
|
||||
const res = runGsdTools(['windows', 'waive', '1', 'waiver reason'], tmp, { GSD_JSON_ERRORS: '1' });
|
||||
assert.equal(res.success, false, 'waive must refuse while the ledger lock is held');
|
||||
const parsed = JSON.parse(res.error);
|
||||
assert.equal(parsed.reason, 'windows_ledger_lock', `expected typed lock reason, got: ${res.error}`);
|
||||
|
||||
lockMod.releaseLock(handle);
|
||||
const res2 = runGsdTools(['windows', 'waive', '1', 'waiver reason'], tmp);
|
||||
assert.equal(res2.success, true, `post-release waive must succeed: ${res2.error || ''}`);
|
||||
});
|
||||
|
||||
test('fixed refuses typed while the ledger lock is held', (t) => {
|
||||
const tmp = createTempDir('bw-3780-held-fixed-');
|
||||
t.after(() => cleanup(tmp));
|
||||
const seed = runGsdTools(
|
||||
['windows', 'append', '--kind', 'todo', '--phase', '1', '--description', 'entry'],
|
||||
tmp,
|
||||
);
|
||||
assert.equal(seed.success, true, `seed append failed: ${seed.error || ''}`);
|
||||
const handle = acquireHeldLock(tmp);
|
||||
t.after(() => lockMod.releaseLock(handle));
|
||||
|
||||
const res = runGsdTools(['windows', 'fixed', '1'], tmp, { GSD_JSON_ERRORS: '1' });
|
||||
assert.equal(res.success, false, 'fixed must refuse while the ledger lock is held');
|
||||
const parsed = JSON.parse(res.error);
|
||||
assert.equal(parsed.reason, 'windows_ledger_lock', `expected typed lock reason, got: ${res.error}`);
|
||||
});
|
||||
|
||||
test('status stays lock-free — reads do not block on the writer lock', (t) => {
|
||||
const tmp = createTempDir('bw-3780-status-free-');
|
||||
t.after(() => cleanup(tmp));
|
||||
const handle = acquireHeldLock(tmp);
|
||||
t.after(() => lockMod.releaseLock(handle));
|
||||
const res = runGsdTools(['windows', 'status', '--raw'], tmp);
|
||||
assert.equal(res.success, true, `status must not take the writer lock: ${res.error || ''}`);
|
||||
assert.equal(JSON.parse(res.output).ledger.open_count, 0);
|
||||
});
|
||||
|
||||
test('REASON enum gains WINDOWS_LEDGER_LOCK and stays frozen+closed', () => {
|
||||
assert.equal(Object.isFrozen(REASON), true);
|
||||
// Assert on the VALUES (the wire codes --json-errors can emit), not
|
||||
// Object.keys — the keys are the UPPER_CASE identifiers. Closure over
|
||||
// all 14 codes is the contract: adding/removing a code must update this
|
||||
// list in the same commit (the three-coordinated-changes rule).
|
||||
assert.deepEqual(Object.values(REASON).sort(), [
|
||||
'windows_already_resolved',
|
||||
'windows_append_missing_field',
|
||||
'windows_id_not_found',
|
||||
'windows_invalid_file',
|
||||
'windows_invalid_id',
|
||||
'windows_invalid_kind',
|
||||
'windows_invalid_text',
|
||||
'windows_ledger_lock',
|
||||
'windows_ledger_malformed',
|
||||
'windows_ledger_missing',
|
||||
'windows_ledger_table_drift',
|
||||
'windows_ok',
|
||||
'windows_usage',
|
||||
'windows_waive_reason_empty',
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -46,6 +46,7 @@ const {
|
||||
} = require('../gsd-core/bin/lib/complexity-trigger.cjs');
|
||||
const gitBaseBranch = require('../gsd-core/bin/lib/git-base-branch.cjs');
|
||||
const windowsModule = require('../gsd-core/bin/lib/broken-windows.cjs');
|
||||
const lockModule = require('../gsd-core/bin/lib/capability-lock.cjs');
|
||||
const { routeRefactorTriggerCommand } = require('../gsd-core/bin/lib/refactor-trigger-command-router.cjs');
|
||||
const registry = require('../gsd-core/bin/lib/capability-registry.cjs');
|
||||
const { validateCapability, VALID_LOOP_POINTS } = require('../gsd-core/bin/lib/capability-validator.cjs');
|
||||
@@ -654,6 +655,36 @@ describe('refactor-trigger: disposition + ledger', () => {
|
||||
|
||||
// ─── Rows 80-86 — strict mode -> broken-windows ledger ───────────────────────
|
||||
|
||||
describe('refactor-trigger router: locked-ledger degrade (#3780)', () => {
|
||||
test('evaluate degrades ledger_recorded to false with a note when a writer holds the ledger lock', (t) => {
|
||||
const dir = setupTriggeringProject('gsd-refactor-cli-3780-lock-', true);
|
||||
t.after(() => cleanup(dir));
|
||||
const lockPath = path.join(dir, '.planning', '.WINDOWS.lock');
|
||||
const handle = lockModule.acquireLock(lockPath, { maxAttempts: 1 });
|
||||
assert.ok(handle, 'test setup: the test process must be able to acquire the ledger lock');
|
||||
t.after(() => lockModule.releaseLock(handle));
|
||||
|
||||
const result = runCliOnce(['refactor', 'evaluate', '--phase', '1', '--raw'], dir);
|
||||
assert.strictEqual(result.exitCode, 0, 'evaluate must degrade, never fail, on lock contention');
|
||||
const parsed = parseStdout(result);
|
||||
assert.strictEqual(parsed.artifact_written, true, 'the local proposal artifact is independent of the ledger');
|
||||
assert.strictEqual(parsed.ledger_recorded, false);
|
||||
assert.match(parsed.ledger_note, /ledger lock/, `note must name the contention: ${parsed.ledger_note}`);
|
||||
assert.strictEqual(
|
||||
fs.existsSync(path.join(dir, '.planning', windowsModule.LEDGER_FILE_NAME)),
|
||||
false,
|
||||
'a degraded record must not write the ledger',
|
||||
);
|
||||
|
||||
// After release the same evaluation records — the refusal was contention.
|
||||
lockModule.releaseLock(handle);
|
||||
const result2 = runCliOnce(['refactor', 'evaluate', '--phase', '1', '--raw'], dir);
|
||||
assert.strictEqual(result2.exitCode, 0);
|
||||
assert.strictEqual(parseStdout(result2).ledger_recorded, true);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
describe('refactor-trigger: strict mode -> broken-windows ledger', () => {
|
||||
test('appendsNoWindowInAdvisoryMode', (t) => {
|
||||
const dir = setupTriggeringProject('gsd-refactor-cli-80-', false);
|
||||
|
||||
Reference in New Issue
Block a user