Merge branch 'next' into fix/1514-retired-phase-total-phases
This commit is contained in:
117
src/commands.cts
117
src/commands.cts
@@ -9,6 +9,7 @@
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { execGit, platformWriteSync, platformReadSync, platformEnsureDir } from './shell-command-projection.cjs';
|
||||
import { requireSafePath, sanitizeForDisplay } from './security.cjs';
|
||||
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
||||
import ioMod = require('./io.cjs');
|
||||
const { output, error } = ioMod;
|
||||
@@ -195,6 +196,120 @@ function cmdListTodos(cwd: string, area: string | undefined, raw: boolean): void
|
||||
output(result, raw, count.toString());
|
||||
}
|
||||
|
||||
/**
|
||||
* List captured seeds from .planning/seeds/SEED-*.md for browsing/audit (#441).
|
||||
*
|
||||
* Unlike audit.scanSeeds (which returns only *unimplemented* seeds for the
|
||||
* milestone surface), this lists seeds of every status with the richer fields a
|
||||
* human audit needs (scope, trigger, planted date). An optional case-insensitive
|
||||
* status filter narrows the set. Seed content is user-controlled, so every
|
||||
* displayed field is passed through sanitizeForDisplay and each file path is
|
||||
* validated with requireSafePath before reading. Read-only — never mutates.
|
||||
*/
|
||||
/**
|
||||
* Derive the canonical `{ seed_id, slug }` from a seed filename stem and the
|
||||
* frontmatter `id:` value. Pure (no I/O) so it can be property-tested directly.
|
||||
*
|
||||
* seed_id: frontmatter `id:` when it matches `SEED-NNN`, else the numeric prefix
|
||||
* of the filename (`SEED-NNN-…`), else the whole stem. slug: the descriptive
|
||||
* remainder after `SEED-NNN-`, else the stem with a leading `SEED-` stripped.
|
||||
* `rawFmId` is `unknown` because frontmatter values are not guaranteed strings.
|
||||
*/
|
||||
function deriveSeedIdentity(stem: string, rawFmId: unknown): { seed_id: string; slug: string } {
|
||||
const fmId = typeof rawFmId === 'string' ? rawFmId.trim() : '';
|
||||
let seedId: string;
|
||||
if (/^SEED-\d+$/i.test(fmId)) {
|
||||
seedId = fmId;
|
||||
} else {
|
||||
const numMatch = stem.match(/^(SEED-\d+)/i);
|
||||
seedId = numMatch ? numMatch[1] : stem;
|
||||
}
|
||||
const slugMatch = stem.match(/^SEED-\d+-(.+)$/i);
|
||||
const slug = slugMatch ? slugMatch[1] : stem.replace(/^SEED-/i, '');
|
||||
return { seed_id: seedId, slug };
|
||||
}
|
||||
|
||||
function cmdListSeeds(cwd: string, statusFilter: string | undefined, raw: boolean): void {
|
||||
const planDir = planningDir(cwd);
|
||||
const seedsDir = path.join(planDir, 'seeds');
|
||||
const wantStatus = statusFilter ? statusFilter.trim().toLowerCase() : null;
|
||||
|
||||
const seeds: Array<{
|
||||
seed_id: string; slug: string; status: string; scope: string;
|
||||
trigger_when: string; planted: string; title: string; path: string;
|
||||
}> = [];
|
||||
const summary: Record<string, number> = {};
|
||||
|
||||
// Frontmatter values are not guaranteed to be scalars: extractFrontmatter
|
||||
// yields {} for a bare `key:` line and an array for `key: [a, b]`. Coerce every
|
||||
// read to a string so one malformed seed cannot crash the whole audit list
|
||||
// (`.toLowerCase()` on a non-string throws) or leak a raw object/array into the
|
||||
// JSON contract. Mirrors the existing `typeof fm.id === 'string'` guard below.
|
||||
const fmStr = (v: unknown): string => (typeof v === 'string' ? v : '');
|
||||
|
||||
let files: fs.Dirent[];
|
||||
try {
|
||||
files = fs.readdirSync(seedsDir, { withFileTypes: true });
|
||||
} catch {
|
||||
// No seeds dir (or unreadable) — an empty, non-error result. The seed dir is
|
||||
// created lazily by the first plant-seed, so absence is the normal zero case.
|
||||
output({ count: 0, seeds: [], summary: {} }, raw, '0');
|
||||
return;
|
||||
}
|
||||
|
||||
for (const entry of files) {
|
||||
if (!entry.isFile()) continue;
|
||||
if (!entry.name.startsWith('SEED-') || !entry.name.endsWith('.md')) continue;
|
||||
|
||||
let safeFilePath: string;
|
||||
try {
|
||||
safeFilePath = requireSafePath(path.join(seedsDir, entry.name), planDir, 'seed file', { allowAbsolute: true });
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
const content = platformReadSync(safeFilePath);
|
||||
if (content === null) continue;
|
||||
|
||||
const fm = extractFrontmatter(content) as Record<string, unknown>;
|
||||
const status = (fmStr(fm.status) || 'dormant').toLowerCase().trim() || 'dormant';
|
||||
|
||||
// Match on the raw lowercased status (both sides already normalized);
|
||||
// sanitizeForDisplay is for output, not comparison.
|
||||
if (wantStatus && status !== wantStatus) continue;
|
||||
|
||||
// Canonical seed id is `SEED-NNN` (frontmatter `id:`, e.g. SEED-001). Fall
|
||||
// back to the numeric prefix of the filename, then to the whole stem. The
|
||||
// descriptive remainder of the filename (`SEED-NNN-<slug>.md`) is the slug.
|
||||
const stem = path.basename(entry.name, '.md');
|
||||
const { seed_id: seedId, slug } = deriveSeedIdentity(stem, fm.id);
|
||||
|
||||
let title = sanitizeForDisplay(fmStr(fm.title).slice(0, 100));
|
||||
if (!title) {
|
||||
const headingMatch = content.match(/^#\s*(.+)$/m);
|
||||
if (headingMatch) title = sanitizeForDisplay(headingMatch[1].trim().slice(0, 100));
|
||||
}
|
||||
|
||||
const safeStatus = sanitizeForDisplay(status);
|
||||
summary[safeStatus] = (summary[safeStatus] || 0) + 1;
|
||||
|
||||
seeds.push({
|
||||
seed_id: sanitizeForDisplay(seedId),
|
||||
slug: sanitizeForDisplay(slug),
|
||||
status: safeStatus,
|
||||
scope: sanitizeForDisplay(fmStr(fm.scope) || 'unknown'),
|
||||
trigger_when: sanitizeForDisplay(fmStr(fm.trigger_when)),
|
||||
planted: sanitizeForDisplay(fmStr(fm.planted)),
|
||||
title,
|
||||
path: toPosixPath(path.relative(cwd, safeFilePath)),
|
||||
});
|
||||
}
|
||||
|
||||
// Stable order: by seed_id so output is deterministic across filesystems.
|
||||
seeds.sort((a, b) => a.seed_id.localeCompare(b.seed_id));
|
||||
|
||||
output({ count: seeds.length, seeds, summary }, raw, seeds.length.toString());
|
||||
}
|
||||
|
||||
function cmdVerifyPathExists(cwd: string, targetPath: string | undefined, raw: boolean): void {
|
||||
if (!targetPath) {
|
||||
error('path required for verification');
|
||||
@@ -1578,6 +1693,8 @@ export = {
|
||||
cmdGenerateSlug,
|
||||
cmdCurrentTimestamp,
|
||||
cmdListTodos,
|
||||
cmdListSeeds,
|
||||
deriveSeedIdentity,
|
||||
cmdVerifyPathExists,
|
||||
cmdHistoryDigest,
|
||||
cmdResolveModel,
|
||||
|
||||
@@ -56,10 +56,14 @@ function extractFrontmatter(content: string): Frontmatter {
|
||||
const frontmatter: Frontmatter = {};
|
||||
// Match frontmatter only at byte 0 — a `---` block later in the document
|
||||
// body (YAML examples, horizontal rules) must never be treated as frontmatter.
|
||||
const match = content.match(/^---\r?\n([\s\S]+?)\r?\n---/);
|
||||
if (!match) return frontmatter;
|
||||
const headerEnd = content.startsWith('---\r\n') ? 5 : content.startsWith('---\n') ? 4 : -1;
|
||||
if (headerEnd === -1) return frontmatter;
|
||||
|
||||
const yaml = match[1];
|
||||
const closingLineStart = content.indexOf('\n---', headerEnd);
|
||||
if (closingLineStart === -1) return frontmatter;
|
||||
|
||||
const yamlEnd = content[closingLineStart - 1] === '\r' ? closingLineStart - 1 : closingLineStart;
|
||||
const yaml = content.slice(headerEnd, yamlEnd);
|
||||
const lines = yaml.split(/\r?\n/);
|
||||
|
||||
// Stack to track nested objects: [{obj, key, indent}]
|
||||
|
||||
12
src/init.cts
12
src/init.cts
@@ -2104,10 +2104,14 @@ function cmdAgentSkills(
|
||||
return;
|
||||
}
|
||||
|
||||
if (block) {
|
||||
process.stdout.write(block);
|
||||
}
|
||||
process.exit(0);
|
||||
// #1400: emit the raw block via the synchronous-flush output() helper (the same
|
||||
// one the --json branch uses) rather than process.stdout.write + process.exit(0).
|
||||
// When stdout is a pipe/file (how workflows consume this via command
|
||||
// substitution) the async stdout buffer is torn down by process.exit() before
|
||||
// it drains — on Windows this reliably truncates the write to 0 bytes, so every
|
||||
// ${AGENT_SKILLS_*} substitution expands empty. output() writes every byte with
|
||||
// writeAllSync and returns, letting the event loop drain naturally.
|
||||
output(block || '', true, block || '');
|
||||
}
|
||||
|
||||
interface SkillEntry {
|
||||
|
||||
@@ -548,12 +548,16 @@ function stageSkillsForRuntimeAsSkills(
|
||||
*
|
||||
* @param srcAgentsDir source agents directory (e.g. agents/)
|
||||
* @param resolvedProfile profile filter from resolveProfile()
|
||||
* @param converter (content: string) → string pure per-file converter
|
||||
* @param converter (content: string, isGlobal?: boolean) → string per-file
|
||||
* converter; scope-aware converters (copilot/antigravity)
|
||||
* read isGlobal, single-arg converters ignore it (#1173)
|
||||
* @param isGlobal install scope passed through to the converter
|
||||
*/
|
||||
function stageAgentsForRuntimeWithConverter(
|
||||
srcAgentsDir: string,
|
||||
resolvedProfile: ResolvedProfile,
|
||||
converter: (content: string) => string,
|
||||
converter: (content: string, isGlobal?: boolean) => string,
|
||||
isGlobal = false,
|
||||
): string {
|
||||
if (!fs.existsSync(srcAgentsDir)) return srcAgentsDir;
|
||||
|
||||
@@ -571,7 +575,7 @@ function stageAgentsForRuntimeWithConverter(
|
||||
}
|
||||
}
|
||||
const content = fs.readFileSync(path.join(srcAgentsDir, entry.name), 'utf8');
|
||||
const converted = converter(content);
|
||||
const converted = converter(content, isGlobal);
|
||||
fs.writeFileSync(path.join(stageDir, entry.name), converted, 'utf8');
|
||||
}
|
||||
} catch (err) {
|
||||
|
||||
@@ -37,6 +37,65 @@ process.on('exit', () => {
|
||||
}
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 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
|
||||
@@ -118,6 +177,12 @@ 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
|
||||
@@ -160,16 +225,68 @@ function withPlanningLock<T>(cwd: string, fn: () => T, clock?: Clock): T {
|
||||
continue;
|
||||
}
|
||||
if (nodeErr.code === 'EEXIST') {
|
||||
// Lock exists — check if stale (>30s old)
|
||||
// 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 stat = fs.statSync(lockPath);
|
||||
if (clock.now() - stat.mtimeMs > 30000) {
|
||||
fs.unlinkSync(lockPath);
|
||||
continue; // retry
|
||||
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 { fs.renameSync(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; }
|
||||
|
||||
// Wait and retry (cross-platform, no shell dependency)
|
||||
// Live holder — wait and retry (cross-platform, no shell dependency).
|
||||
clock.sleep(100);
|
||||
continue;
|
||||
}
|
||||
@@ -177,10 +294,18 @@ function withPlanningLock<T>(cwd: string, fn: () => T, clock?: Clock): T {
|
||||
}
|
||||
}
|
||||
|
||||
// Timeout — stale-lock recovery, then re-acquire atomically before entering critical section.
|
||||
try { fs.unlinkSync(lockPath); } catch { /* ok */ }
|
||||
acquireLock();
|
||||
return runWithHeldLock();
|
||||
// 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 = {}): {
|
||||
@@ -269,4 +394,19 @@ export = {
|
||||
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;
|
||||
},
|
||||
// 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;
|
||||
},
|
||||
};
|
||||
|
||||
@@ -25,7 +25,7 @@ const { loadConfig } = configLoader;
|
||||
import { platformReadSync as safeReadFile, platformWriteSync, platformEnsureDir } from './shell-command-projection.cjs';
|
||||
import { getGlobalSkillDir, getGlobalConfigDir } from './runtime-homes.cjs';
|
||||
import { formatGsdSlash, resolveRuntime } from './runtime-slash.cjs';
|
||||
import { resolveRuntimeNameFromCandidates } from './runtime-name-policy.cjs';
|
||||
import { resolveRuntimeNameFromCandidates, getProjectInstructionFile } from './runtime-name-policy.cjs';
|
||||
|
||||
// ─── Types ────────────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -1120,20 +1120,32 @@ function cmdGenerateClaudeMd(cwd: string, options: CmdGenerateClaudeMdOptions, r
|
||||
// repo-root `CLAUDE.md`, so generated GSD content does not land next to — or
|
||||
// pollute — a hand-crafted repo-root CLAUDE.md. An explicit `claude_md_path`
|
||||
// config value or `--output` still wins.
|
||||
let configClaudeMdPath = './.claude/CLAUDE.md';
|
||||
let configClaudeMdPath = '.claude/CLAUDE.md';
|
||||
try {
|
||||
const config = loadConfig(cwd);
|
||||
if (config['claude_md_path']) configClaudeMdPath = config['claude_md_path'] as string;
|
||||
if (config['claude_md_assembly']) assemblyConfig = config['claude_md_assembly'] as Record<string, unknown>;
|
||||
// #3163: When runtime is codex, override the output target to AGENTS.md
|
||||
// regardless of claude_md_path, so Codex projects never write to CLAUDE.md.
|
||||
// GSD_RUNTIME env var takes precedence over config.runtime, mirroring detectRuntime().
|
||||
// #1529: When no explicit --output is provided, derive the instruction
|
||||
// file from the runtime via the shared `getProjectInstructionFile` policy
|
||||
// (single source of truth in runtime-name-policy.cjs, shared with the
|
||||
// new-project.md bash workflow via `gsd-tools query
|
||||
// project-instruction-file`). Previously this was a codex-only override
|
||||
// (#3163) that left AGENTS-native runtimes (opencode/kilo/kimi) emitting
|
||||
// CLAUDE.md; copilot now resolves to .github/copilot-instructions.md, and
|
||||
// antigravity/gemini to GEMINI.md. GSD_RUNTIME env var takes precedence
|
||||
// over config.runtime, mirroring detectRuntime().
|
||||
//
|
||||
// Non-claude runtimes always win over a stale `claude_md_path` (the #3163
|
||||
// rationale: a Codex/AGENTS-native project must never write to CLAUDE.md
|
||||
// even if a prior Claude setup left a `claude_md_path` behind). For the
|
||||
// claude runtime, `claude_md_path` config is honored — it IS the
|
||||
// Claude-specific output setting (per #1098 and the #3163 non-codex test).
|
||||
const effectiveRuntime = resolveRuntimeNameFromCandidates(
|
||||
process.env['GSD_RUNTIME'],
|
||||
config['runtime']
|
||||
);
|
||||
if (!options.output && effectiveRuntime === 'codex') {
|
||||
configClaudeMdPath = './AGENTS.md';
|
||||
if (!options.output && effectiveRuntime && effectiveRuntime !== 'claude') {
|
||||
configClaudeMdPath = getProjectInstructionFile(effectiveRuntime);
|
||||
}
|
||||
} catch { /* use default */ }
|
||||
|
||||
|
||||
@@ -426,8 +426,11 @@ function cmdRoadmapAnalyze(cwd: string, raw: boolean): void {
|
||||
const totalSummaries = phases.reduce((sum, p) => sum + p.summary_count, 0);
|
||||
const completedPhases = phases.filter(p => p.disk_status === 'complete').length;
|
||||
|
||||
// Detect phases in summary list without detail sections (malformed ROADMAP)
|
||||
const checklistPattern = /-\s*\[[ x]\]\s*\*\*Phase\s+(\d+[A-Z]?(?:\.\d+)*)/gi;
|
||||
// Detect phases in summary list without detail sections (malformed ROADMAP).
|
||||
// The char class must allow `-` (not just `.`) so dash-separated milestone-prefixed
|
||||
// IDs (e.g. `1-01`) match the detail-heading scanner above; otherwise they truncate
|
||||
// at the dash (`1-01` -> `1`) and every such phase reports a phantom missing detail.
|
||||
const checklistPattern = /-\s*\[[ x]\]\s*\*\*Phase\s+(\d+[A-Z]?(?:[.-]\d+)*)/gi;
|
||||
const checklistPhases = new Set<string>();
|
||||
let checklistMatch: RegExpExecArray | null;
|
||||
while ((checklistMatch = checklistPattern.exec(content)) !== null) {
|
||||
|
||||
@@ -21,10 +21,46 @@ import os from 'node:os';
|
||||
import fs from 'node:fs';
|
||||
import commandRoster = require('./command-roster.cjs');
|
||||
const { readGsdCommandNames, transformContentToHyphen } = commandRoster;
|
||||
const pkg = require('../../../package.json');
|
||||
import runtimeNamePolicy = require('./runtime-name-policy.cjs');
|
||||
const { getDirName } = runtimeNamePolicy;
|
||||
|
||||
// #1383: resolve GSD's version WITHOUT a top-level
|
||||
// `require('../../../package.json')`. That require ran at module load on every
|
||||
// gsd-tools invocation (this module sits in the gsd-tools loader chain) and
|
||||
// threw `Cannot find module '../../../package.json'` on runtimes whose root has
|
||||
// no package.json — notably Codex, where the installer omits the synthetic root
|
||||
// package.json — taking the entire CLI down before it did anything. And even
|
||||
// where it resolved (Claude's synthetic `{"type":"commonjs"}`), there is no
|
||||
// `version` field, so the single consumer below already emitted
|
||||
// `version: undefined`. Resolve lazily and defensively instead:
|
||||
// 1. Installed trees carry <root>/gsd-core/VERSION (written by the installer);
|
||||
// this module lives at <root>/gsd-core/bin/lib, so VERSION is two dirs up.
|
||||
// 2. The source / npm-package tree has no gsd-core/VERSION but carries a real
|
||||
// package.json three dirs up — read it lazily, never at module-load time.
|
||||
// A failed/invalid lookup degrades to '' (the caller omits the field) rather
|
||||
// than crashing or emitting `version: undefined`. Both sources are validated
|
||||
// against the same semver shape the repo's other VERSION reader enforces
|
||||
// (src/update-context.cts) so a garbled VERSION file is never emitted verbatim.
|
||||
// Exported for the #1383 regression.
|
||||
const SEMVER_PREFIX = /^\d+\.\d+\.\d+/; // mirrors src/update-context.cts SEMVER_PREFIX
|
||||
function resolveVersionFrom(libDir: string): string {
|
||||
try {
|
||||
const v = fs.readFileSync(path.join(libDir, '..', '..', 'VERSION'), 'utf8').trim();
|
||||
if (SEMVER_PREFIX.test(v)) return v;
|
||||
} catch { /* not an installed tree (no gsd-core/VERSION) */ }
|
||||
try {
|
||||
const pkg = require(path.join(libDir, '..', '..', '..', 'package.json'));
|
||||
if (pkg && typeof pkg.version === 'string' && SEMVER_PREFIX.test(pkg.version)) return pkg.version;
|
||||
} catch { /* runtime root has no package.json (e.g. Codex) */ }
|
||||
return '';
|
||||
}
|
||||
|
||||
let cachedVersion: string | undefined;
|
||||
function gsdVersion(): string {
|
||||
if (cachedVersion === undefined) cachedVersion = resolveVersionFrom(__dirname);
|
||||
return cachedVersion;
|
||||
}
|
||||
|
||||
|
||||
const colorNameToHex = {
|
||||
cyan: '#00FFFF',
|
||||
@@ -393,7 +429,10 @@ function convertClaudeCommandToClaudeSkill(content, skillName, runtime = null, c
|
||||
// Hermes' SKILL.md spec lists `version` as a required frontmatter field.
|
||||
// Track GSD's package version so Hermes' skill_view() reports a stable
|
||||
// identifier per install.
|
||||
if (runtime === 'hermes') fm += `version: ${yamlQuote(pkg.version)}\n`;
|
||||
if (runtime === 'hermes') {
|
||||
const version = gsdVersion();
|
||||
if (version) fm += `version: ${yamlQuote(version)}\n`;
|
||||
}
|
||||
// #778 (b) — Qwen-only numeric priority for /skills ordering. Scoped to qwen
|
||||
// so Claude/Hermes skill frontmatter is unchanged (they ignore the field, but
|
||||
// we keep their output byte-stable). skillName is the `gsd-<stem>` dir name.
|
||||
@@ -1816,11 +1855,17 @@ function convertGeminiToolName(claudeTool) {
|
||||
// Task/Agent: exclude — agents are auto-registered as callable tools.
|
||||
// AskUserQuestion: exclude — Gemini CLI does not expose an ask_user tool;
|
||||
// emitting it causes frontmatter validation errors (#3362).
|
||||
// Skill/SlashCommand: exclude — Gemini CLI has no 'skill' built-in tool;
|
||||
// the lowercase fallback would emit an invalid 'skill'/'slashcommand' name
|
||||
// that fails frontmatter validation (tools.N: Invalid tool name) and aborts
|
||||
// the entire agent load (#1394).
|
||||
if (
|
||||
claudeTool === 'Task' ||
|
||||
claudeTool === 'Agent' ||
|
||||
claudeTool === 'AskUserQuestion' ||
|
||||
claudeTool === 'ask_user'
|
||||
claudeTool === 'ask_user' ||
|
||||
claudeTool === 'Skill' ||
|
||||
claudeTool === 'SlashCommand'
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
@@ -2541,6 +2586,9 @@ export = {
|
||||
convertClaudeCommandToKiloSkill,
|
||||
readGsdCommandNames,
|
||||
transformContentToHyphen,
|
||||
// #1383: version resolver (exported for regression test of the Codex
|
||||
// missing-package.json crash + the VERSION-file source of truth).
|
||||
resolveVersionFrom,
|
||||
// #1182: agent converters + tool-name table dependency closure
|
||||
claudeToCopilotTools,
|
||||
convertCopilotToolName,
|
||||
|
||||
@@ -175,6 +175,20 @@ function agentsKind(destSubpath: string, prefix: string, configDir: string): Art
|
||||
* Agent filenames are preserved verbatim (the prefix is already embedded in the
|
||||
* agent stem — e.g. `gsd-planner.md`).
|
||||
*
|
||||
* #1173 SCOPE — plumbing only (declarations deferred): this provides the
|
||||
* converter dispatch + `isGlobal` scope threading for the descriptor's `agents`
|
||||
* kind, but NO runtime currently declares a converted `agents` kind in its
|
||||
* `capability.json`. The descriptor declarations for the 8 non-Claude runtimes
|
||||
* (copilot/antigravity/cursor/windsurf/augment/trae/codebuddy/cline) are
|
||||
* DEFERRED to a follow-up that first ships the ADR-1235 §0 byte-for-byte parity
|
||||
* harness, because the second `layout.kinds` consumer — `applySurface` /
|
||||
* `/gsd:surface` / `--materialize` (`src/surface.cts`) — does not yet mirror the
|
||||
* legacy agent pipeline (Copilot's `.agent.md` filename rename, the cross-cutting
|
||||
* path-prefix rewrite + attribution, stale-file cleanup, config-reading steps),
|
||||
* so declaring the kind now would regress the surface path. Until then the legacy
|
||||
* `bin/install.js` agent loop remains authoritative for the real install, and
|
||||
* this `convertedAgentsKind` is exercised only by synthetic-descriptor seam tests.
|
||||
*
|
||||
* Mirrors the `convertedCommandsKind` pattern (#785).
|
||||
*
|
||||
* @param destSubpath destination subpath within configDir (e.g. 'agents')
|
||||
@@ -187,14 +201,24 @@ function convertedAgentsKind(
|
||||
prefix: string,
|
||||
converterName: string,
|
||||
configDir: string,
|
||||
scope: 'local' | 'global' = 'global',
|
||||
): ArtifactKind {
|
||||
return {
|
||||
kind: 'agents',
|
||||
destSubpath,
|
||||
prefix,
|
||||
stage: (resolved) => {
|
||||
const converter = conversionExports[converterName] as (content: string) => string;
|
||||
return stageAgentsForRuntimeWithConverter(findAgentsSourceRoot(configDir), resolved, converter);
|
||||
// isGlobal is threaded so scope-aware agent converters (copilot, antigravity)
|
||||
// choose global-home vs workspace-relative paths; converters that only take
|
||||
// (content) ignore the extra positional arg. Mirrors skillsKind's scope
|
||||
// threading (#1173).
|
||||
const converter = conversionExports[converterName] as (content: string, isGlobal?: boolean) => string;
|
||||
return stageAgentsForRuntimeWithConverter(
|
||||
findAgentsSourceRoot(configDir),
|
||||
resolved,
|
||||
converter,
|
||||
scope === 'global',
|
||||
);
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -411,7 +435,7 @@ function dispatchKindEntry(entry: ArtifactKindDescriptor, runtime: string, confi
|
||||
if (converter == null) {
|
||||
return agentsKind(destSubpath, prefix, configDir);
|
||||
}
|
||||
return convertedAgentsKind(destSubpath, prefix, converter, configDir);
|
||||
return convertedAgentsKind(destSubpath, prefix, converter, configDir, scope);
|
||||
|
||||
case 'skills':
|
||||
if (converter == null) {
|
||||
|
||||
@@ -89,6 +89,48 @@ export function resolveRuntimeNameFromCandidates(...candidates: unknown[]): stri
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Map a runtime id to its project instruction file path (relative to project
|
||||
* root). Bug #1529: this is the SINGLE source of truth shared by both
|
||||
* consumption surfaces —
|
||||
* (A) the Node surface: profile-output.cjs (generate-claude-md handler)
|
||||
* (B) the bash surface: `gsd-tools query project-instruction-file --runtime <r>`,
|
||||
* consumed by gsd-core/workflows/new-project.md to set $INSTRUCTION_FILE
|
||||
*
|
||||
* Mapping table (per the #1529 issue contract):
|
||||
*
|
||||
* claude → .claude/CLAUDE.md
|
||||
* codex, opencode, kilo, kimi → AGENTS.md
|
||||
* copilot → .github/copilot-instructions.md
|
||||
* antigravity, gemini → GEMINI.md
|
||||
* unknown / future runtimes → AGENTS.md (safe cross-agent default)
|
||||
*
|
||||
* Source-of-truth references for each runtime's read path:
|
||||
* - copilot: GitHub Docs — repository-wide custom instructions are read ONLY
|
||||
* from `.github/copilot-instructions.md`; a root `copilot-instructions.md`
|
||||
* is not a read path. `AGENTS.md` is also read (agent instructions).
|
||||
* https://docs.github.com/en/copilot/how-tos/configure-custom-instructions/add-repository-instructions
|
||||
* (Installer parity: runtime-config-adapter-registry.cts installSurface
|
||||
* 'copilot-instructions' writes the same `.github/copilot-instructions.md`.)
|
||||
* - codex/opencode/kilo/kimi: AGENTS.md is the documented cross-agent
|
||||
* instruction file (agentsmd/agents.md convention).
|
||||
* - antigravity/gemini: GEMINI.md is Gemini CLI's contextFileName.
|
||||
*
|
||||
* Aliases are normalized via `canonicalizeRuntimeName` first, so inputs like
|
||||
* `codex-cli` resolve to `codex` → `AGENTS.md`. Replaces the prior codex-only
|
||||
* override in profile-output.cjs (#3163) which left AGENTS-native runtimes
|
||||
* (opencode/kilo/kimi) incorrectly emitting `.claude/CLAUDE.md`. Pure: no I/O.
|
||||
*/
|
||||
export function getProjectInstructionFile(runtime: unknown): string {
|
||||
const canonical = canonicalizeRuntimeName(runtime);
|
||||
if (canonical === 'claude') return '.claude/CLAUDE.md';
|
||||
if (canonical === 'copilot') return '.github/copilot-instructions.md';
|
||||
if (canonical === 'antigravity' || canonical === 'gemini') return 'GEMINI.md';
|
||||
// codex, opencode, kilo, kimi, AND unknown/future runtimes all default to
|
||||
// root AGENTS.md (the safe cross-agent instruction file).
|
||||
return 'AGENTS.md';
|
||||
}
|
||||
|
||||
/**
|
||||
* Map a canonical runtime id to its on-disk local config directory name
|
||||
* (e.g. `cursor` -> `.cursor`, `windsurf` -> `.devin`). Unknown/empty inputs
|
||||
|
||||
282
src/state.cts
282
src/state.cts
@@ -152,6 +152,116 @@ process.on('exit', () => {
|
||||
}
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Lock liveness probe (test seam) — audit M1
|
||||
//
|
||||
// mtime is a LEAKY proxy for "the holder is still alive": a live-but-slow writer
|
||||
// whose critical section runs past staleThresholdMs ages out and a waiter would
|
||||
// steal its lock → two writers in STATE.md's read-modify-write window → lost
|
||||
// update / corruption (the recurring #500/#905/#1230 family). The real signal —
|
||||
// process.kill(pid, 0) — is already used by capability-lock.cts. We backport it
|
||||
// here. The indirection lets unit tests inject a deterministic isPidAlive without
|
||||
// real pids (mirrors capability-lock's _lockProbes / _setLockProbes seam).
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** 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 _stateLockProbes: { isPidAlive: (pid: number) => boolean } = { isPidAlive: _realIsPidAlive };
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// State-lock test hooks (test seam) — audit M8 / M9
|
||||
//
|
||||
// Both M8 (scan-before-lock TOCTOU in writeStateMd) and M9 (orphan empty lock +
|
||||
// fd leak on a recoverable writeSync/closeSync error in acquireStateLock) are
|
||||
// concurrency / resource-safety issues a single-threaded test cannot otherwise
|
||||
// observe. These purpose-built hooks make the failure windows deterministic
|
||||
// (mirrors the M1 _setLockProbes seam above):
|
||||
//
|
||||
// afterAcquire(lockPath) — fired inside writeStateMd immediately AFTER the lock
|
||||
// is acquired. A test can mutate the disk here (simulate a concurrent writer
|
||||
// landing in the scan→lock window) to prove the disk scan runs INSIDE the lock.
|
||||
// simulateWriteError — a ONE-SHOT errno string. When set, the next writeSync
|
||||
// inside acquireStateLock throws it (and the hook self-clears), forcing the
|
||||
// openSync-succeeds-then-write-fails cleanup path without an OS-level fault.
|
||||
// onLoopIteration(ctx) — fired at the TOP of each acquireStateLock retry
|
||||
// iteration so a test can snapshot whether an orphan lock is stranded.
|
||||
// beforeSteal(ctx) — fired AFTER the steal decision but BEFORE the identity
|
||||
// re-confirm + atomic rename-steal. A test can recreate a fresh lock here to
|
||||
// simulate a racer winning the steal in the decision→steal gap, proving the
|
||||
// identity re-confirm aborts a double-steal (PR #1532 review window b).
|
||||
//
|
||||
// All hooks default to no-ops; real callers are byte-for-behaviour unchanged.
|
||||
// ---------------------------------------------------------------------------
|
||||
interface StateLockTestHooks {
|
||||
afterAcquire?: (lockPath: string) => void;
|
||||
simulateWriteError?: string | null;
|
||||
onLoopIteration?: (ctx: { iteration: number }) => void;
|
||||
beforeSteal?: (ctx: { lockPath: string }) => void;
|
||||
}
|
||||
const _stateLockTestHooks: StateLockTestHooks = {};
|
||||
|
||||
/**
|
||||
* Consume the one-shot simulateWriteError errno, if set. Returns an Error with the
|
||||
* configured `.code` and self-clears so only the NEXT writeSync throws (the retry
|
||||
* then succeeds). Returns null when no injection is pending.
|
||||
*/
|
||||
function _consumeSimulatedWriteError(): NodeJS.ErrnoException | null {
|
||||
const code = _stateLockTestHooks.simulateWriteError;
|
||||
if (!code) return null;
|
||||
_stateLockTestHooks.simulateWriteError = null; // one-shot
|
||||
const e = new Error('simulated writeSync failure (' + code + ')') as NodeJS.ErrnoException;
|
||||
e.code = code;
|
||||
return e;
|
||||
}
|
||||
|
||||
function _stateLockIsPidAlive(pid: number): boolean {
|
||||
return _stateLockProbes.isPidAlive(pid);
|
||||
}
|
||||
|
||||
/**
|
||||
* Is the holder recorded in the lock body VERIFIED-LIVE? The STATE.md lock body is
|
||||
* a bare pid (written at acquire time). Returns true ONLY when the body parses to a
|
||||
* positive integer pid AND that pid signals alive. A garbage / non-numeric / legacy
|
||||
* 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 stolen.
|
||||
*/
|
||||
function _stateHolderVerifiedLive(lockPath: string): boolean {
|
||||
const pid = _stateLockBodyPid(lockPath);
|
||||
return pid !== null && _stateLockIsPidAlive(pid);
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse the lock body to its recorded pid, or null when the body is empty / non-numeric
|
||||
* / unreadable (legacy or mid-creation). Distinguishing a COMPLETE dead-pid body (steal
|
||||
* promptly) from an EMPTY/unparseable one (the create→write window — do not steal while
|
||||
* fresh) is what `_stateHolderVerifiedLive` alone cannot express, so the steal decision
|
||||
* in acquireStateLock reads the pid directly (PR #1532 review, window a).
|
||||
*/
|
||||
function _stateLockBodyPid(lockPath: string): number | null {
|
||||
let body: string;
|
||||
try {
|
||||
body = fs.readFileSync(lockPath, 'utf-8');
|
||||
} catch {
|
||||
return null; // unreadable body → cannot verify
|
||||
}
|
||||
const trimmed = body.trim();
|
||||
const pid = parseInt(trimmed, 10);
|
||||
if (!Number.isInteger(pid) || pid <= 0 || String(pid) !== trimmed) return null;
|
||||
return pid;
|
||||
}
|
||||
|
||||
// Monotonic sequence for unique stale-steal rename targets (no crypto dependency).
|
||||
let _stateStealSeq = 0;
|
||||
|
||||
// Hoisted to module scope — compiled once, not per call (#320). Stateless (/i, used with .match).
|
||||
const byPhaseTablePattern = /(\|\s*Phase\s*\|\s*Plans\s*\|\s*Total\s*\|\s*Avg\/Plan\s*\|[ \t]*\n\|(?:[- :\t]+\|)+[ \t]*\n)((?:[ \t]*\|[^\n]*\n)*)(?=\n|$)/i;
|
||||
|
||||
@@ -1663,8 +1773,23 @@ function acquireStateLock(statePath: string, clock?: StateLockClock): string {
|
||||
if (clock === undefined) clock = realClock;
|
||||
const lockPath = statePath + '.lock';
|
||||
const retryDelay = 200; // ms
|
||||
const staleThresholdMs = 10000;
|
||||
const maxWaitMs = 30000;
|
||||
// Deadman ceiling (audit M1) — set ABOVE maxWaitMs so a holder that reads as
|
||||
// VERIFIED-LIVE is NEVER stolen within the wait budget; only a crashed (dead
|
||||
// pid) or unparseable-body lock is stolen, and a pid-reuse holder (reads alive
|
||||
// but is unrelated) is recovered once age crosses this absolute ceiling rather
|
||||
// than blocking forever. The prior mtime-only `staleThresholdMs = 10000` gate
|
||||
// was BELOW maxWaitMs, so a live-but-slow holder >10 s was robbed mid-write.
|
||||
const deadmanCeilingMs = 60000;
|
||||
// Fresh-create floor (PR #1532 review, window a) — a lock with an EMPTY/unparseable
|
||||
// body is either mid-creation (O_EXCL create done, pid not yet written by the holder)
|
||||
// or a genuine orphan. While such a body is younger than this floor it is treated as
|
||||
// mid-creation and is NEVER stolen — stealing it at age ≈ 0 robs a holder still
|
||||
// writing its pid (the lost-update window capability-lock.cts's `age <= LOCK_STALE_MS`
|
||||
// floor closes). The create→write gap is sub-millisecond; this floor is orders of
|
||||
// magnitude larger yet well under maxWaitMs so a real orphan still clears within budget.
|
||||
// A COMPLETE dead-pid body is NOT subject to this floor — it is stolen promptly.
|
||||
const freshCreateFloorMs = 1000;
|
||||
const startedAt = clock.now();
|
||||
|
||||
// Shared helper: check the time budget then back off with jitter before the
|
||||
@@ -1683,11 +1808,33 @@ function acquireStateLock(statePath: string, clock?: StateLockClock): string {
|
||||
clock.sleep(retryDelay + jitter);
|
||||
};
|
||||
|
||||
let _loopIteration = 0;
|
||||
while (true) {
|
||||
if (_stateLockTestHooks.onLoopIteration) _stateLockTestHooks.onLoopIteration({ iteration: _loopIteration++ });
|
||||
try {
|
||||
const fd = fs.openSync(lockPath, fs.constants.O_CREAT | fs.constants.O_EXCL | fs.constants.O_WRONLY);
|
||||
fs.writeSync(fd, String(process.pid));
|
||||
fs.closeSync(fd);
|
||||
// Audit M9 (resource-safety): once the exclusive create SUCCEEDS, a
|
||||
// writeSync/closeSync failure must NOT leak the fd or strand the just-created
|
||||
// (now empty) lock — an orphan body self-blocks every later acquirer until a
|
||||
// liveness steal or the deadman. On any write/close error, guardedly close the
|
||||
// fd and unlink the file we created, then re-throw to the existing outer catch
|
||||
// (which keeps classifying recoverable vs fatal errnos — DRY). A FATAL errno
|
||||
// still propagates after cleanup; a RECOVERABLE one retries from a clean slate.
|
||||
// Mirrors capability-lock.cts:415-425.
|
||||
try {
|
||||
const injected = _consumeSimulatedWriteError();
|
||||
if (injected) throw injected; // test seam: one-shot writeSync failure (M9)
|
||||
fs.writeSync(fd, String(process.pid));
|
||||
fs.closeSync(fd);
|
||||
} catch (writeErr) {
|
||||
try { fs.closeSync(fd); } catch { /* best-effort — fd may already be closed */ }
|
||||
// Best-effort unlink of the lock WE just created. Guarded so we never throw
|
||||
// here; if another acquirer already stole the empty lock the unlink is a
|
||||
// harmless ENOENT no-op (we do not double-unlink someone else's lock — the
|
||||
// open(O_EXCL) above guarantees we created this path this iteration).
|
||||
try { fs.unlinkSync(lockPath); } catch { /* best-effort — no orphan */ }
|
||||
throw writeErr; // re-throw to the outer catch for recoverable/fatal classification
|
||||
}
|
||||
// Exit-time cleanup keeps a crashed locked region from leaving a stale file (#1916).
|
||||
_heldStateLocks.add(lockPath);
|
||||
return lockPath;
|
||||
@@ -1701,31 +1848,80 @@ function acquireStateLock(statePath: string, clock?: StateLockClock): string {
|
||||
continue;
|
||||
}
|
||||
if ((err as NodeJS.ErrnoException).code !== 'EEXIST') throw err; // propagate — silent bypass causes lost updates
|
||||
// Only unlink a lock we did not place when it has crossed the staleness
|
||||
// threshold (crashed holder). Nuking a fresh lock held by a slow-but-live
|
||||
// writer causes lost updates (#3711 regression).
|
||||
// Liveness-gated steal (audit M1) + steal-safety (PR #1532 review). The steal
|
||||
// decision is three-way on the lock body:
|
||||
// - VERIFIED-LIVE holder (parseable pid that signals alive): NEVER stolen until
|
||||
// its age crosses the absolute deadman ceiling (the pid-reuse backstop) —
|
||||
// nuking a slow-but-live writer's lock causes lost updates (#3711 / #500/#905/
|
||||
// #1230 family).
|
||||
// - COMPLETE DEAD pid (parseable pid, not alive): stolen PROMPTLY regardless of
|
||||
// age — a crashed holder left a full body.
|
||||
// - EMPTY / unparseable body: liveness is unknowable. While FRESH (age <=
|
||||
// freshCreateFloorMs) it is a lock still mid-creation (O_EXCL done, pid not yet
|
||||
// written) and is NOT stolen (window a); only once aged past the floor is it a
|
||||
// genuine orphan and stealable.
|
||||
// The steal itself is an ATOMIC rename-then-recreate (only one racer can rename the
|
||||
// inode) guarded by an identity re-confirm, so a racer that recreates a fresh lock
|
||||
// in the decision→steal gap never has its replacement deleted (window b). Mirrors
|
||||
// capability-lock.cts:455-499.
|
||||
try {
|
||||
const stat = fs.statSync(lockPath);
|
||||
if ((clock).now() - stat.mtimeMs > staleThresholdMs) {
|
||||
let removed = false;
|
||||
try { fs.unlinkSync(lockPath); removed = true; } catch { /* swallow: bounded below */ }
|
||||
if (removed) {
|
||||
// Successful steal — retry immediately to grab the just-freed lock.
|
||||
// Must NOT call checkBudgetAndSleep here: a throw-after-delete would
|
||||
// corrupt the filesystem state, and the budget is already bounded on
|
||||
// the next iteration's EEXIST or open attempt (#1217 regression fix).
|
||||
const ageMs = clock.now() - stat.mtimeMs;
|
||||
const bodyPid = _stateLockBodyPid(lockPath);
|
||||
const holderLive = bodyPid !== null && _stateLockIsPidAlive(bodyPid);
|
||||
let steal: boolean;
|
||||
if (holderLive) {
|
||||
steal = ageMs > deadmanCeilingMs; // pid-reuse backstop only
|
||||
} else if (bodyPid !== null) {
|
||||
steal = true; // complete dead pid → prompt steal
|
||||
} else {
|
||||
steal = ageMs > freshCreateFloorMs; // empty/garbage → protect the create window
|
||||
}
|
||||
if (steal) {
|
||||
if (_stateLockTestHooks.beforeSteal) _stateLockTestHooks.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) and/or
|
||||
// the body pid → do NOT delete the replacement; re-evaluate from scratch.
|
||||
let confirmStat: fs.Stats;
|
||||
try {
|
||||
confirmStat = fs.statSync(lockPath);
|
||||
} catch {
|
||||
continue; // lock vanished between decision and steal — retry the create.
|
||||
}
|
||||
const sameInstance =
|
||||
typeof stat.dev === 'number' && typeof stat.ino === 'number' &&
|
||||
confirmStat.dev === stat.dev && confirmStat.ino === stat.ino &&
|
||||
_stateLockBodyPid(lockPath) === bodyPid;
|
||||
if (!sameInstance) {
|
||||
// The lock changed under us (a racer won the steal + recreated). Back off
|
||||
// and re-evaluate rather than deleting the racer's fresh replacement.
|
||||
checkBudgetAndSleep('lock changed before steal');
|
||||
continue;
|
||||
}
|
||||
// Persistent unlinkSync failure — apply budget + backoff so it cannot
|
||||
// busy-spin (#1217).
|
||||
checkBudgetAndSleep('stale lock removal failed');
|
||||
// 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() + '-' + (_stateStealSeq++);
|
||||
let renamed = false;
|
||||
try { fs.renameSync(lockPath, stolen); renamed = true; } catch { /* another racer won */ }
|
||||
if (renamed) {
|
||||
try { fs.rmSync(stolen, { force: true }); } catch { /* best-effort */ }
|
||||
// Successful steal — retry immediately to grab the just-freed lock.
|
||||
// Must NOT call checkBudgetAndSleep here: a throw-after-rename would
|
||||
// corrupt filesystem state, and the budget is already bounded on the next
|
||||
// iteration's EEXIST or open attempt (#1217 regression fix).
|
||||
continue;
|
||||
}
|
||||
// Lost the steal race (or a transient rename failure) — apply budget + backoff
|
||||
// so it cannot busy-spin (#1217).
|
||||
checkBudgetAndSleep('stale lock steal lost to racer');
|
||||
continue;
|
||||
}
|
||||
} catch (err) {
|
||||
// Re-throw a budget-exceeded error from the unlinkSync failure path above
|
||||
// unchanged — its message already names the real cause ("stale lock removal
|
||||
// failed") and double-wrapping it would replace that with the misleading
|
||||
// "statSync failed after EEXIST" context string (#1217 diagnostic fix).
|
||||
// Re-throw a budget-exceeded error from the steal path above unchanged — its
|
||||
// message already names the real cause ("lock changed before steal" / "stale
|
||||
// lock steal lost to racer") and double-wrapping it would replace that with the
|
||||
// misleading "statSync failed after EEXIST" context string (#1217 diagnostic fix).
|
||||
if ((err as Record<string, unknown>)?.lockBudgetExceeded) throw err;
|
||||
// statSync failed — lock was likely released between our EEXIST and this
|
||||
// stat call. Apply budget + backoff so a persistent statSync failure
|
||||
@@ -1765,13 +1961,24 @@ function withStateLock<T>(statePath: string, fn: () => T): T {
|
||||
* Optional clock seam; defaults to realClock. Passed through to acquireStateLock.
|
||||
*/
|
||||
function writeStateMd(statePath: string, content: string, cwd?: string, clock?: StateLockClock): void {
|
||||
// Invalidate disk scan cache before computing new frontmatter — the write
|
||||
// may create new PLAN/SUMMARY files that buildStateFrontmatter must see.
|
||||
// Safe for any calling pattern, not just short-lived CLI processes (#1967).
|
||||
if (cwd) _diskScanCache.delete(cwd);
|
||||
const synced = syncStateFrontmatter(content, cwd);
|
||||
const lockPath = acquireStateLock(statePath, clock);
|
||||
// Test seam (audit M8): fire AFTER the lock is taken so a test can simulate a
|
||||
// concurrent writer landing in the (now-closed) scan→lock window.
|
||||
if (_stateLockTestHooks.afterAcquire) _stateLockTestHooks.afterAcquire(lockPath);
|
||||
try {
|
||||
// Audit M8 (leaky-abstractions): the disk scan that counts PLAN/SUMMARY files
|
||||
// to build the frontmatter is the READ half of this read-modify-write — it must
|
||||
// run INSIDE the lock (mirroring readModifyWriteStateMd), not before it. Scanning
|
||||
// before acquireStateLock left a TOCTOU window where a concurrent writer that
|
||||
// committed a new PLAN/SUMMARY between our scan and our lock made writeStateMd
|
||||
// stamp STALE progress counts (lost update — the #500/#905/#1230 family). The
|
||||
// scan order is otherwise byte-for-behaviour identical for single-threaded
|
||||
// callers — only the concurrent-writer window closes.
|
||||
//
|
||||
// Invalidate the disk scan cache first — the write may create new PLAN/SUMMARY
|
||||
// files that buildStateFrontmatter must see (#1967).
|
||||
if (cwd) _diskScanCache.delete(cwd);
|
||||
const synced = syncStateFrontmatter(content, cwd);
|
||||
platformWriteSync(statePath, synced);
|
||||
} finally {
|
||||
releaseStateLock(lockPath);
|
||||
@@ -2982,4 +3189,27 @@ export = {
|
||||
cmdStateMilestoneSwitch,
|
||||
cmdSignalWaiting,
|
||||
cmdSignalResume,
|
||||
// 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') _stateLockProbes.isPidAlive = probes.isPidAlive;
|
||||
},
|
||||
_resetLockProbes(): void {
|
||||
_stateLockProbes.isPidAlive = _realIsPidAlive;
|
||||
},
|
||||
// Test seam (audit M8/M9): inject deterministic hooks for the scan-in-lock window
|
||||
// (afterAcquire), the one-shot recoverable writeSync failure (simulateWriteError),
|
||||
// and per-iteration orphan-lock snapshots (onLoopIteration). See _stateLockTestHooks.
|
||||
_setStateLockTestHooks(hooks: StateLockTestHooks): void {
|
||||
if ('afterAcquire' in hooks) _stateLockTestHooks.afterAcquire = hooks.afterAcquire;
|
||||
if ('simulateWriteError' in hooks) _stateLockTestHooks.simulateWriteError = hooks.simulateWriteError;
|
||||
if ('onLoopIteration' in hooks) _stateLockTestHooks.onLoopIteration = hooks.onLoopIteration;
|
||||
if ('beforeSteal' in hooks) _stateLockTestHooks.beforeSteal = hooks.beforeSteal;
|
||||
},
|
||||
_resetStateLockTestHooks(): void {
|
||||
delete _stateLockTestHooks.afterAcquire;
|
||||
delete _stateLockTestHooks.simulateWriteError;
|
||||
delete _stateLockTestHooks.onLoopIteration;
|
||||
delete _stateLockTestHooks.beforeSteal;
|
||||
},
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user