* test(3579): failing-first coverage for repo-marker inheritance A session that carries an identity but has never run 'workstream use' reads an absent session pointer, resolves null, and composes the flat .planning tree even when .planning/active-workstream names a live workstream. These tests fail on that and pin the invariants the fix must not break: a session with its own pointer is never repointed, and a session that merely lacked a pointer must never clear the shared marker on another session's behalf. * fix(3579): a pointer-less session inherits the repo active-workstream marker RED proven at 157cae26: the three inheritance tests failed while every isolation and negative control passed on base — the gap, and nothing else. pickActiveWorkstreamAdapter returned exactly ONE adapter: the session-scoped one whenever a session key existed, so the shared .planning/active-workstream marker was never consulted. getWorkstreamSessionKey resolves a key from ~13 env vars or the controlling TTY, so on any normal interactive terminal a key almost always exists — which is why a session that had never run 'workstream use' read an absent pointer, resolved null, and composed the FLAT planning tree even though the repo marker named a live workstream. Reads misreported; writes corrupted the superseded flat STATE. Silent, because the stale tree is well-formed. This was a genuine design fork, not an oversight: references/workstream-flag.md documented step 4 as a fallback 'when no session key exists', and the session isolation that buys is deliberate (#2850). The issue's Agent Brief left the choice open and said the reference doc should match whatever semantics ship. The maintainer ruled in chat for inheritance. Resolution now walks an ORDERED chain — session adapter first, shared second — and only a null from the session adapter falls through to the marker. Strictly additive: it can only turn a null into a name, never change a name that already resolves. The dangerous part is clear() ownership. resolveFromChain treats chain[0] as owned: only it is ever cleared, and only under selfHeal (getActiveWorkstream, never peek). An INHERITED marker is read-only — a stale value there resolves null and the file is left alone. Without that, one pointer-less session's read would delete the repo marker for every other session, which is a worse bug than the one being fixed. Covered by a test that asserts the marker still exists on disk after such a read. peekActiveWorkstream inherits but still mutates nothing (#2850 — the statusline draws on every render). references/workstream-flag.md's Resolution Priority is rewritten to match, keeping the session-isolation rationale and noting that inheritance does not weaken it: a session that owns a pointer is never repointed. Fixes #3579 * fix(3579): correct the guard diagnostics and lock the clear-semantics Three review passes; every finding fixed inline. MISSING ACCEPTANCE CRITERION (spec pass). The brief requires refusal diagnostics that distinguish 'marker present but the session lookup missed it' from 'no workstream set at all', and the two workstream-mode fail-safe guards were byte-for-byte untouched — still emitting a generic 'no active workstream is set' even when a marker exists and merely names a missing directory. Both guards (cmdPhaseComplete, cmdInitProgress) now branch on a new read-only diagnoseUnresolvedActiveWorkstream, which reuses the SAME resolvesToExistingWorkstream predicate resolveFromChain uses, so the diagnosis and the resolution cannot disagree. Two typed reasons added to ERROR_REASON; both arms still refuse — the fail-closed behavior is unchanged, only the message is now true. REAL TEST FAILURE, not a flake. The remote run failed 'clearing one session does not clear another session pointer'. That describe uses before() rather than beforeEach, so one tmpDir is shared and an earlier test writes active-workstream=beta into it; under inheritance the just-cleared session picks that marker up and resolves beta instead of null. The failure is a CORRECT consequence of Option A surfaced through an order-dependent fixture. The test now establishes its own marker state explicitly — its real intent (clearing A must not disturb B's pointer) is preserved and not weakened — and a new test pins the semantic deliberately: clearing a session pointer returns that session to INHERITING the marker, it does not force flat mode. Documented in references/workstream-flag.md, including how to actually get flat behavior. Also from review: partial activeWorkstreamAdapters injection no longer silently synthesizes a REAL filesystem adapter for the missing half (a latent test-isolation trap); the duplicated validate-then-existsSync logic is factored into one predicate; and the two try/finally test bodies are converted to t.after per CONTRIBUTING. New coverage: whitespace/empty shared marker; a session whose OWN pointer is stale while the marker names a different valid workstream (must self-heal to null, never inherit — the isolation guarantee at its sharpest); and both new diagnostic arms asserted on structured --json-errors output rather than prose. * fix(3579): read resolvability with the non-mutating peek, not the self-healing resolver Three of our own new tests failed on 7f5e706a. All three had ONE root cause, and none was fixed by relaxing an assertion. gsd-tools.cjs's bootstrap called the MUTATING getActiveWorkstream unconditionally on every invocation, purely to populate routing env. On an unresolvable pointer that self-healed — cleared it — BEFORE the dispatched command ran its own resolution. A second read in the same process then observed already-cleared state: - Isolation violation: a session whose own pointer was stale had it cleared by the bootstrap, so cmdWorkstreamGet's own resolution found a pointer-LESS session and inherited the shared marker ('beta' instead of null). Exactly the guarantee #2850 exists to protect, defeated across two calls rather than within one. - Guard diagnostics: the guards' own truthiness check also used the mutating resolver, so it cleared the invalid marker and the immediately-following read-only diagnosis found nothing and reported none_active instead of marker_unresolved. So a single invocation's answer depended on how many times it resolved. The bootstrap self-heal is PRE-EXISTING and was harmless while pointer-less meant flat — inheritance is what made it answer-changing, so this fix belongs here. Every call site that only CHECKS resolvability — the bootstrap, both fail-safe guards' truthiness check, and two informational init report fields — now uses the non-mutating peekActiveWorkstream. Self-heal is unchanged in active-workstream-store and still fires exactly once, at whichever site actually consumes the workstream. Verified by driving the real CLI against temp fixtures, since the suite cannot run locally: stale-own-pointer resolves null with the marker intact; both guard arms report marker_unresolved with missing_workstream_dir / invalid_name and the marker survives; no-marker still reports none_active; identity-less self-heal still deletes an invalid marker byte-identically to pre-#3579; and a session with a valid own pointer still wins. * chore(3579): backfill changeset PR number (#3616) * test(3579): kill the surviving mutants in the new resolution code CI's Stryker gate failed: active-workstream-store scored 79.45% against a break threshold of 80 — 259 killed, 67 survived, at 'Ran 1.00 tests per mutant on average'. The survivors cluster in the code this PR added (pickActiveWorkstreamAdapterChain, resolvesToExistingWorkstream, resolveFromChain, diagnoseUnresolvedActiveWorkstream): the CLI-level tests exercise those paths but do not DISCRIMINATE their branches, which is precisely what a surviving mutant means. Raised by strengthening assertions, never by touching the threshold. 21 unit tests added to the existing unit suite, each written to fail under a specific named mutant, using the module's injected adapter seams and createMemoryPointerAdapter so they stay hermetic under Stryker's per-mutant reruns: - chain shape with and without a session key, asserting length AND element identity (kills the if(false), the ': []' array mutant, and the block removal) - partial adapter injection, asserting the missing half is an inert memory adapter that never touches the filesystem (kills the three '??' -> '&&' mutants) - both arms of '!name || !validateWorkstreamName(name)' as SEPARATE tests — an absent name and a non-empty invalid one — which is what kills the '||' -> '&&' mutant - self-heal discrimination: getActiveWorkstream must clear an unresolvable owned pointer and peekActiveWorkstream must not, asserted on adapter state after each (kills if(selfHeal) -> if(true)) - fallback arm both ways: a fallback that resolves and one that does not - diagnoseUnresolvedActiveWorkstream asserted as a full object per case, with the reason strings compared exactly (kills present:true -> false and both StringLiteral mutants) One mutant is deliberately left: 'if (chain.length === 0)' -> 'if (false)'. The branch is structurally unreachable — the only chain source always returns a 1- or 2-element array literal — and resolveFromChain is not exported. Killing it would mean exporting an internal or deleting a defensive guard; neither is worth doing for a mutant, and the score clears 80 without it. Recorded here rather than left unexplained. Every new assertion was evaluated against the built module with real fixtures before committing, since the suite cannot run locally. --------- Co-authored-by: sim <sim@local>
504 lines
22 KiB
TypeScript
504 lines
22 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, retryRenameSync } 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,
|
|
peekActiveWorkstream: peekStoredActiveWorkstream,
|
|
setActiveWorkstream: setStoredActiveWorkstream,
|
|
clearActiveWorkstream: clearStoredActiveWorkstream,
|
|
diagnoseUnresolvedActiveWorkstream: diagnoseUnresolvedStoredActiveWorkstream,
|
|
} = 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);
|
|
}
|
|
|
|
// Test seam (PR #1532 review): beforeSteal fires AFTER the steal decision but BEFORE
|
|
// the identity re-confirm + atomic rename-steal, so a test can recreate a fresh lock
|
|
// in the decision→steal gap and prove the identity re-confirm aborts a double-steal.
|
|
// Defaults to a no-op; real callers are byte-for-behaviour unchanged.
|
|
interface PlanningLockTestHooks {
|
|
beforeSteal?: (ctx: { lockPath: string }) => void;
|
|
}
|
|
const _planningLockTestHooks: PlanningLockTestHooks = {};
|
|
|
|
// Monotonic sequence for unique stale-steal rename targets (no crypto dependency).
|
|
let _planningStealSeq = 0;
|
|
|
|
/**
|
|
* 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');
|
|
}
|
|
|
|
// Sorted list of workstream directory names under `<root>/.planning/workstreams`,
|
|
// or `[]` when the project is flat (no workstreams dir). Single source of truth
|
|
// for the "workstream mode" detection shared by the #1912/#2028 fail-safe guards
|
|
// (init.progress, phase.complete) so the two paths cannot drift.
|
|
function listAvailableWorkstreams(cwd: string): string[] {
|
|
try {
|
|
return fs
|
|
.readdirSync(path.join(planningRoot(cwd), 'workstreams'), { withFileTypes: true })
|
|
.filter((e) => e.isDirectory())
|
|
.map((e) => e.name)
|
|
.sort();
|
|
} catch {
|
|
return [];
|
|
}
|
|
}
|
|
|
|
interface PlanningPaths {
|
|
planning: string;
|
|
state: string;
|
|
roadmap: string;
|
|
project: string;
|
|
config: string;
|
|
phases: string;
|
|
requirements: string;
|
|
debug: string;
|
|
quick: string;
|
|
}
|
|
|
|
// #2142: the quick-task directory. Exported as its own function (not only as a
|
|
// `planningPaths` key) because `audit.cts`'s `scanQuickTasks` receives an
|
|
// already-resolved planning base rather than a `cwd`, so it cannot reach
|
|
// `planningPaths`. Without this shared helper, adding the `quick` key would
|
|
// leave TWO composers of `<planning>/quick` — the DEFECT.GENERATIVE-FIX shape
|
|
// the `debug` key (#3149) was introduced to eliminate.
|
|
function quickDirFrom(planningBase: string): string {
|
|
return path.join(planningBase, 'quick');
|
|
}
|
|
|
|
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'),
|
|
// #3149: the debug-session directory. Single source for both `state.load`'s
|
|
// `debug_dir` field and `init.debug`'s — previously each composed its own
|
|
// `path.join(planning, 'debug')` (DEFECT.GENERATIVE-FIX).
|
|
debug: path.join(base, 'debug'),
|
|
// #2142: quick-task directory, composed via the shared quickDirFrom helper.
|
|
quick: quickDirFrom(base),
|
|
};
|
|
}
|
|
|
|
/**
|
|
* @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
|
|
// Deadman ceiling (audit M1 / R4-FIX) — set ABOVE lockTimeout so a holder that reads
|
|
// as alive but is actually a pid-reuse alias (the .lock body has no startTime, so
|
|
// liveness alone cannot detect reuse) is still recovered once its lock ages past this
|
|
// absolute ceiling. Without it, a false-alive holder would make withPlanningLock throw
|
|
// on every call with no self-heal. Mirrors acquireStateLock's deadmanCeilingMs.
|
|
const deadmanCeilingMs = 60000;
|
|
const start = clock.now();
|
|
|
|
// Ensure .planning/ exists. A genuine failure here (EACCES/ENOSPC/EROFS/EMFILE)
|
|
// MUST surface immediately: the prior `catch { /* ok */ }` swallowed it, the lock
|
|
// write below then failed with ENOENT (parent dir missing), and ENOENT is retryable
|
|
// (PLANNING_LOCK_RETRY_ERRNOS — added for a Docker overlay-fs race), so the loop
|
|
// spun the full 10s budget and reported a PHANTOM "held by a live process"
|
|
// contention pointing at a nonexistent holder (epic #1879 / F16, #1884).
|
|
// `mkdirSync(recursive:true)` does not throw on an existing dir, so the normal
|
|
// path (dir already present) is unaffected; only real creation failures propagate.
|
|
platformEnsureDir(planningDir(cwd));
|
|
|
|
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.
|
|
// The steal is an ATOMIC rename-then-recreate guarded by an identity re-confirm
|
|
// so a racer that recreates a fresh lock in the decision→steal gap never has
|
|
// its replacement deleted (audit M2 / PR #1532 review, window b). The body is
|
|
// written atomically (writeFileSync …{flag:'wx'}) so there is no empty-body
|
|
// create window here — only the double-steal needs hardening.
|
|
try {
|
|
const decisionStat = fs.statSync(lockPath);
|
|
// Snapshot the decision-time body too: (dev, ino) alone is defeated by inode
|
|
// REUSE (a racer's unlink+recreate can land on the same inode), so the body
|
|
// content binds the identity as well — mirrors capability-lock.cts's (dev,
|
|
// ino, ts) re-confirm.
|
|
let decisionBody: string | null;
|
|
try { decisionBody = fs.readFileSync(lockPath, 'utf-8'); } catch { decisionBody = null; }
|
|
let stealable = !_planningHolderVerifiedLive(lockPath);
|
|
if (!stealable) {
|
|
// Verified-live, but recover anyway once the lock crosses the absolute
|
|
// deadman ceiling — defeats a pid-reuse false-alive that would otherwise
|
|
// block forever (R4-FIX; mtime age is from lock creation, not this call).
|
|
const age = clock.now() - decisionStat.mtimeMs;
|
|
stealable = age > deadmanCeilingMs;
|
|
}
|
|
if (stealable) {
|
|
if (_planningLockTestHooks.beforeSteal) _planningLockTestHooks.beforeSteal({ lockPath });
|
|
// Identity re-confirm immediately before the steal: a racer that stole +
|
|
// recreated a fresh lock in the decision→steal gap changes (dev, ino) → do
|
|
// NOT delete the replacement; back off and re-evaluate.
|
|
let confirmStat: fs.Stats;
|
|
try {
|
|
confirmStat = fs.statSync(lockPath);
|
|
} catch {
|
|
continue; // vanished between decision and steal — retry the create.
|
|
}
|
|
let confirmBody: string | null;
|
|
try { confirmBody = fs.readFileSync(lockPath, 'utf-8'); } catch { confirmBody = null; }
|
|
const sameInstance =
|
|
typeof decisionStat.dev === 'number' && typeof decisionStat.ino === 'number' &&
|
|
confirmStat.dev === decisionStat.dev && confirmStat.ino === decisionStat.ino &&
|
|
decisionBody !== null && confirmBody === decisionBody;
|
|
if (!sameInstance) {
|
|
clock.sleep(100); // a racer won the steal + recreated — re-evaluate, don't delete it.
|
|
continue;
|
|
}
|
|
// Atomic steal: rename the inode aside, then remove it. Only ONE racer can
|
|
// win the rename; a failed rename means another process already stole it, so
|
|
// we must NOT fall through to a delete — back off and retry the create.
|
|
const stolen = lockPath + '.stale-' + process.pid + '-' + clock.now() + '-' + (_planningStealSeq++);
|
|
let renamed = false;
|
|
try { retryRenameSync(lockPath, stolen); renamed = true; } catch { /* another racer won */ }
|
|
if (renamed) {
|
|
try { fs.rmSync(stolen, { force: true }); } catch { /* best-effort */ }
|
|
continue; // dead/garbage/expired holder freed — retry immediately to grab it.
|
|
}
|
|
clock.sleep(100); // lost the steal race — back off and retry.
|
|
continue;
|
|
}
|
|
} 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);
|
|
}
|
|
|
|
// #3579 root-cause fix: read-only sibling of getActiveWorkstream, thin
|
|
// pass-through to active-workstream-store's non-mutating peek. Callers that
|
|
// only need to KNOW whether a workstream resolves (a guard's initial check,
|
|
// a bootstrap that will re-derive the answer anyway) must use this instead
|
|
// of getActiveWorkstream — the mutating variant self-heals (clears) a
|
|
// present-but-unresolvable chain[0] value, and a later read in the SAME
|
|
// process (another getActiveWorkstream call, or diagnoseUnresolvedActiveWorkstream)
|
|
// would then observe already-cleared state instead of the original evidence,
|
|
// silently changing the answer or losing the diagnostic reason. Self-heal
|
|
// still happens — exactly once, wherever the real consuming call site invokes
|
|
// getActiveWorkstream — this sibling just avoids triggering it prematurely.
|
|
function peekActiveWorkstream(cwd: string): string | null {
|
|
return peekStoredActiveWorkstream(cwd);
|
|
}
|
|
|
|
function setActiveWorkstream(cwd: string, name: string): void {
|
|
setStoredActiveWorkstream(cwd, name);
|
|
}
|
|
|
|
// #3579 item 1: read-only diagnostic sibling of getActiveWorkstream, thin
|
|
// pass-through to active-workstream-store's chain walk. Lets the #1912/#2028
|
|
// fail-safe guards (init.progress, phase.complete) distinguish "no marker at
|
|
// all" from "a marker exists but didn't resolve" without duplicating the
|
|
// resolution predicate.
|
|
function diagnoseUnresolvedActiveWorkstream(cwd: string): {
|
|
present: boolean;
|
|
value: string | null;
|
|
reason: 'invalid_name' | 'missing_workstream_dir' | null;
|
|
} {
|
|
return diagnoseUnresolvedStoredActiveWorkstream(cwd);
|
|
}
|
|
|
|
// #3579 item 1: human-readable clause for diagnoseUnresolvedActiveWorkstream's
|
|
// `reason`, shared by the init.progress and phase.complete fail-safe guards so
|
|
// the two error messages describe the same failure the same way instead of
|
|
// drifting (CLAUDE.md's Generative Fix Divergence anti-pattern).
|
|
function describeUnresolvedWorkstreamReason(reason: 'invalid_name' | 'missing_workstream_dir' | null): string {
|
|
if (reason === 'invalid_name') return 'the name is not a valid workstream name';
|
|
return "its workstream directory doesn't exist (it may have been renamed or removed)";
|
|
}
|
|
|
|
/**
|
|
* 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 (err) {
|
|
// #1883: distinguish genuine absence from a permission/I-O failure. ENOENT
|
|
// ("nothing there") keeps the long-standing null contract the callers rely
|
|
// on; every other error (EACCES, EIO, …) is a real read failure that must
|
|
// propagate — otherwise an unreadable phase dir is silently reported as
|
|
// "no CONTEXT.md" and the discuss/plan gates wrongly skip context.
|
|
if ((err as NodeJS.ErrnoException).code === 'ENOENT') return null;
|
|
throw err;
|
|
}
|
|
}
|
|
|
|
export = {
|
|
createPlanningWorkspace,
|
|
createSharedPointerAdapter,
|
|
createSessionScopedPointerAdapter,
|
|
createMemoryPointerAdapter,
|
|
planningDir,
|
|
planningRoot,
|
|
listAvailableWorkstreams,
|
|
planningPaths,
|
|
quickDirFrom,
|
|
withPlanningLock,
|
|
getActiveWorkstream,
|
|
peekActiveWorkstream,
|
|
setActiveWorkstream,
|
|
diagnoseUnresolvedActiveWorkstream,
|
|
describeUnresolvedWorkstreamReason,
|
|
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;
|
|
},
|
|
// Test seam (PR #1532 review): script the steal decision→steal gap (window b).
|
|
_setPlanningLockTestHooks(hooks: PlanningLockTestHooks): void {
|
|
if ('beforeSteal' in hooks) _planningLockTestHooks.beforeSteal = hooks.beforeSteal;
|
|
},
|
|
_resetPlanningLockTestHooks(): void {
|
|
delete _planningLockTestHooks.beforeSteal;
|
|
},
|
|
};
|