* test(#4257): W002 harvest precision + workstream-scoped warning regression rows Tests-only RED commit: A-rows pin the command-mention/code-span harvest precision on statePhaseTokens, B-rows drive W002 under root and workstream scope (scope clause asserted, root grammar byte-identical), C-rows pin the additive workstream snapshot field. All fail on next; fix follows. * fix(#4257): harvest only prose phase references; W002 names its workstream scope Sub-defect (a): the statePhaseTokens harvest was the verbatim #3309 relocation of verify.cts's unanchored, markdown-blind scan ([Pp]hase\s+(TOKEN) over the raw file), so GSD's own command names (/gsd-execute-phase 5, bare or quoted) and any token inside a code span/fenced block were harvested as phase references and fired W002 on ledger rows. Now strips fenced blocks then inline spans via the canonical markdown-sectionizer seam (#2365 composition order) and matches with a left word boundary (?<![-\w]) so hyphen- or word-suffixed carriers are mentions, not references. Pinned tradeoff: a genuine reference written in backticks stops counting (a quoted literal is not a reference). Sub-defect (b): the valid set is workstream-scoped by construction (planningPaths under GSD_WORKSTREAM; per-workstream numbering is deliberate), but the message claimed 'only phases 1, 2 are declared' unqualified. New additive PlanningSnapshot.workstream field, sourced from planning-workspace's new resolveEnvWorkstream() — the ONE env discriminator planningDir itself applies — so the clause cannot disagree with the base the reads used. Root scope keeps the byte-identical message; the checker's scope is unchanged. * test(#4257): close the B2 quoted-literal code span (fixture typo) The B2 fixture wrote a single opening backtick — an unterminated span is literal text per CommonMark, so its content is prose and W002 correctly fired on it. The test's name, the A3 snapshot-level twin, and the B2 matrix row all intend a closed span; pre-fix this was indistinguishable because the unanchored harvest fired either way. * chore(#4257): changeset fragment (pr number to backfill) * chore(#4257): backfill PR number in changeset --------- Co-authored-by: sim <sim@local>
717 lines
33 KiB
TypeScript
717 lines
33 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 planningScopeMod = require('./planning-scope.cjs');
|
|
const { SCOPE } = planningScopeMod;
|
|
type Scope = planningScopeMod.Scope;
|
|
// 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>;
|
|
|
|
/**
|
|
* #4257: the ONE owner of the env workstream discriminator `planningDir`
|
|
* itself applies when handed no `ws` argument. `planningPaths(cwd)` — and
|
|
* therefore every workstream-scoped `PlanningSnapshot` read — resolves its
|
|
* base through exactly this read, and the CLI bootstrap has already folded
|
|
* the stored active-workstream pointer into the env by the time any
|
|
* diagnostic runs (`resolveActiveWorkstream` → `applyResolvedWorkstreamEnv`,
|
|
* `active-workstream-store.cjs`). Exposed so a consumer that needs to NAME
|
|
* the scope those reads used (W002's warning message, via the snapshot's
|
|
* `workstream` field) derives it from the same resolution point instead of
|
|
* growing a second env read site that can drift (the #612 PR-2
|
|
* two-readers-two-bases lesson).
|
|
*/
|
|
function resolveEnvWorkstream(): string | null {
|
|
return process.env['GSD_WORKSTREAM'] ?? null;
|
|
}
|
|
|
|
function planningDir(cwd: string, ws?: string | null, project?: string | null): string {
|
|
if (project === undefined) project = process.env['GSD_PROJECT'] ?? null;
|
|
if (ws === undefined) ws = resolveEnvWorkstream();
|
|
|
|
// 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');
|
|
}
|
|
|
|
/**
|
|
* #3972: the ONE owner of "is this planning scope opted out of worktrees?" —
|
|
* the effective `workflow.use_worktrees === false` read every
|
|
* isolation-deciding surface must share (config-get's merged view is the
|
|
* contract). Ladder: the scoped config's OWN key wins (planningDir is
|
|
* project- and workstream-aware); otherwise the flat root's key, but only
|
|
* under the GSD_WORKSTREAM env gate — config-get deliberately does NOT
|
|
* inherit root under GSD_PROJECT alone, and this read must not diverge
|
|
* (#3963). Strict `=== false` (never coerced); any read failure degrades to
|
|
* "not opted out" (worktrees on — the fail-safe direction: the guard keeps
|
|
* enforcing). Direct file reads only — never loadConfig, which normalizes
|
|
* and rewrites config on paths that back sentinel writes.
|
|
*/
|
|
function worktreesOptedOut(cwd: string): boolean {
|
|
// #3972 review: the WHOLE body is guarded — planningDir/planningRoot
|
|
// themselves throw on a GSD_PROJECT/GSD_WORKSTREAM value containing path
|
|
// separators or `..`, and this contract ("any failure degrades to not
|
|
// opted out — worktrees on, keep enforcing") must hold for that shape too.
|
|
try {
|
|
return worktreesOptedOutUnguarded(cwd);
|
|
} catch {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
function worktreesOptedOutUnguarded(cwd: string): boolean {
|
|
type MaybeConfig = { workflow?: unknown } | null;
|
|
const readCfg = (p: string): MaybeConfig => {
|
|
try {
|
|
return JSON.parse(String(fs.readFileSync(p, 'utf8'))) as MaybeConfig;
|
|
} catch {
|
|
return null;
|
|
}
|
|
};
|
|
const ownKey = (cfg: MaybeConfig): { present: boolean; value: unknown } => {
|
|
if (cfg === null || typeof cfg !== 'object') return { present: false, value: undefined };
|
|
const wf = cfg.workflow;
|
|
if (wf === null || typeof wf !== 'object' || Array.isArray(wf)) return { present: false, value: undefined };
|
|
const wfRec = wf as Record<string, unknown>;
|
|
return Object.prototype.hasOwnProperty.call(wfRec, 'use_worktrees')
|
|
? { present: true, value: wfRec['use_worktrees'] }
|
|
: { present: false, value: undefined };
|
|
};
|
|
const scoped = ownKey(readCfg(path.join(planningDir(cwd), 'config.json')));
|
|
if (scoped.present) return scoped.value === false;
|
|
if (process.env['GSD_WORKSTREAM']) {
|
|
const root = ownKey(readCfg(path.join(planningRoot(cwd), 'config.json')));
|
|
if (root.present) return root.value === false;
|
|
}
|
|
return false;
|
|
}
|
|
|
|
/**
|
|
* #612: resolve `phase_id_convention` with the SAME workstream->root federation
|
|
* config-loader uses (config-loader.cts:618/:649) — the workstream config wins,
|
|
* the root config is the fallback.
|
|
*
|
|
* Why this exists rather than `loadConfig(cwd)['phase_id_convention']`: as of
|
|
* #2997 (aa7697fe, in `next`), loadConfig surfaces `phase_id_convention` in
|
|
* its resolved `_baseConfig` — the "loadConfig drops keys it does not know"
|
|
* rationale this comment used to give is stale. The surviving reasons for the
|
|
* direct read are (1) the workstream->root federation below, a standalone
|
|
* resolution this function needs to run against a GIVEN cwd rather than
|
|
* whatever base a `loadConfig(cwd)` call elsewhere would federate from, and
|
|
* (2) convention-ENUM validation, which is still #612 PR-4 work — this
|
|
* function returns the raw string unvalidated, same as the now-surfaced
|
|
* resolved key would. #2997 surfacing the key makes consuming it from
|
|
* resolved config (instead of re-reading config.json here) a natural PR-4
|
|
* consolidation, not this PR's scope. Cycles were never the obstacle.
|
|
*
|
|
* Why federation matters here specifically: the phase-id readers were splitting
|
|
* on this value from two different bases — one resolving from the workstream
|
|
* directory, one from the root — so a workstream repo got the widened ROADMAP
|
|
* read with the narrow directory read, or the reverse, and reported every phase
|
|
* either missing from disk or malformed on disk. One resolver, one answer.
|
|
*
|
|
* The workstream is `planningDir`'s own `ws` parameter, forwarded, so this
|
|
* shares the canonical resolution (and its GSD_PROJECT/GSD_WORKSTREAM
|
|
* handling). Root is consulted as a fallback only when a workstream is active,
|
|
* matching config-loader; a project-scoped directory stands alone. Returns null
|
|
* when unset, absent, or unreadable — every caller treats null as "not the
|
|
* bracket convention".
|
|
*
|
|
* #2761 B1 (trek-e review): `ws` is a PARAMETER, not read from the environment
|
|
* here. It was omitted at first on the reasoning that "the active workstream is
|
|
* whatever planningDir resolves" — true only for the env-driven caller. A
|
|
* caller that iterates workstreams passes the name as an ARGUMENT (it cannot
|
|
* set `GSD_WORKSTREAM` per iteration), and `planningDir` falls back to the env
|
|
* only when `ws` is `undefined`, so an argument-driven call resolved this
|
|
* convention from the ROOT config while reading that workstream's ROADMAP. Two
|
|
* consequences, both reproduced: a workstream that explicitly declares its OWN
|
|
* convention had it ignored — the root's value decided how the workstream's
|
|
* roadmap was parsed, so flipping ONLY the root config changed which milestone
|
|
* a workstream extracted; and `--workstream foo` disagreed with
|
|
* `GSD_WORKSTREAM=foo` on the same repo.
|
|
*
|
|
* `undefined` (the default) keeps `planningDir`'s env fallback, so every
|
|
* pre-#2761 call site is byte-identical; `null` means "explicitly no
|
|
* workstream". Same discriminator `planningDir` and `getMilestonePhaseFilter`
|
|
* already carry.
|
|
*
|
|
* SCOPE: this governs the #612 bracket-selection reads ONLY. The shipped
|
|
* milestone-prefixed W021 gate keeps its own root-only read — re-basing a
|
|
* legacy convention's gate onto a different config is a behaviour change to a
|
|
* shipped check, in both directions, and is not part of read tolerance.
|
|
*/
|
|
function resolvePhaseIdConvention(cwd: string, ws?: string | null): string | null {
|
|
const readFrom = (dir: string): string | null => {
|
|
const configPath = path.join(dir, 'config.json');
|
|
if (!fs.existsSync(configPath)) return null;
|
|
try {
|
|
const parsed = JSON.parse(fs.readFileSync(configPath, 'utf-8')) as Record<string, unknown>;
|
|
const value = parsed['phase_id_convention'];
|
|
return typeof value === 'string' && value !== '' ? value : null;
|
|
} catch {
|
|
return null;
|
|
}
|
|
};
|
|
const scoped = planningDir(cwd, ws);
|
|
const root = planningRoot(cwd);
|
|
if (scoped === root) return readFrom(root);
|
|
// Root is a fallback only when a WORKSTREAM is active — config-loader falls
|
|
// back to the root config under `if (ws)` and not otherwise, so a
|
|
// project-scoped directory stands alone. Detected by suppressing the
|
|
// workstream segment rather than re-reading the environment.
|
|
const projectOnly = planningDir(cwd, null);
|
|
if (scoped === projectOnly) return readFrom(scoped);
|
|
return readFrom(scoped) ?? readFrom(root);
|
|
}
|
|
|
|
// 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;
|
|
todos: 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');
|
|
}
|
|
|
|
// #4256: the todos directory — deliberately ROOT-SCOPED, unlike every other
|
|
// planningPaths key. Todos are shared project state by construction: the
|
|
// migrateToWorkstreams contract keeps them among the shared files that "stay
|
|
// in place" at .planning/todos/ (workstream.cts), and every workflow writer
|
|
// writes that literal cwd-relative root path. The six todos readers
|
|
// previously hand-composed `path.join(planningDir(cwd), 'todos', ...)`,
|
|
// which silently re-scoped to .planning/workstreams/<ws>/todos/ — a
|
|
// directory nothing creates — under a workstream, so todos went invisible
|
|
// and audit-open passed the milestone-close gate vacuously. Same
|
|
// two-composers-of-one-path shape the `debug` (#3149) and `quick` (#2142)
|
|
// keys were introduced to eliminate (DEFECT.GENERATIVE-FIX).
|
|
//
|
|
// Exported as its own function pair (not only as a `planningPaths` key)
|
|
// because `audit.cts`'s `scanTodos`/`cmdAuditAcknowledge` consume an
|
|
// already-resolved todos base rather than a `cwd`, mirroring how #2142
|
|
// exported `quickDirFrom` for `scanQuickTasks`. `todosDir` takes NO ws/project
|
|
// parameter — todos have no workstream- or project-scoped form anywhere, so
|
|
// there is no discriminator to thread. This is also the single root #4327's
|
|
// future filename-containment guard should enforce against.
|
|
function todosDirFrom(planningBase: string): string {
|
|
return path.join(planningBase, 'todos');
|
|
}
|
|
|
|
function todosDir(cwd: string): string {
|
|
return todosDirFrom(planningRoot(cwd));
|
|
}
|
|
|
|
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),
|
|
// #4256: todos directory — deliberately ROOT-scoped while the rest of
|
|
// this record follows the active workstream/project (todos are shared
|
|
// project state per the migrateToWorkstreams contract), composed via the
|
|
// shared todosDir helper so this key and every direct caller agree.
|
|
todos: todosDir(cwd),
|
|
};
|
|
}
|
|
|
|
/**
|
|
* @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.
|
|
*
|
|
* 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).
|
|
*
|
|
* Two call shapes, two return shapes (#4014, epic #3473 B4-unreadable):
|
|
*
|
|
* - Array-input form (`files: string[]`, an already-read directory listing —
|
|
* avoids a redundant readdirSync at call sites that already hold one, and
|
|
* lets a caller pass an already phase-scoped listing): UNCHANGED —
|
|
* returns the matched filename or `null`, never throws (there is no I/O
|
|
* to fail on an in-memory array).
|
|
* - Directory-string form (`absDir: string`): performs the `readdirSync`
|
|
* itself and returns `{ file, files, scope }` — `file`/`files` are the
|
|
* match and the raw listing, `scope` is `SCOPE.COMPLETE` on a successful
|
|
* read (including ENOENT, which is a genuine "nothing there yet" answer,
|
|
* not a failure) or `SCOPE.UNREADABLE` on any other read error
|
|
* (EACCES/EIO/…). This form never throws — a caller that used to see an
|
|
* exception on an unreadable directory now sees `scope: SCOPE.UNREADABLE`
|
|
* instead, so an unreadable phase dir is reported distinctly from a
|
|
* genuinely empty one rather than being silently indistinguishable from
|
|
* it (#1883's original defect this closes at the source).
|
|
*/
|
|
function findContextMdIn(files: string[]): string | null;
|
|
function findContextMdIn(absDir: string): { file: string | null; files: string[]; scope: Scope };
|
|
function findContextMdIn(
|
|
absDirOrFiles: string | string[],
|
|
): string | null | { file: string | null; files: string[]; scope: Scope } {
|
|
const matchIn = (files: string[]): string | null => {
|
|
if (files.includes('CONTEXT.md')) return 'CONTEXT.md';
|
|
return files.find((f: string) => f.endsWith('-CONTEXT.md')) ?? null;
|
|
};
|
|
|
|
if (Array.isArray(absDirOrFiles)) {
|
|
return matchIn(absDirOrFiles);
|
|
}
|
|
|
|
try {
|
|
const files = fs.readdirSync(absDirOrFiles);
|
|
return { file: matchIn(files), files, scope: SCOPE.COMPLETE };
|
|
} catch (err) {
|
|
// #1883 / #4014: distinguish genuine absence from a permission/I-O
|
|
// failure. ENOENT ("nothing there") keeps the long-standing "real empty"
|
|
// contract callers rely on; every other error (EACCES, EIO, …) is a real
|
|
// read failure — reported as SCOPE.UNREADABLE rather than thrown, so a
|
|
// caller no longer needs its own try/catch to keep an unreadable phase
|
|
// dir from being silently reported the same as "no CONTEXT.md".
|
|
if ((err as NodeJS.ErrnoException).code === 'ENOENT') {
|
|
return { file: null, files: [], scope: SCOPE.COMPLETE };
|
|
}
|
|
return { file: null, files: [], scope: SCOPE.UNREADABLE };
|
|
}
|
|
}
|
|
|
|
export = {
|
|
worktreesOptedOut,
|
|
createPlanningWorkspace,
|
|
createSharedPointerAdapter,
|
|
createSessionScopedPointerAdapter,
|
|
createMemoryPointerAdapter,
|
|
planningDir,
|
|
planningRoot,
|
|
resolveEnvWorkstream,
|
|
resolvePhaseIdConvention,
|
|
listAvailableWorkstreams,
|
|
planningPaths,
|
|
quickDirFrom,
|
|
todosDirFrom,
|
|
todosDir,
|
|
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;
|
|
},
|
|
};
|