Files
msd-core/tests/milestone-lock.test.cjs
Tom Boucher c99d7bb2be test(#4522): migrate core CLI/domain state batch to named timeout constants (#4662)
Batch 11 of the ad hoc timeout literal migration (epic #4445). Replaces
every bare numeric timeout/timeoutMs object-literal property in
tests/state-document.test.cjs, tests/phase.test.cjs, tests/commands.test.cjs,
tests/pattern.test.cjs, tests/adr-612-bracket-coherence.test.cjs,
tests/adr-612-bracket-read-tolerance.test.cjs, tests/milestone-lock.test.cjs,
tests/init.test.cjs, tests/state-todos-render.test.cjs,
tests/quick-batch.test.cjs, tests/graphify.test.cjs, and
tests/effort-surface-axis.test.cjs with a named constant, per
eslint-rules/no-adhoc-timeout-literal.cjs. Removes these 12 files from the
rule's allowlist.

Ground truth via eslint found 25 sites, not the issue's stated 24 (phase.test.cjs
has 5, not 4) -- disclosed in the PR body.

Reuses PROBE_TIMEOUT_MS, GIT_TIMEOUT_MS, and LOOP_HOOK_POINT_CLI_TIMEOUT_MS
across 8 files. Adds two new shared constants to tests/helpers/timeouts.cjs
(each independently arrived at by 2 files in this batch, crossing the
promotion bar): PATHOLOGICAL_INPUT_TEST_TIMEOUT_MS (node:test's own per-test
timeout option, not a subprocess bound) and GSD_TOOLS_CLI_MODERATE_TIMEOUT_MS
(a single gsd-tools.cjs CLI subcommand spawn, distinct tier from
PROBE_TIMEOUT_MS/LOOP_HOOK_POINT_CLI_TIMEOUT_MS). Adds 3 file-local constants
for values used by only 1 file in this batch. No src/bin file touched, no
numeric value changed anywhere.

Co-authored-by: sim <sim@local>
Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-12 09:12:31 -04:00

650 lines
27 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// allow-test-rule: source-text-is-the-product (see #3311)
// Reads .planning/STATE.md (and the milestone.lock claim JSON) because the
// deployed text of those files IS the contract under test: #3311 is exactly
// about two sessions silently overwriting each other's Current Position text.
'use strict';
// #3311 — milestone lock keyed by phase + session id.
//
// The defect: two sessions running different phases in ONE working tree both
// read-modify-write the single un-scoped `## Current Position` slot in
// STATE.md. Byte-level serialization already exists (STATE.md.lock, #464), so
// no write is lost — but the SEMANTIC clobber is silent: `state.advance-plan`
// takes no phase argument and advances whatever plan the (possibly just
// clobbered) Current Position names, and nothing ever surfaces that two
// sessions claimed two different phases.
//
// The fix (maintainer decision on #3311): an advisory claim file
// `.planning/milestone.lock` holding { phase, session, pid, updated_at }.
// A second session working a DIFFERENT phase gets a visible warning
// (stderr + typed `milestone_conflict` JSON field / phase.complete's
// warnings[]) instead of a silent overwrite.
//
// Session identity comes from getWorkstreamSessionKey() (env-first). Tests
// drive it with GSD_SESSION_KEY via runGsdTools's per-call env override;
// helpers blank ambient session identity, so default runs are headless
// (null session) deterministically.
const { test, describe, beforeEach, afterEach } = require('node:test');
const assert = require('node:assert/strict');
const fs = require('fs');
const path = require('path');
const helpers = require('./helpers.cjs');
const { runGsdTools, createTempProject, cleanup, TOOLS_PATH, captureFdSync } = helpers;
const processSeam = require('./helpers/process-seam.cjs');
const { collectSection } = require('../gsd-core/bin/lib/markdown-sectionizer.cjs');
const { LOOP_HOOK_POINT_CLI_TIMEOUT_MS } = require('./helpers/timeouts.cjs');
// runGsdTools's legacy shape drops stderr on success, but the #3311 contract
// is exactly that a conflict is VISIBLE — these tests must see stderr. Drive
// the process seam directly for the conflicting invocations (same env base
// runGsdTools applies, exposed as the TEST_ENV_BASE getter).
function runToolsWithStderr(args, cwd, env = {}) {
return processSeam.runNode([TOOLS_PATH, ...args], {
cwd,
env: { ...process.env, ...helpers.TEST_ENV_BASE, ...env },
timeoutMs: LOOP_HOOK_POINT_CLI_TIMEOUT_MS,
});
}
const SESSION_A = { GSD_SESSION_KEY: 'session-a' };
const SESSION_B = { GSD_SESSION_KEY: 'session-b' };
// Pinned clock base for deterministic claim freshness (realClock.now() honors
// GSD_TEST_MODE + GSD_NOW_MS in the subprocess under test).
const T0 = Date.parse('2026-08-10T09:00:00.000Z');
// A delta comfortably past any plausible claim TTL (4h) — staleness driver.
const WEEK_LATER = T0 + 7 * 24 * 60 * 60 * 1000;
function milestoneLockPath(tmpDir) {
return path.join(tmpDir, '.planning', 'milestone.lock');
}
function readClaim(tmpDir) {
return JSON.parse(fs.readFileSync(milestoneLockPath(tmpDir), 'utf-8'));
}
const BEGIN_PHASE_STATE_MD = [
'# Project State',
'',
'**Current Phase:** 1',
'**Current Phase Name:** setup',
'**Total Phases:** 5',
'**Current Plan:** 0',
'**Total Plans in Phase:** 0',
'**Status:** Ready to plan',
'**Last Activity:** 2026-03-20',
'',
'## Current Position',
'Phase: 1 of 5 (setup)',
'Plan: 0 of ? in current phase',
'Status: Ready to plan',
'Last activity: 2026-03-20 — roadmap created',
'Progress: [..........] 0%',
'',
].join('\n');
// STATE.md whose Current Position names phase 2 — the post-clobber state a
// session-A advance-plan must detect against its phase-1 claim.
const ADVANCE_POSITION_PHASE_2 = [
'# Project State',
'',
'**Current Plan:** 1',
'**Total Plans in Phase:** 3',
'**Status:** Executing',
'**Last Activity:** 2026-08-10',
'',
'## Current Position',
'Phase: 2 of 5 (api)',
'Plan: 1 of 3 in current phase',
'Status: Executing',
'Last activity: 2026-08-10 — began api work',
'',
].join('\n');
/**
* Minimal project where `phase complete <n>` reaches its transaction:
* ROADMAP with two phases, phase dir with one plan + summary + passed
* verification for phase 1, and an empty (already-verified) phase 2.
*/
function writePhaseCompleteFixture(tmpDir) {
const planningDir = path.join(tmpDir, '.planning');
const phase1Dir = path.join(planningDir, 'phases', '01-foundation');
const phase2Dir = path.join(planningDir, 'phases', '02-api');
fs.mkdirSync(phase1Dir, { recursive: true });
fs.mkdirSync(phase2Dir, { recursive: true });
fs.writeFileSync(
path.join(planningDir, 'ROADMAP.md'),
[
'# Roadmap',
'',
'- [ ] Phase 1: Foundation',
'- [ ] Phase 2: API',
'',
'### Phase 1: Foundation',
'**Goal:** Setup',
'**Plans:** 1 plans',
'',
'### Phase 2: API',
'**Goal:** Build API',
'**Plans:** 0 plans',
'',
'## Progress',
'',
'| Phase | Plans Complete | Status | Completed |',
'|-------|----------------|--------|-----------|',
'| 01. Foundation | 0/1 | Not started | - |',
'| 02. API | 0/1 | Not started | - |',
'',
].join('\n'),
);
fs.writeFileSync(
path.join(planningDir, 'STATE.md'),
[
'# State',
'',
'**Current Phase:** 01',
'**Current Phase Name:** Foundation',
'**Status:** In progress',
'**Current Plan:** 01-01',
'**Last Activity:** 2026-08-10',
'',
'## Current Position',
'Phase: 1 of 2 (Foundation)',
'Plan: 1 of 1 in current phase',
'Status: Executing',
'',
].join('\n'),
);
fs.writeFileSync(path.join(phase1Dir, '01-01-PLAN.md'), '# Plan\n');
fs.writeFileSync(path.join(phase1Dir, '01-01-SUMMARY.md'), '# Summary\n');
fs.writeFileSync(
path.join(phase1Dir, '01-VERIFICATION.md'),
['---', 'status: passed', '---', '', '# Verification', ''].join('\n'),
);
fs.writeFileSync(
path.join(phase2Dir, '02-VERIFICATION.md'),
['---', 'status: passed', '---', '', '# Verification', ''].join('\n'),
);
}
// ─────────────────────────────────────────────────────────────────────────────
// M1–M4, M7, M8, M11 — begin-phase claim behavior
// ─────────────────────────────────────────────────────────────────────────────
describe('#3311 milestone lock: begin-phase claims', () => {
let tmpDir;
beforeEach(() => {
tmpDir = createTempProject();
fs.writeFileSync(path.join(tmpDir, '.planning', 'STATE.md'), BEGIN_PHASE_STATE_MD);
});
afterEach(() => {
cleanup(tmpDir);
});
test('M1: begin-phase records a milestone.lock claim with phase + session key', () => {
const result = runGsdTools(
['state', 'begin-phase', '--phase', '1', '--name', 'setup', '--plans', '4'],
tmpDir,
{ ...SESSION_A, GSD_TEST_MODE: '1', GSD_NOW_MS: String(T0) },
);
assert.ok(result.success, `begin-phase failed: ${result.error}`);
const claim = readClaim(tmpDir);
assert.strictEqual(claim.phase, '1', `claim phase must be 1; got ${JSON.stringify(claim)}`);
assert.ok(
typeof claim.session === 'string' && claim.session.includes('session-a'),
`claim session must carry the session key; got ${JSON.stringify(claim)}`,
);
assert.strictEqual(claim.updated_at, T0, 'claim updated_at must be the pinned now');
});
test('M2: second session beginning a DIFFERENT phase gets a visible conflict, claim left intact, STATE.md still updated', () => {
const first = runGsdTools(
['state', 'begin-phase', '--phase', '1', '--name', 'setup', '--plans', '4'],
tmpDir,
{ ...SESSION_A, GSD_TEST_MODE: '1', GSD_NOW_MS: String(T0) },
);
assert.ok(first.success, `first begin-phase failed: ${first.error}`);
const second = runToolsWithStderr(
['state', 'begin-phase', '--phase', '2', '--name', 'api', '--plans', '3'],
tmpDir,
{ ...SESSION_B, GSD_TEST_MODE: '1', GSD_NOW_MS: String(T0 + 1000) },
);
assert.strictEqual(second.exitCode, 0, `second begin-phase failed: ${second.stderr}`);
const out = JSON.parse(second.stdout);
assert.ok(
out.milestone_conflict && out.milestone_conflict.locked_phase === '1',
`output must carry milestone_conflict.locked_phase 1; got ${second.stdout}`,
);
assert.match(
second.stderr || '',
/milestone/i,
'a visible stderr warning must accompany the conflict',
);
// Warn, not block: STATE.md still moves to phase 2 …
const stateContent = fs.readFileSync(path.join(tmpDir, '.planning', 'STATE.md'), 'utf-8');
assert.match(stateContent, /^Phase:.*2.*EXECUTING/m, 'STATE.md must still be updated (warn, not block)');
// … and the first session's claim is NOT overwritten, so the conflict
// stays visible on every subsequent mutation.
const claim = readClaim(tmpDir);
assert.strictEqual(claim.phase, '1', `session A's claim must be left intact; got ${JSON.stringify(claim)}`);
assert.ok(claim.session.includes('session-a'), 'claim session must still be session A');
});
test('M3: same session re-targeting a different phase — no conflict, claim follows', () => {
const first = runGsdTools(
['state', 'begin-phase', '--phase', '1', '--name', 'setup', '--plans', '4'],
tmpDir,
{ ...SESSION_A, GSD_TEST_MODE: '1', GSD_NOW_MS: String(T0) },
);
assert.ok(first.success, `first begin-phase failed: ${first.error}`);
assert.ok(!JSON.parse(first.output).milestone_conflict, 'no conflict on fresh claim');
const second = runGsdTools(
['state', 'begin-phase', '--phase', '2', '--name', 'api', '--plans', '3'],
tmpDir,
{ ...SESSION_A, GSD_TEST_MODE: '1', GSD_NOW_MS: String(T0 + 1000) },
);
assert.ok(second.success, `second begin-phase failed: ${second.error}`);
const out = JSON.parse(second.output);
assert.ok(!out.milestone_conflict, `same session re-targeting must not conflict; got ${second.output}`);
const claim = readClaim(tmpDir);
assert.strictEqual(claim.phase, '2', `claim must follow the session's new phase; got ${JSON.stringify(claim)}`);
});
test('M4: different session, SAME phase — no conflict (keyed by phase)', () => {
const first = runGsdTools(
['state', 'begin-phase', '--phase', '1', '--name', 'setup', '--plans', '4'],
tmpDir,
{ ...SESSION_A, GSD_TEST_MODE: '1', GSD_NOW_MS: String(T0) },
);
assert.ok(first.success, `first begin-phase failed: ${first.error}`);
const second = runGsdTools(
['state', 'begin-phase', '--phase', '1', '--name', 'setup', '--plans', '4'],
tmpDir,
{ ...SESSION_B, GSD_TEST_MODE: '1', GSD_NOW_MS: String(T0 + 1000) },
);
assert.ok(second.success, `second begin-phase failed: ${second.error}`);
const out = JSON.parse(second.output);
assert.ok(!out.milestone_conflict, `same phase from another session must not conflict; got ${second.output}`);
});
test('M7: headless sessions (no session identity) still conflict across phases', () => {
// No session env at all — both invocations resolve a null session key.
// A null key must never count as "same session", or the headless parallel
// case (CI, cron) would be undetectable.
const first = runGsdTools(
['state', 'begin-phase', '--phase', '1', '--name', 'setup', '--plans', '4'],
tmpDir,
{ GSD_TEST_MODE: '1', GSD_NOW_MS: String(T0) },
);
assert.ok(first.success, `first begin-phase failed: ${first.error}`);
const second = runGsdTools(
['state', 'begin-phase', '--phase', '2', '--name', 'api', '--plans', '3'],
tmpDir,
{ GSD_TEST_MODE: '1', GSD_NOW_MS: String(T0 + 1000) },
);
assert.ok(second.success, `second begin-phase failed: ${second.error}`);
const out = JSON.parse(second.output);
assert.ok(
out.milestone_conflict && out.milestone_conflict.locked_phase === '1',
`headless cross-phase conflict must be detected; got ${second.output}`,
);
});
test('M8: a claim older than the TTL is stale — takeover without conflict', () => {
const first = runGsdTools(
['state', 'begin-phase', '--phase', '1', '--name', 'setup', '--plans', '4'],
tmpDir,
{ ...SESSION_A, GSD_TEST_MODE: '1', GSD_NOW_MS: String(T0) },
);
assert.ok(first.success, `first begin-phase failed: ${first.error}`);
// A week later, session B begins phase 2: the abandoned claim must not
// fire a false conflict.
const second = runGsdTools(
['state', 'begin-phase', '--phase', '2', '--name', 'api', '--plans', '3'],
tmpDir,
{ ...SESSION_B, GSD_TEST_MODE: '1', GSD_NOW_MS: String(WEEK_LATER) },
);
assert.ok(second.success, `second begin-phase failed: ${second.error}`);
const out = JSON.parse(second.output);
assert.ok(!out.milestone_conflict, `stale claim must not conflict; got ${second.output}`);
const claim = readClaim(tmpDir);
assert.strictEqual(claim.phase, '2', `stale claim must be taken over; got ${JSON.stringify(claim)}`);
assert.strictEqual(claim.updated_at, WEEK_LATER, 'takeover must re-stamp updated_at');
});
test('M11: a corrupt milestone.lock body is treated as no claim — no crash, no conflict', () => {
fs.writeFileSync(milestoneLockPath(tmpDir), 'not-json{garbage');
const result = runGsdTools(
['state', 'begin-phase', '--phase', '2', '--name', 'api', '--plans', '3'],
tmpDir,
{ ...SESSION_B, GSD_TEST_MODE: '1', GSD_NOW_MS: String(T0) },
);
assert.ok(result.success, `begin-phase failed on corrupt claim: ${result.error}`);
const out = JSON.parse(result.output);
assert.ok(!out.milestone_conflict, `corrupt claim must not conflict; got ${result.output}`);
const claim = readClaim(tmpDir);
assert.strictEqual(claim.phase, '2', `corrupt claim must be replaced; got ${JSON.stringify(claim)}`);
});
});
// ─────────────────────────────────────────────────────────────────────────────
// M5, M6 — advance-plan flip detection + heartbeat
// ─────────────────────────────────────────────────────────────────────────────
describe('#3311 milestone lock: advance-plan position checks', () => {
let tmpDir;
beforeEach(() => {
tmpDir = createTempProject();
});
afterEach(() => {
cleanup(tmpDir);
});
test('M5: position phase matching the claim advances silently and heartbeats the claim', () => {
fs.writeFileSync(path.join(tmpDir, '.planning', 'STATE.md'), BEGIN_PHASE_STATE_MD);
const begin = runGsdTools(
['state', 'begin-phase', '--phase', '1', '--name', 'setup', '--plans', '3'],
tmpDir,
{ ...SESSION_A, GSD_TEST_MODE: '1', GSD_NOW_MS: String(T0) },
);
assert.ok(begin.success, `begin-phase failed: ${begin.error}`);
const before = readClaim(tmpDir);
assert.strictEqual(before.updated_at, T0);
const adv = runGsdTools(
['state', 'advance-plan'],
tmpDir,
{ ...SESSION_A, GSD_TEST_MODE: '1', GSD_NOW_MS: String(T0 + 60_000) },
);
assert.ok(adv.success, `advance-plan failed: ${adv.error}`);
const out = JSON.parse(adv.output);
assert.ok(out.advanced === true, `advance-plan must advance; got ${adv.output}`);
assert.ok(!out.milestone_conflict, `matching claim must not conflict; got ${adv.output}`);
const after = readClaim(tmpDir);
assert.strictEqual(after.phase, '1', 'claim phase unchanged');
assert.strictEqual(
after.updated_at,
T0 + 60_000,
`claim must be heartbeat-refreshed; got ${JSON.stringify(after)}`,
);
});
test('M6: Current Position naming ANOTHER session\'s claimed phase is reported as a conflict', () => {
// Session A legitimately began phase 1 …
fs.writeFileSync(path.join(tmpDir, '.planning', 'STATE.md'), BEGIN_PHASE_STATE_MD);
const begin = runGsdTools(
['state', 'begin-phase', '--phase', '1', '--name', 'setup', '--plans', '3'],
tmpDir,
{ ...SESSION_A, GSD_TEST_MODE: '1', GSD_NOW_MS: String(T0) },
);
assert.ok(begin.success, `begin-phase failed: ${begin.error}`);
// … then session B's begin-phase clobbered the single Current Position
// slot to phase 2 (the #3311 flip). Session A's next advance-plan must
// SAY SO instead of silently advancing phase 2's plan counter.
fs.writeFileSync(path.join(tmpDir, '.planning', 'STATE.md'), ADVANCE_POSITION_PHASE_2);
const adv = runToolsWithStderr(
['state', 'advance-plan'],
tmpDir,
{ ...SESSION_A, GSD_TEST_MODE: '1', GSD_NOW_MS: String(T0 + 60_000) },
);
assert.strictEqual(adv.exitCode, 0, `advance-plan failed: ${adv.stderr}`);
const out = JSON.parse(adv.stdout);
assert.ok(
out.milestone_conflict && out.milestone_conflict.locked_phase === '1',
`position/claim mismatch must surface milestone_conflict; got ${adv.stdout}`,
);
assert.match(adv.stderr || '', /milestone/i, 'a visible stderr warning must accompany the conflict');
});
});
// ─────────────────────────────────────────────────────────────────────────────
// M9, M10 — phase.complete conflict warning + claim release
// ─────────────────────────────────────────────────────────────────────────────
describe('#3311 milestone lock: phase.complete', () => {
let tmpDir;
beforeEach(() => {
tmpDir = createTempProject();
writePhaseCompleteFixture(tmpDir);
});
afterEach(() => {
cleanup(tmpDir);
});
test('M9: completing a phase while a live claim holds a DIFFERENT phase warns via warnings[]', () => {
// Session A is mid-phase-1 (live claim) …
fs.writeFileSync(path.join(tmpDir, '.planning', 'milestone.lock'), JSON.stringify({
phase: '1',
session: 'gsd-session-key-session-a',
pid: 4242,
updated_at: Date.now(),
}));
// … and session B completes phase 2 in the same tree.
const result = runGsdTools(
['phase', 'complete', '2'],
tmpDir,
SESSION_B,
);
assert.ok(result.success, `phase complete failed: ${result.error}`);
const out = JSON.parse(result.output);
assert.ok(
Array.isArray(out.warnings) && out.warnings.some((w) => /milestone/i.test(w)),
`warnings[] must carry the milestone-lock conflict; got ${result.output}`,
);
assert.strictEqual(out.has_warnings, true, 'has_warnings must be true');
assert.ok(
out.milestone_conflict && out.milestone_conflict.locked_phase === '1',
`typed milestone_conflict must be present; got ${result.output}`,
);
// The live claim is left intact for the same reason as M2.
const claim = readClaim(tmpDir);
assert.strictEqual(claim.phase, '1', 'session A\'s claim must be left intact');
});
test('M10: completing the CLAIMED phase releases the claim', () => {
fs.writeFileSync(path.join(tmpDir, '.planning', 'milestone.lock'), JSON.stringify({
phase: '1',
session: 'gsd-session-key-session-a',
pid: 4242,
updated_at: Date.now(),
}));
const result = runGsdTools(['phase', 'complete', '1'], tmpDir, SESSION_A);
assert.ok(result.success, `phase complete failed: ${result.error}`);
const out = JSON.parse(result.output);
assert.ok(!out.milestone_conflict, `completing the claimed phase must not conflict; got ${result.output}`);
assert.ok(
!fs.existsSync(milestoneLockPath(tmpDir)),
'milestone.lock must be released when the claimed phase completes',
);
});
test('M10b: no claim file — phase.complete carries no conflict and creates no claim', () => {
const result = runGsdTools(['phase', 'complete', '1'], tmpDir, SESSION_A);
assert.ok(result.success, `phase complete failed: ${result.error}`);
const out = JSON.parse(result.output);
assert.ok(!out.milestone_conflict, `no claim must mean no conflict; got ${result.output}`);
assert.ok(
!Array.isArray(out.warnings) || !out.warnings.some((w) => /milestone/i.test(w)),
'no milestone warning without a claim',
);
assert.ok(!fs.existsSync(milestoneLockPath(tmpDir)), 'phase.complete must not create a claim');
});
});
// ─────────────────────────────────────────────────────────────────────────────
// G1 — lock-domain serialization guard (triage brief regression test 1)
// ─────────────────────────────────────────────────────────────────────────────
describe('#3311 guard: phase.complete does not lose a concurrent STATE.md write', () => {
test('G1: a STATE.md edit landing while phase.complete waits on STATE.md.lock survives', () => {
const tmpDir = createTempProject();
try {
writePhaseCompleteFixture(tmpDir);
const stateMod = require('../gsd-core/bin/lib/state.cjs');
const phaseMod = require('../gsd-core/bin/lib/phase.cjs');
const statePath = path.join(tmpDir, '.planning', 'STATE.md');
const lockPath = statePath + '.lock';
// Pre-hold STATE.md.lock the way a concurrent `state.advance-plan`
// mid-transform would: a body with a pid the liveness probe reports as
// VERIFIED-LIVE, so the phase.complete path must WAIT, not steal.
fs.writeFileSync(lockPath, '4242');
stateMod._setLockProbes({ isPidAlive: (pid) => pid === 4242 });
let injected = false;
stateMod._setStateLockTestHooks({
onLoopIteration(ctx) {
// Gate on iteration >= 1: the hook fires at the top of EVERY loop
// iteration, so iteration 0 alone proves nothing. Injecting only
// once the first open() has hit EEXIST proves the writer is
// actually HONORING the held lock rather than bypassing it.
if (ctx.iteration < 1 || injected) return;
injected = true;
// The "advance-plan write" lands on disk while phase.complete waits …
fs.writeFileSync(
statePath,
fs.readFileSync(statePath, 'utf-8') + '\n## Concurrent Notes\nsurvived-concurrent-writer\n',
);
// … then the concurrent holder releases.
fs.unlinkSync(lockPath);
},
});
// Capture fd-1 writes (io.output writes JSON straight to fd 1).
// Delegates to the shared, safe fd-capture helper (#4306) — see
// tests/helpers.cjs's captureFdSync.
const captured = [];
let threw = null;
try {
captured.push(captureFdSync(1, () => phaseMod.cmdPhaseComplete(tmpDir, '1', true)));
} catch (e) {
threw = e;
} finally {
stateMod._resetStateLockTestHooks();
stateMod._resetLockProbes();
}
assert.ok(!threw, `phase.complete must succeed under contention: ${threw && threw.message}`);
assert.ok(injected, 'the EEXIST contention path must have been reached (lock honored)');
const finalContent = fs.readFileSync(statePath, 'utf-8');
assert.ok(
finalContent.includes('survived-concurrent-writer'),
`the concurrent writer's STATE.md edit must NOT be lost; got:\n${finalContent}`,
);
// And the completion itself also landed (both writes present).
const payload = JSON.parse(captured.join('') || '{}');
assert.strictEqual(payload.completed_phase, '1', `completion result must be phase 1; got ${captured.join('')}`);
} finally {
cleanup(tmpDir);
}
});
});
// ─────────────────────────────────────────────────────────────────────────────
// G2 — targeted-field-replace guard (triage brief regression test 3)
// ─────────────────────────────────────────────────────────────────────────────
describe('#3311 guard: advancePlan stays a targeted field replace', () => {
test('G2: only Status / Last Activity / Plan lines change inside ## Current Position', () => {
const { transitionCore } = require('../gsd-core/bin/lib/state-transition.cjs');
const fixedClock = Object.freeze({
today: () => '2026-08-10',
localToday: () => '2026-08-10',
nowIso: () => '2026-08-10T12:00:00.000Z',
});
const before = [
'# Project State',
'',
'**Current Plan:** 1',
'**Total Plans in Phase:** 3',
'**Status:** Executing',
'**Last Activity:** 2026-08-09',
'',
'## Current Position',
'Phase: 2 of 5 (api)',
'Plan: 1 of 3 in current phase',
'Status: Executing',
// Bare date = handler-generated shape, so advancePlan's template-aware
// replace is permitted to update it (an executor-authored value like
// "2026-08-09 — began api work" is deliberately preserved instead).
'Last activity: 2026-08-09',
'Owner: alice',
'',
'## Next Steps',
'',
'Do the thing.',
'',
].join('\n');
const result = transitionCore(before, { kind: 'advancePlan' }, { clock: fixedClock, sourcePath: 'STATE.md' });
const after = result.content;
const section = (text) => {
const s = collectSection(text, (h) => /^Current Position$/i.test(h.text));
assert.ok(s, 'Current Position section must exist');
return s.body;
};
const beforeSection = section(before);
assert.ok(beforeSection.length > 0, 'before-section must be non-empty');
const afterSection = section(after);
// Untouched lines — the triage's "must not turn into a whole-block rewrite".
assert.match(afterSection, /^Phase: 2 of 5 \(api\)$/m, 'Phase line must be byte-identical');
assert.match(afterSection, /^Owner: alice$/m, 'unrelated custom line must be byte-identical');
assert.ok(
after.includes('## Next Steps\n\nDo the thing.'),
'the sibling ## Next Steps section must be untouched',
);
// Mutated lines.
assert.match(afterSection, /^Plan: 2 of 3/m, 'Plan line must advance');
assert.match(afterSection, /^Status: Ready to execute$/m, 'Status line must update');
assert.match(afterSection, /^Last activity: 2026-08-10/m, 'Last activity line must update');
});
});