The STATE.md write lock (acquireStateLock) and the .planning/ workspace lock (withPlanningLock) stole contended locks on mtime age alone with no process.kill(pid,0) liveness check, and mis-ordered stale-vs-wait so a live-but-slow holder could be robbed mid-critical-section. M1 (lost update / STATE.md corruption): a live writer whose critical section ran past the stale threshold aged out and a waiter unlinked its lock and acquired -> two writers in STATE.md's read-modify-write window. mtime is a leaky proxy for "holder is alive"; it leaks under exactly the slow-holder condition the lock guards against. M2 (uncaught EEXIST): withPlanningLock's timeout fallback unconditionally unlinked whatever lock existed (even a live holder's) and re-acquired OUTSIDE any try -- a concurrent re-create raced a raw EEXIST out of the helper. Fix backports capability-lock.cts's liveness gate (process.kill(pid,0) via a _setLockProbes/_resetLockProbes test seam): - acquireStateLock: steal when holder pid is DEAD (any age) OR age exceeds a deadman ceiling (60000ms, ABOVE maxWaitMs=30000) so a verified-live holder is never stolen within budget; garbage/legacy bodies stay recoverable. - withPlanningLock: same gate in the EEXIST path (dead stolen promptly, live waited on); removed the unconditional force-steal -> clear timeout throw, which also closes M2 (no re-acquire outside try). Uncontended path unchanged byte-for-behaviour; realClock + real process.kill remain the defaults. Pid-reuse residual fails safe (waits/times out, never corrupts) and recovers at the deadman ceiling. Tests: TDD red->green via the clock + new pid-liveness probe seams (no wall-clock; #453 deleted the race tests). 8 new behavioural tests across tests/clock-seam.test.cjs and tests/planning-workspace.test.cjs; the prior withPlanningLock timeout test rewritten to pin the no-force-steal contract. Claude-Session: https://claude.ai/code/session_01R88n7Q54bAaVHFkDbbH1yz
338 lines
12 KiB
TypeScript
338 lines
12 KiB
TypeScript
/**
|
|
* 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.
|
|
*
|
|
* ADR-457 build-at-publish: the hand-written bin/lib/planning-workspace.cjs collapsed
|
|
* to a TypeScript source of truth. Behaviour is preserved byte-for-behaviour from
|
|
* the prior hand-written .cjs; only types are added.
|
|
*/
|
|
|
|
import fs from 'node:fs';
|
|
import path from 'node:path';
|
|
import { platformEnsureDir } from './shell-command-projection.cjs';
|
|
import { realClock } from './clock.cjs';
|
|
import type { Clock } from './clock.cjs';
|
|
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
|
import activeWorkstreamStore = require('./active-workstream-store.cjs');
|
|
const {
|
|
createSharedPointerAdapter,
|
|
createSessionScopedPointerAdapter,
|
|
createMemoryPointerAdapter,
|
|
getActiveWorkstream: getStoredActiveWorkstream,
|
|
setActiveWorkstream: setStoredActiveWorkstream,
|
|
clearActiveWorkstream: clearStoredActiveWorkstream,
|
|
} = activeWorkstreamStore;
|
|
|
|
// Track .planning/.lock files held by this process so they can be removed on exit.
|
|
const _heldPlanningLocks = new Set<string>();
|
|
process.on('exit', () => {
|
|
for (const lockPath of _heldPlanningLocks) {
|
|
try { fs.unlinkSync(lockPath); } catch { /* already gone */ }
|
|
}
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Lock liveness probe (test seam) — audit M1
|
|
//
|
|
// mtime is a leaky proxy for "the holder is alive". The prior withPlanningLock
|
|
// timeout fallback unconditionally unlinked WHATEVER lock existed — even a fresh,
|
|
// live holder's — and re-acquired it, force-stealing a live writer's critical
|
|
// section. We backport capability-lock.cts's pid-liveness gate: a dead holder is
|
|
// stolen promptly inside the polite loop; a live holder is waited on. The
|
|
// indirection lets unit tests inject a deterministic isPidAlive without real pids.
|
|
// ---------------------------------------------------------------------------
|
|
|
|
/** Is `pid` a live process? process.kill(pid, 0) succeeds for a live (signalable) process. */
|
|
function _realIsPidAlive(pid: number): boolean {
|
|
try {
|
|
process.kill(pid, 0);
|
|
return true; // signalable → alive
|
|
} catch (err) {
|
|
// EPERM = process exists but we cannot signal it (still ALIVE). ESRCH = gone.
|
|
return (err as NodeJS.ErrnoException).code === 'EPERM';
|
|
}
|
|
}
|
|
|
|
const _planningLockProbes: { isPidAlive: (pid: number) => boolean } = { isPidAlive: _realIsPidAlive };
|
|
|
|
function _planningLockIsPidAlive(pid: number): boolean {
|
|
return _planningLockProbes.isPidAlive(pid);
|
|
}
|
|
|
|
/**
|
|
* Is the holder recorded in the .lock body VERIFIED-LIVE? The body is JSON
|
|
* { pid, cwd, acquired }. Returns true ONLY when the body parses AND the recorded
|
|
* pid signals alive. A garbage / pid-less / unreadable body (or a dead pid) is NOT
|
|
* verified-live, so the lock stays stealable — corrupt locks never block forever,
|
|
* and a live holder is never force-stolen.
|
|
*/
|
|
function _planningHolderVerifiedLive(lockPath: string): boolean {
|
|
let parsed: unknown;
|
|
try {
|
|
parsed = JSON.parse(fs.readFileSync(lockPath, 'utf-8'));
|
|
} catch {
|
|
return false; // unreadable / unparseable body → cannot verify → not verified-live
|
|
}
|
|
const pid = (parsed as { pid?: unknown } | null)?.pid;
|
|
if (typeof pid !== 'number' || !Number.isInteger(pid) || pid <= 0) return false;
|
|
return _planningLockIsPidAlive(pid);
|
|
}
|
|
|
|
// 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)
|
|
]);
|
|
|
|
// Loose opts type accepted by createPlanningWorkspace — passed through to
|
|
// active-workstream-store get/set/clear which accept { activeWorkstreamAdapter?,
|
|
// activeWorkstreamAdapters?, getStored? }. Using Record<string, unknown> is
|
|
// compatible with the structural type the store expects.
|
|
type WorkstreamAdapterOpts = Record<string, unknown>;
|
|
|
|
function planningDir(cwd: string, ws?: string | null, project?: string | null): string {
|
|
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: string): string {
|
|
return path.join(cwd, '.planning');
|
|
}
|
|
|
|
interface PlanningPaths {
|
|
planning: string;
|
|
state: string;
|
|
roadmap: string;
|
|
project: string;
|
|
config: string;
|
|
phases: string;
|
|
requirements: string;
|
|
}
|
|
|
|
function planningPaths(cwd: string, ws?: string | null): PlanningPaths {
|
|
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 cwd
|
|
* @param fn - callback to run while holding the lock
|
|
* @param 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<T>(cwd: string, fn: () => T, clock?: Clock): T {
|
|
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 acquireLock(): void {
|
|
// Atomic create — fails if file exists
|
|
fs.writeFileSync(lockPath, JSON.stringify({
|
|
pid: process.pid,
|
|
cwd,
|
|
acquired: new Date().toISOString(),
|
|
}), { flag: 'wx' });
|
|
|
|
_heldPlanningLocks.add(lockPath);
|
|
}
|
|
|
|
function runWithHeldLock(): T {
|
|
try {
|
|
return fn();
|
|
} finally {
|
|
_heldPlanningLocks.delete(lockPath);
|
|
try { fs.unlinkSync(lockPath); } catch { /* already released */ }
|
|
}
|
|
}
|
|
|
|
while (clock.now() - start < lockTimeout) {
|
|
let lockWasAcquired = false;
|
|
try {
|
|
acquireLock();
|
|
lockWasAcquired = true;
|
|
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 (lockWasAcquired) throw err;
|
|
const nodeErr = err as NodeJS.ErrnoException;
|
|
if (PLANNING_LOCK_RETRY_ERRNOS.has(nodeErr.code ?? '')) {
|
|
clock.sleep(100);
|
|
continue;
|
|
}
|
|
if (nodeErr.code === 'EEXIST') {
|
|
// Liveness-gated steal (audit M1). Steal the lock PROMPTLY only when its
|
|
// recorded holder is NOT verified-live (crashed/dead pid or garbage body).
|
|
// A verified-live holder is waited on — never force-stolen — because nuking
|
|
// a slow-but-live writer's lock corrupts the .planning/ critical section.
|
|
try {
|
|
if (!_planningHolderVerifiedLive(lockPath)) {
|
|
fs.unlinkSync(lockPath);
|
|
continue; // dead/garbage holder — retry immediately to grab the freed lock
|
|
}
|
|
} catch { continue; }
|
|
|
|
// Live holder — wait and retry (cross-platform, no shell dependency).
|
|
clock.sleep(100);
|
|
continue;
|
|
}
|
|
throw err;
|
|
}
|
|
}
|
|
|
|
// Timeout against a holder still present at budget exhaustion. The polite loop
|
|
// already stole any DEAD holder; reaching here means the holder is verified-live
|
|
// (or a pid-reuse alias we must not corrupt). Do NOT force-steal — the prior
|
|
// unconditional `unlinkSync(lockPath); acquireLock()` here (audit M1) robbed live
|
|
// writers, and its re-acquire sat OUTSIDE any try so a concurrent re-create raced
|
|
// a raw EEXIST out of the helper (audit M2). Surface a clear timeout error instead.
|
|
const timeoutErr = new Error(
|
|
'withPlanningLock: ' + lockPath + ' held by a live process for ' +
|
|
(clock.now() - start) + 'ms (exceeded ' + lockTimeout + 'ms budget)'
|
|
);
|
|
(timeoutErr as unknown as Record<string, unknown>).lockTimeout = true;
|
|
throw timeoutErr;
|
|
}
|
|
|
|
function createPlanningWorkspace(cwd: string, opts: WorkstreamAdapterOpts = {}): {
|
|
paths: {
|
|
dir(ws?: string | null, project?: string | null): string;
|
|
root(): string;
|
|
all(ws?: string | null): PlanningPaths;
|
|
};
|
|
activeWorkstream: {
|
|
get(): string | null;
|
|
set(name: string): void;
|
|
clear(): void;
|
|
};
|
|
} {
|
|
return {
|
|
paths: {
|
|
dir(ws?: string | null, project?: string | null) {
|
|
return planningDir(cwd, ws, project);
|
|
},
|
|
root() {
|
|
return planningRoot(cwd);
|
|
},
|
|
all(ws?: string | null) {
|
|
return planningPaths(cwd, ws);
|
|
},
|
|
},
|
|
activeWorkstream: {
|
|
get() {
|
|
return getStoredActiveWorkstream(cwd, opts);
|
|
},
|
|
set(name: string) {
|
|
setStoredActiveWorkstream(cwd, name, opts);
|
|
},
|
|
clear() {
|
|
clearStoredActiveWorkstream(cwd, opts);
|
|
},
|
|
},
|
|
};
|
|
}
|
|
|
|
function getActiveWorkstream(cwd: string): string | null {
|
|
return getStoredActiveWorkstream(cwd);
|
|
}
|
|
|
|
function setActiveWorkstream(cwd: string, name: string): void {
|
|
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 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).
|
|
*/
|
|
function findContextMdIn(absDirOrFiles: string | string[]): string | null {
|
|
try {
|
|
const files = Array.isArray(absDirOrFiles)
|
|
? absDirOrFiles
|
|
: fs.readdirSync(absDirOrFiles);
|
|
if (files.includes('CONTEXT.md')) return 'CONTEXT.md';
|
|
return files.find((f: string) => f.endsWith('-CONTEXT.md')) ?? null;
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
export = {
|
|
createPlanningWorkspace,
|
|
createSharedPointerAdapter,
|
|
createSessionScopedPointerAdapter,
|
|
createMemoryPointerAdapter,
|
|
planningDir,
|
|
planningRoot,
|
|
planningPaths,
|
|
withPlanningLock,
|
|
getActiveWorkstream,
|
|
setActiveWorkstream,
|
|
findContextMdIn,
|
|
// Test seam (audit M1): inject a deterministic isPidAlive so the liveness-gated
|
|
// steal decision is exercised without real pids. Mirrors capability-lock.cts.
|
|
_setLockProbes(probes: Partial<{ isPidAlive: (pid: number) => boolean }>): void {
|
|
if (typeof probes.isPidAlive === 'function') _planningLockProbes.isPidAlive = probes.isPidAlive;
|
|
},
|
|
_resetLockProbes(): void {
|
|
_planningLockProbes.isPidAlive = _realIsPidAlive;
|
|
},
|
|
};
|