* fix(#142): canonicalize runtime aliases across cjs and sdk * fix(#142): satisfy hand-sync and inventory parity gates * test(#1974): remove record-session lock contention in context monitor spec * test(config): retry transient config-ensure-section failures * docs(context): capture PR #143 CI reliability findings * fix(#142): bump CLI Modules inventory headline to 76 (runtime-name-policy + runtime-slash) docs/INVENTORY.md had the two new .cjs rows listed but the headline count stayed at 75; fs count is 76. inventory-counts.test.cjs caught the drift. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> --------- Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -11,20 +11,11 @@
|
||||
* 4. Path resolution uses __dirname, not hardcoded ~/.claude/.
|
||||
* 5. A WARNING-only fire does NOT set criticalRecorded (selectivity counter-test).
|
||||
*
|
||||
* Design note (#3726, #3775): the original test polled STATE.md on a
|
||||
* wall-clock deadline against a fire-and-forget spawn().unref() subprocess —
|
||||
* racy under Docker contention. On loaded Docker hosts (cartographer,
|
||||
* holodeck) the subprocess intrinsic cost (Node startup + state lock acquire
|
||||
* + atomic write) reached 900–1700ms, consuming the entire budget and causing
|
||||
* intermittent CI failures (#3775). The fix uses two deterministic
|
||||
* assertions that do not depend on subprocess completion timing:
|
||||
* (a) The hook writes criticalRecorded:true to the warnPath file BEFORE it
|
||||
* exits (synchronously, before .unref() returns). Since runHook() uses
|
||||
* spawnSync, this is readable the moment runHook() returns.
|
||||
* (b) The state record-session command is invoked synchronously (spawnSync)
|
||||
* to verify the persistence function writes STATE.md correctly. This
|
||||
* decouples the hook's fire-and-forget semantics from the test
|
||||
* assertion entirely — no wall-clock budget needed.
|
||||
* Design note (#3726, #3775): the original test used a short wall-clock poll
|
||||
* against a fire-and-forget spawn().unref() subprocess and flaked under load.
|
||||
* We keep one deterministic assertion (criticalRecorded sentinel is written
|
||||
* before hook exit), and use a bounded poll window for the detached writer's
|
||||
* STATE.md update. A separate test verifies direct record-session invocation.
|
||||
*/
|
||||
|
||||
'use strict';
|
||||
@@ -80,9 +71,29 @@ function runRecordSession(cwd, stoppedAt) {
|
||||
const result = spawnSync(
|
||||
process.execPath,
|
||||
[GSD_TOOLS, 'state', 'record-session', '--stopped-at', stoppedAt, '--cwd', cwd],
|
||||
{ encoding: 'utf-8', timeout: 10000 }
|
||||
{ encoding: 'utf-8', timeout: 30000 }
|
||||
);
|
||||
return { exitCode: result.status, stdout: result.stdout, stderr: result.stderr };
|
||||
return {
|
||||
exitCode: result.status,
|
||||
signal: result.signal,
|
||||
error: result.error,
|
||||
stdout: result.stdout,
|
||||
stderr: result.stderr,
|
||||
};
|
||||
}
|
||||
|
||||
function sleep(ms) {
|
||||
Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
|
||||
}
|
||||
|
||||
function waitForStateMatch(statePath, regex, timeoutMs = 45000) {
|
||||
const deadline = Date.now() + timeoutMs;
|
||||
while (Date.now() < deadline) {
|
||||
const content = fs.readFileSync(statePath, 'utf-8');
|
||||
if (regex.test(content)) return content;
|
||||
sleep(100);
|
||||
}
|
||||
return fs.readFileSync(statePath, 'utf-8');
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -129,7 +140,6 @@ describe('#1974 context exhaustion auto-record', () => {
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
const sleep = (ms) => Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
|
||||
for (let attempt = 0; attempt < 5; attempt += 1) {
|
||||
try {
|
||||
cleanup(tmpDir);
|
||||
@@ -166,16 +176,9 @@ describe('#1974 context exhaustion auto-record', () => {
|
||||
'hook must set criticalRecorded:true in warn sentinel on CRITICAL'
|
||||
);
|
||||
|
||||
// (b) Deterministic: invoke state record-session synchronously to verify
|
||||
// the persistence seam writes STATE.md correctly. This is the same
|
||||
// command the hook spawns — we call it directly (spawnSync) to avoid
|
||||
// wall-clock timing dependency on the hook's fire-and-forget subprocess.
|
||||
const usedPct = 80; // 100 - 20
|
||||
const stoppedAt = `context exhaustion at ${usedPct}% (${new Date().toISOString().split('T')[0]})`;
|
||||
const recordResult = runRecordSession(tmpDir, stoppedAt);
|
||||
assert.strictEqual(recordResult.exitCode, 0, `record-session should exit 0: ${recordResult.stderr}`);
|
||||
|
||||
const content = fs.readFileSync(statePath, 'utf-8');
|
||||
// (b) Hook-spawned detached record-session should eventually persist
|
||||
// a context exhaustion breadcrumb in STATE.md.
|
||||
const content = waitForStateMatch(statePath, /context exhaustion at \d+%/, 45000);
|
||||
assert.match(content, /context exhaustion at \d+%/, 'STATE.md must contain context exhaustion entry');
|
||||
});
|
||||
|
||||
@@ -204,11 +207,6 @@ describe('#1974 context exhaustion auto-record', () => {
|
||||
assert.ok(warnData1, 'warn sentinel must exist after first CRITICAL fire');
|
||||
assert.strictEqual(warnData1.criticalRecorded, true, 'first fire must set criticalRecorded:true');
|
||||
|
||||
// Verify the persistence seam works by calling record-session directly
|
||||
// (synchronous — no subprocess race).
|
||||
const recordResult = runRecordSession(tmpDir, 'context exhaustion at 80% (2026-01-01)');
|
||||
assert.strictEqual(recordResult.exitCode, 0, 'record-session should succeed');
|
||||
|
||||
// Second CRITICAL fire — same session, criticalRecorded already true in
|
||||
// warnPath. Advance callsSinceWarn past DEBOUNCE_CALLS (5, see hook
|
||||
// line 29) so the hook processes the warning message path and exercises
|
||||
@@ -235,6 +233,17 @@ describe('#1974 context exhaustion auto-record', () => {
|
||||
);
|
||||
});
|
||||
|
||||
test('state record-session command persists Stopped At when invoked directly', () => {
|
||||
const recordResult = runRecordSession(tmpDir, 'context exhaustion at 80% (2026-01-01)');
|
||||
assert.strictEqual(
|
||||
recordResult.exitCode,
|
||||
0,
|
||||
`record-session should exit 0 (signal=${recordResult.signal || 'none'} error=${recordResult.error ? recordResult.error.message : 'none'}): ${recordResult.stderr}`
|
||||
);
|
||||
const content = fs.readFileSync(statePath, 'utf-8');
|
||||
assert.match(content, /context exhaustion at 80% \(2026-01-01\)/, 'STATE.md must contain direct record-session value');
|
||||
});
|
||||
|
||||
test('WARNING-only fire does NOT set criticalRecorded (selectivity counter-test)', () => {
|
||||
// Trigger WARNING (remaining 30% — below WARNING_THRESHOLD=35, above CRITICAL_THRESHOLD=25)
|
||||
const result = runHook(sessionId, 30, tmpDir);
|
||||
|
||||
@@ -132,6 +132,26 @@ describe('bug-3584: init manager recommendedActions emit hyphen form', () => {
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test('codex alias runtime emits $gsd-<cmd> in recommended_actions[].command', () => {
|
||||
const phaseDir = path.join(tmpDir, '.planning', 'phases', '01-foundation');
|
||||
fs.mkdirSync(phaseDir, { recursive: true });
|
||||
fs.writeFileSync(path.join(phaseDir, '01-01-PLAN.md'), '# Plan\n');
|
||||
|
||||
const result = runGsdTools('init manager', tmpDir, { GSD_RUNTIME: 'codex-app' });
|
||||
assert.ok(result.success, `init manager (codex-app) failed: ${result.error || result.output}`);
|
||||
|
||||
const payload = JSON.parse(result.output);
|
||||
const commands = collectCommandFields(payload.recommended_actions || []);
|
||||
assert.ok(commands.length > 0);
|
||||
|
||||
for (const cmd of commands) {
|
||||
assert.ok(
|
||||
cmd.startsWith('$gsd-'),
|
||||
`codex alias recommended_actions command must use $gsd- shell-var form, got ${cmd}`,
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('bug-3584: phase add persists hyphen form into ROADMAP.md', () => {
|
||||
|
||||
@@ -55,6 +55,12 @@ describe('formatGsdSlash — runtime-aware slash command formatter', () => {
|
||||
assert.strictEqual(formatGsdSlash('new-milestone', null), '/gsd-new-milestone');
|
||||
assert.strictEqual(formatGsdSlash('new-milestone', undefined), '/gsd-new-milestone');
|
||||
});
|
||||
|
||||
test('runtime aliases for non-codex runtimes still emit hyphen form', () => {
|
||||
assert.strictEqual(formatGsdSlash('new-project', 'claude-code'), '/gsd-new-project');
|
||||
assert.strictEqual(formatGsdSlash('new-project', 'gemini-cli'), '/gsd-new-project');
|
||||
assert.strictEqual(formatGsdSlash('new-project', 'opencode-cli'), '/gsd-new-project');
|
||||
});
|
||||
});
|
||||
|
||||
describe('codex shell-var form', () => {
|
||||
@@ -62,6 +68,11 @@ describe('formatGsdSlash — runtime-aware slash command formatter', () => {
|
||||
assert.strictEqual(formatGsdSlash('execute-phase', 'codex'), '$gsd-execute-phase');
|
||||
});
|
||||
|
||||
test('emits $gsd-<cmd> for codex aliases (app/cli)', () => {
|
||||
assert.strictEqual(formatGsdSlash('execute-phase', 'codex-app'), '$gsd-execute-phase');
|
||||
assert.strictEqual(formatGsdSlash('execute-phase', 'codex_cli'), '$gsd-execute-phase');
|
||||
});
|
||||
|
||||
test('codex output is lowercased', () => {
|
||||
assert.strictEqual(
|
||||
formatGsdSlash('Execute-Phase', 'codex'),
|
||||
@@ -196,6 +207,19 @@ describe('resolveRuntime — env > config > default', () => {
|
||||
}
|
||||
});
|
||||
|
||||
test('canonicalizes codex env aliases', () => {
|
||||
const saved = process.env.GSD_RUNTIME;
|
||||
try {
|
||||
process.env.GSD_RUNTIME = 'codex-app';
|
||||
assert.strictEqual(resolveRuntime(null), 'codex');
|
||||
process.env.GSD_RUNTIME = 'codex_cli';
|
||||
assert.strictEqual(resolveRuntime('/nonexistent'), 'codex');
|
||||
} finally {
|
||||
if (saved === undefined) delete process.env.GSD_RUNTIME;
|
||||
else process.env.GSD_RUNTIME = saved;
|
||||
}
|
||||
});
|
||||
|
||||
test('defaults to claude when env is unset and projectDir missing', () => {
|
||||
const saved = process.env.GSD_RUNTIME;
|
||||
try {
|
||||
@@ -228,6 +252,48 @@ describe('resolveRuntime — env > config > default', () => {
|
||||
}
|
||||
});
|
||||
|
||||
test('canonicalizes codex config aliases', (t) => {
|
||||
const fs = require('fs');
|
||||
const os = require('os');
|
||||
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-3584-'));
|
||||
t.after(() => fs.rmSync(tmp, { recursive: true, force: true }));
|
||||
|
||||
fs.mkdirSync(path.join(tmp, '.planning'), { recursive: true });
|
||||
fs.writeFileSync(
|
||||
path.join(tmp, '.planning', 'config.json'),
|
||||
JSON.stringify({ runtime: 'codex-cli' }),
|
||||
);
|
||||
|
||||
const saved = process.env.GSD_RUNTIME;
|
||||
try {
|
||||
delete process.env.GSD_RUNTIME;
|
||||
assert.strictEqual(resolveRuntime(tmp), 'codex');
|
||||
} finally {
|
||||
if (saved !== undefined) process.env.GSD_RUNTIME = saved;
|
||||
}
|
||||
});
|
||||
|
||||
test('canonicalizes non-codex config aliases', (t) => {
|
||||
const fs = require('fs');
|
||||
const os = require('os');
|
||||
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-3584-'));
|
||||
t.after(() => fs.rmSync(tmp, { recursive: true, force: true }));
|
||||
|
||||
fs.mkdirSync(path.join(tmp, '.planning'), { recursive: true });
|
||||
fs.writeFileSync(
|
||||
path.join(tmp, '.planning', 'config.json'),
|
||||
JSON.stringify({ runtime: 'claude-code' }),
|
||||
);
|
||||
|
||||
const saved = process.env.GSD_RUNTIME;
|
||||
try {
|
||||
delete process.env.GSD_RUNTIME;
|
||||
assert.strictEqual(resolveRuntime(tmp), 'claude');
|
||||
} finally {
|
||||
if (saved !== undefined) process.env.GSD_RUNTIME = saved;
|
||||
}
|
||||
});
|
||||
|
||||
test('lowercases the resolved runtime', () => {
|
||||
const saved = process.env.GSD_RUNTIME;
|
||||
try {
|
||||
|
||||
@@ -25,6 +25,24 @@ function writeConfig(tmpDir, obj) {
|
||||
fs.writeFileSync(configPath, JSON.stringify(obj, null, 2), 'utf-8');
|
||||
}
|
||||
|
||||
function sleep(ms) {
|
||||
Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
|
||||
}
|
||||
|
||||
function runConfigEnsureSectionWithRetry(tmpDir, attempts = 4) {
|
||||
let last;
|
||||
for (let i = 0; i < attempts; i += 1) {
|
||||
last = runGsdTools('config-ensure-section', tmpDir);
|
||||
if (last.success) return last;
|
||||
|
||||
const detail = `${last.error || ''}\n${last.output || ''}`;
|
||||
const transient = /(EPERM|EBUSY|EACCES|ENOTEMPTY|resource busy|used by another process|permission denied)/i.test(detail);
|
||||
if (!transient || i === attempts - 1) return last;
|
||||
sleep(150 * (i + 1));
|
||||
}
|
||||
return last;
|
||||
}
|
||||
|
||||
// ─── config-ensure-section ───────────────────────────────────────────────────
|
||||
|
||||
describe('config-ensure-section command', () => {
|
||||
@@ -64,12 +82,12 @@ describe('config-ensure-section command', () => {
|
||||
});
|
||||
|
||||
test('is idempotent — returns already_exists on second call', () => {
|
||||
const first = runGsdTools('config-ensure-section', tmpDir);
|
||||
const first = runConfigEnsureSectionWithRetry(tmpDir);
|
||||
assert.ok(first.success, `First call failed: ${first.error}`);
|
||||
const firstOutput = JSON.parse(first.output);
|
||||
assert.strictEqual(firstOutput.created, true);
|
||||
|
||||
const second = runGsdTools('config-ensure-section', tmpDir);
|
||||
const second = runConfigEnsureSectionWithRetry(tmpDir);
|
||||
assert.ok(second.success, `Second call failed: ${second.error}`);
|
||||
const secondOutput = JSON.parse(second.output);
|
||||
assert.strictEqual(secondOutput.created, false);
|
||||
|
||||
Reference in New Issue
Block a user