* feat(#453): add deterministic clock seam to lock modules Introduces get-shit-done/bin/lib/clock.cjs exporting realClock with now() (Date.now) and sleep() (Atomics.wait). acquireStateLock, writeStateMd, and readModifyWriteStateMd in state.cjs each accept an optional trailing clock param (default: realClock). withPlanningLock in planning-workspace.cjs gains the same seam. No production behavior change — all callers that omit the param continue to use realClock. Adds tests/helpers/clock.cjs (makeFakeClock) and tests/clock-seam.test.cjs with 20 deterministic in-process tests covering: lock serialization, timeout throw at maxWaitMs boundary, stale-lock takeover, lock released on error path, withPlanningLock timeout recovery, exit-cleanup integration, readModifyWriteStateMd call-site coverage (7 cmd*), and roadmap analyze behavioral assertion (50 phases, no elapsed-time gate). Deletes/converts per research verdicts: removes 11 source-grep/elapsed-time/ non-deterministic-concurrent tests across concurrency-safety.test.cjs, locking-bugs-1909-1916-1925-1927.test.cjs, and bug-1974-context-exhaustion- record.test.cjs. All deleted tests have deterministic replacements in clock-seam.test.cjs or surviving barrier-based tests. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(#453): update module inventory for clock.cjs; make EEXIST-retry assertion behavioral Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(#453): satisfy lint-tests — allow-test-rule annotation on readFileSync/includes runtime output check Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --------- Co-authored-by: CI Rebase Check <ci@gsd-redux> Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
233 lines
7.4 KiB
JavaScript
233 lines
7.4 KiB
JavaScript
/**
|
|
* Planning Workspace — .planning path resolution + active workstream routing.
|
|
*
|
|
* This module owns the planning workspace seam:
|
|
* - planningDir/planningRoot/planningPaths
|
|
* - planning lock semantics
|
|
*
|
|
* Active workstream pointer policy/session identity lives in
|
|
* active-workstream-store.cjs and is consumed here via thin adapters.
|
|
*/
|
|
|
|
const fs = require('fs');
|
|
const path = require('path');
|
|
const { platformEnsureDir } = require('./shell-command-projection.cjs');
|
|
const { realClock } = require('./clock.cjs');
|
|
const {
|
|
createSharedPointerAdapter,
|
|
createSessionScopedPointerAdapter,
|
|
createMemoryPointerAdapter,
|
|
getActiveWorkstream: getStoredActiveWorkstream,
|
|
setActiveWorkstream: setStoredActiveWorkstream,
|
|
clearActiveWorkstream: clearStoredActiveWorkstream,
|
|
} = require('./active-workstream-store.cjs');
|
|
|
|
// Track .planning/.lock files held by this process so they can be removed on exit.
|
|
const _heldPlanningLocks = new Set();
|
|
process.on('exit', () => {
|
|
for (const lockPath of _heldPlanningLocks) {
|
|
try { fs.unlinkSync(lockPath); } catch { /* already gone */ }
|
|
}
|
|
});
|
|
|
|
// Transient errno codes that indicate a temporary filesystem condition under
|
|
// concurrent O_EXCL races — Docker overlay-fs (ENOENT/EINVAL/EIO), NFS
|
|
// (ESTALE), and OS-level interrupt/retry signals (EAGAIN/EINTR). These are
|
|
// recoverable; withPlanningLock retries instead of propagating them.
|
|
// Truly fatal codes (EMFILE, ENOSPC, EROFS, EACCES) are NOT in this set and
|
|
// will still throw immediately.
|
|
const PLANNING_LOCK_RETRY_ERRNOS = new Set([
|
|
'EPERM', // Windows / macOS AV scanner holds the file open during delete
|
|
'EBUSY', // Windows: file in use by another process
|
|
'EAGAIN', // POSIX: resource temporarily unavailable
|
|
'EINTR', // POSIX: syscall interrupted by signal
|
|
'EINVAL', // Docker overlay-fs: transient during concurrent O_EXCL creation
|
|
'EIO', // Docker overlay-fs / NFS: transient I/O error
|
|
'ENOENT', // Docker overlay-fs: parent dir transiently missing during race
|
|
'ESTALE', // NFS: stale file handle (self-resolves on retry)
|
|
]);
|
|
|
|
function planningDir(cwd, ws, project) {
|
|
if (project === undefined) project = process.env.GSD_PROJECT || null;
|
|
if (ws === undefined) ws = process.env.GSD_WORKSTREAM || null;
|
|
|
|
// Reject path separators and traversal components in project/workstream names
|
|
const BAD_SEGMENT = /[/\\]|\.\./;
|
|
if (project && BAD_SEGMENT.test(project)) {
|
|
throw new Error(`GSD_PROJECT contains invalid path characters: ${project}`);
|
|
}
|
|
if (ws && BAD_SEGMENT.test(ws)) {
|
|
throw new Error(`GSD_WORKSTREAM contains invalid path characters: ${ws}`);
|
|
}
|
|
|
|
let base = path.join(cwd, '.planning');
|
|
if (project) base = path.join(base, project);
|
|
if (ws) base = path.join(base, 'workstreams', ws);
|
|
return base;
|
|
}
|
|
|
|
function planningRoot(cwd) {
|
|
return path.join(cwd, '.planning');
|
|
}
|
|
|
|
function planningPaths(cwd, ws) {
|
|
const base = planningDir(cwd, ws);
|
|
return {
|
|
planning: base,
|
|
state: path.join(base, 'STATE.md'),
|
|
roadmap: path.join(base, 'ROADMAP.md'),
|
|
project: path.join(base, 'PROJECT.md'),
|
|
config: path.join(base, 'config.json'),
|
|
phases: path.join(base, 'phases'),
|
|
requirements: path.join(base, 'REQUIREMENTS.md'),
|
|
};
|
|
}
|
|
|
|
/**
|
|
* @param {string} cwd
|
|
* @param {function} fn - callback to run while holding the lock
|
|
* @param {{ now(): number, sleep(ms: number): void }} [clock]
|
|
* Optional clock seam for testing. Defaults to realClock (Date.now + Atomics.wait).
|
|
* Pass a fake clock from tests/helpers/clock.cjs to drive timeout/stale logic
|
|
* without real wall-clock waits.
|
|
*/
|
|
function withPlanningLock(cwd, fn, clock) {
|
|
if (clock === undefined) clock = realClock;
|
|
const lockPath = path.join(planningDir(cwd), '.lock');
|
|
const lockTimeout = 10000; // 10 seconds
|
|
const start = clock.now();
|
|
|
|
// Ensure .planning/ exists
|
|
try { platformEnsureDir(planningDir(cwd)); } catch { /* ok */ }
|
|
|
|
function runWithHeldLock() {
|
|
// Atomic create — fails if file exists
|
|
fs.writeFileSync(lockPath, JSON.stringify({
|
|
pid: process.pid,
|
|
cwd,
|
|
acquired: new Date().toISOString(),
|
|
}), { flag: 'wx' });
|
|
|
|
_heldPlanningLocks.add(lockPath);
|
|
|
|
// Lock acquired — run the function
|
|
try {
|
|
return fn();
|
|
} finally {
|
|
_heldPlanningLocks.delete(lockPath);
|
|
try { fs.unlinkSync(lockPath); } catch { /* already released */ }
|
|
}
|
|
}
|
|
|
|
while (clock.now() - start < lockTimeout) {
|
|
try {
|
|
return runWithHeldLock();
|
|
} catch (err) {
|
|
// Transient filesystem errors (Docker overlay-fs, NFS, OS signals, AV scanners)
|
|
// are recoverable — wait and retry rather than propagating.
|
|
// See PLANNING_LOCK_RETRY_ERRNOS for the full list and rationale.
|
|
if (PLANNING_LOCK_RETRY_ERRNOS.has(err.code)) {
|
|
clock.sleep(100);
|
|
continue;
|
|
}
|
|
if (err.code === 'EEXIST') {
|
|
// Lock exists — check if stale (>30s old)
|
|
try {
|
|
const stat = fs.statSync(lockPath);
|
|
if (clock.now() - stat.mtimeMs > 30000) {
|
|
fs.unlinkSync(lockPath);
|
|
continue; // retry
|
|
}
|
|
} catch { continue; }
|
|
|
|
// Wait and retry (cross-platform, no shell dependency)
|
|
clock.sleep(100);
|
|
continue;
|
|
}
|
|
throw err;
|
|
}
|
|
}
|
|
|
|
// Timeout — stale-lock recovery, then re-acquire atomically before entering critical section.
|
|
try { fs.unlinkSync(lockPath); } catch { /* ok */ }
|
|
return runWithHeldLock();
|
|
}
|
|
|
|
function createPlanningWorkspace(cwd, opts = {}) {
|
|
return {
|
|
paths: {
|
|
dir(ws, project) {
|
|
return planningDir(cwd, ws, project);
|
|
},
|
|
root() {
|
|
return planningRoot(cwd);
|
|
},
|
|
all(ws) {
|
|
return planningPaths(cwd, ws);
|
|
},
|
|
},
|
|
activeWorkstream: {
|
|
get() {
|
|
return getStoredActiveWorkstream(cwd, opts);
|
|
},
|
|
set(name) {
|
|
setStoredActiveWorkstream(cwd, name, opts);
|
|
},
|
|
clear() {
|
|
clearStoredActiveWorkstream(cwd, opts);
|
|
},
|
|
},
|
|
};
|
|
}
|
|
|
|
function getActiveWorkstream(cwd) {
|
|
return getStoredActiveWorkstream(cwd);
|
|
}
|
|
|
|
function setActiveWorkstream(cwd, name) {
|
|
setStoredActiveWorkstream(cwd, name);
|
|
}
|
|
|
|
/**
|
|
* Locate the CONTEXT.md file in a phase directory, handling both the bare
|
|
* form (`CONTEXT.md`) and the padded-prefix convention (`NN-CONTEXT.md`,
|
|
* `NN.N-CONTEXT.md`, etc.) used by gsd-discuss-phase output.
|
|
*
|
|
* Returns the filename (not the full path) of the first match, or null if
|
|
* no CONTEXT.md exists in the directory.
|
|
*
|
|
* Canonical dual-form predicate extracted here to eliminate the 5-site
|
|
* duplication that previously existed across init.cjs, roadmap.cjs,
|
|
* core.cjs, gap-checker.cjs (#3739).
|
|
*
|
|
* @param {string|string[]} absDirOrFiles - Absolute path to the phase directory,
|
|
* OR an already-read files array (avoids a redundant readdirSync at call sites
|
|
* that already hold a directory listing).
|
|
* @returns {string|null}
|
|
*/
|
|
function findContextMdIn(absDirOrFiles) {
|
|
try {
|
|
const files = Array.isArray(absDirOrFiles)
|
|
? absDirOrFiles
|
|
: fs.readdirSync(absDirOrFiles);
|
|
if (files.includes('CONTEXT.md')) return 'CONTEXT.md';
|
|
return files.find(f => f.endsWith('-CONTEXT.md')) ?? null;
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
module.exports = {
|
|
createPlanningWorkspace,
|
|
createSharedPointerAdapter,
|
|
createSessionScopedPointerAdapter,
|
|
createMemoryPointerAdapter,
|
|
planningDir,
|
|
planningRoot,
|
|
planningPaths,
|
|
withPlanningLock,
|
|
getActiveWorkstream,
|
|
setActiveWorkstream,
|
|
findContextMdIn,
|
|
};
|