test: add regression harness for auto-mode dispatch loop (125 assertions) (#1319)

This commit is contained in:
Tom Boucher 2026-03-18 23:14:59 -04:00 committed by GitHub
parent 805c7718c4
commit 68e0672dda
4 changed files with 1582 additions and 0 deletions

View file

@ -0,0 +1,691 @@
/**
* auto-dispatch-loop.test.ts End-to-end regression tests for the
* auto-mode dispatch loop: deriveState() resolveDispatch()
*
* Exercises the full state-machine chain WITHOUT an LLM. Each test
* creates a .gsd/ filesystem fixture, derives state, runs the dispatch
* table, and verifies the correct unit type/id is produced.
*
* Regression coverage for:
* #1270 Replaying completed run-uat units
* #1277 Non-artifact UATs dispatched, blocking progression
* #1241 Slice progression gated on file existence, not verdict content
* #909 Missing task plan files infinite plan-slice loop
* #807 Prose slice headers not parsed "No slice eligible" block
* #1248 Prose header regex only matched H2 with colon separator
* #1289 Crash recovery false-positive on own PID
* #1217 (orphaned processes tested via post-unit, not dispatch)
*
* Pattern: create fixture deriveState resolveDispatch assert
*/
import { mkdtempSync, mkdirSync, writeFileSync, rmSync, existsSync } from 'node:fs';
import { join } from 'node:path';
import { tmpdir } from 'node:os';
import { deriveState, invalidateStateCache } from '../state.ts';
import { resolveDispatch, type DispatchContext } from '../auto-dispatch.ts';
import { parseRoadmapSlices } from '../roadmap-slices.ts';
import { checkNeedsRunUat } from '../auto-prompts.ts';
import { checkIdempotency, type IdempotencyContext } from '../auto-idempotency.ts';
import { invalidateAllCaches } from '../cache.ts';
import { AutoSession } from '../auto/session.ts';
import { createTestContext } from './test-helpers.ts';
const { assertEq, assertTrue, assertMatch, report } = createTestContext();
// ═══════════════════════════════════════════════════════════════════════════
// Fixture Helpers
// ═══════════════════════════════════════════════════════════════════════════
function createBase(): string {
const base = mkdtempSync(join(tmpdir(), 'gsd-dispatch-loop-'));
mkdirSync(join(base, '.gsd', 'milestones'), { recursive: true });
return base;
}
function cleanup(base: string): void {
rmSync(base, { recursive: true, force: true });
}
function writeMilestoneFile(base: string, mid: string, suffix: string, content: string): void {
const dir = join(base, '.gsd', 'milestones', mid);
mkdirSync(dir, { recursive: true });
writeFileSync(join(dir, `${mid}-${suffix}.md`), content);
}
function writeSliceFile(base: string, mid: string, sid: string, suffix: string, content: string): void {
const dir = join(base, '.gsd', 'milestones', mid, 'slices', sid);
mkdirSync(dir, { recursive: true });
writeFileSync(join(dir, `${sid}-${suffix}.md`), content);
}
function writeTaskFile(base: string, mid: string, sid: string, tid: string, suffix: string, content: string): void {
const dir = join(base, '.gsd', 'milestones', mid, 'slices', sid, 'tasks');
mkdirSync(dir, { recursive: true });
writeFileSync(join(dir, `${tid}-${suffix}.md`), content);
}
/** Standard machine-readable roadmap with checkbox slices */
function standardRoadmap(mid: string, title: string, slices: Array<{ id: string; title: string; done: boolean; risk?: string; depends?: string[] }>): string {
const lines = [
`# ${mid}: ${title}`,
'',
'## Slices',
'',
];
for (const s of slices) {
const check = s.done ? 'x' : ' ';
const risk = s.risk ?? 'low';
const deps = s.depends ?? [];
lines.push(`- [${check}] **${s.id}: ${s.title}** \`risk:${risk}\` \`depends:[${deps.join(',')}]\``);
}
lines.push('', '## Boundary Map', '');
return lines.join('\n');
}
/** Standard slice plan with tasks */
function standardPlan(sid: string, title: string, tasks: Array<{ id: string; title: string; done: boolean; est?: string }>): string {
const lines = [
`# ${sid}: ${title}`,
'',
'## Tasks',
'',
];
for (const t of tasks) {
const check = t.done ? 'x' : ' ';
const est = t.est ?? '1h';
lines.push(`- [${check}] **${t.id}: ${t.title}** \`est:${est}\``);
}
return lines.join('\n');
}
function freshState(): void {
invalidateAllCaches();
invalidateStateCache();
}
async function dispatchFor(base: string): Promise<ReturnType<typeof resolveDispatch>> {
freshState();
const state = await deriveState(base);
const mid = state.activeMilestone?.id;
if (!mid) return { action: 'stop', reason: 'No active milestone', level: 'info' };
const midTitle = state.activeMilestone?.title ?? mid;
const ctx: DispatchContext = { basePath: base, mid, midTitle, state, prefs: undefined };
return resolveDispatch(ctx);
}
// ═══════════════════════════════════════════════════════════════════════════
// Tests
// ═══════════════════════════════════════════════════════════════════════════
async function main(): Promise<void> {
// ─── 1. Basic state derivation: pre-planning → plan-milestone ─────────
console.log('\n=== 1. pre-planning with context → plan-milestone (or research) ===');
{
const base = createBase();
try {
writeMilestoneFile(base, 'M001', 'CONTEXT', '# M001: Test Project\n\nBuild a thing.\n');
const result = await dispatchFor(base);
assertTrue(
result.action === 'dispatch',
'pre-planning with context dispatches a unit',
);
if (result.action === 'dispatch') {
assertTrue(
result.unitType === 'research-milestone' || result.unitType === 'plan-milestone',
`dispatches research-milestone or plan-milestone, got ${result.unitType}`,
);
assertEq(result.unitId, 'M001', 'unit ID is M001');
}
} finally {
cleanup(base);
}
}
// ─── 2. Planning → plan-slice ─────────────────────────────────────────
console.log('\n=== 2. has roadmap, no slice plan → plan-slice ===');
{
const base = createBase();
try {
writeMilestoneFile(base, 'M001', 'CONTEXT', '# M001: Test\n\nDesc.\n');
writeMilestoneFile(base, 'M001', 'ROADMAP', standardRoadmap('M001', 'Test', [
{ id: 'S01', title: 'First Slice', done: false },
{ id: 'S02', title: 'Second Slice', done: false, depends: ['S01'] },
]));
const result = await dispatchFor(base);
assertTrue(result.action === 'dispatch', 'planning phase dispatches');
if (result.action === 'dispatch') {
assertTrue(
result.unitType === 'plan-slice' || result.unitType === 'research-slice',
`dispatches plan-slice or research-slice, got ${result.unitType}`,
);
assertMatch(result.unitId, /M001\/S01/, 'targets S01');
}
} finally {
cleanup(base);
}
}
// ─── 3. Executing → execute-task ──────────────────────────────────────
console.log('\n=== 3. has plan with incomplete task → execute-task ===');
{
const base = createBase();
try {
writeMilestoneFile(base, 'M001', 'CONTEXT', '# M001\n\nDesc.\n');
writeMilestoneFile(base, 'M001', 'ROADMAP', standardRoadmap('M001', 'Test', [
{ id: 'S01', title: 'First Slice', done: false },
]));
writeSliceFile(base, 'M001', 'S01', 'PLAN', standardPlan('S01', 'First Slice', [
{ id: 'T01', title: 'First Task', done: false },
{ id: 'T02', title: 'Second Task', done: false },
]));
writeTaskFile(base, 'M001', 'S01', 'T01', 'PLAN', '# T01: First Task\n\nDo the thing.\n');
writeTaskFile(base, 'M001', 'S01', 'T02', 'PLAN', '# T02: Second Task\n\nDo more.\n');
const result = await dispatchFor(base);
assertTrue(result.action === 'dispatch', 'executing phase dispatches');
if (result.action === 'dispatch') {
assertEq(result.unitType, 'execute-task', 'dispatches execute-task');
assertEq(result.unitId, 'M001/S01/T01', 'targets T01');
}
} finally {
cleanup(base);
}
}
// ─── 4. All tasks done → complete-slice (summarizing) ─────────────────
console.log('\n=== 4. all tasks done → summarizing → complete-slice ===');
{
const base = createBase();
try {
writeMilestoneFile(base, 'M001', 'CONTEXT', '# M001\n\nDesc.\n');
writeMilestoneFile(base, 'M001', 'ROADMAP', standardRoadmap('M001', 'Test', [
{ id: 'S01', title: 'First Slice', done: false },
]));
writeSliceFile(base, 'M001', 'S01', 'PLAN', standardPlan('S01', 'First Slice', [
{ id: 'T01', title: 'First Task', done: true },
{ id: 'T02', title: 'Second Task', done: true },
]));
writeTaskFile(base, 'M001', 'S01', 'T01', 'PLAN', '# T01\nDone.');
writeTaskFile(base, 'M001', 'S01', 'T02', 'PLAN', '# T02\nDone.');
const result = await dispatchFor(base);
assertTrue(result.action === 'dispatch', 'summarizing phase dispatches');
if (result.action === 'dispatch') {
assertEq(result.unitType, 'complete-slice', 'dispatches complete-slice');
assertEq(result.unitId, 'M001/S01', 'targets S01');
}
} finally {
cleanup(base);
}
}
// ─── 5. Regression #909: Missing task plan files → plan-slice ─────────
console.log('\n=== 5. #909: tasks in plan but empty tasks/ dir → plan-slice (not stuck loop) ===');
{
const base = createBase();
try {
writeMilestoneFile(base, 'M001', 'CONTEXT', '# M001\n\nDesc.\n');
// Add milestone research so research-slice doesn't fire first
writeMilestoneFile(base, 'M001', 'RESEARCH', '# Research\n\nDone.\n');
writeMilestoneFile(base, 'M001', 'ROADMAP', standardRoadmap('M001', 'Test', [
{ id: 'S01', title: 'First Slice', done: false },
]));
// Also write slice research so research-slice is skipped
writeSliceFile(base, 'M001', 'S01', 'RESEARCH', '# Slice Research\n\nDone.\n');
// Plan references tasks but tasks/ dir has no files
writeSliceFile(base, 'M001', 'S01', 'PLAN', standardPlan('S01', 'First Slice', [
{ id: 'T01', title: 'First Task', done: false },
]));
// Create empty tasks directory (no task plan files)
mkdirSync(join(base, '.gsd', 'milestones', 'M001', 'slices', 'S01', 'tasks'), { recursive: true });
freshState();
const state = await deriveState(base);
// Should fall back to planning phase since tasks dir is empty
assertEq(state.phase, 'planning', '#909: empty tasks dir → planning phase (not executing)');
const result = await dispatchFor(base);
assertTrue(result.action === 'dispatch', '#909: dispatches');
if (result.action === 'dispatch') {
assertEq(result.unitType, 'plan-slice', '#909: dispatches plan-slice to regenerate task plans');
}
} finally {
cleanup(base);
}
}
// ─── 6. Regression #1277: Non-artifact UAT not dispatched ─────────────
console.log('\n=== 6. #1277: human-experience UAT → null (skip, not dispatch) ===');
{
const base = createBase();
try {
writeMilestoneFile(base, 'M001', 'CONTEXT', '# M001\n\nDesc.\n');
writeMilestoneFile(base, 'M001', 'ROADMAP', standardRoadmap('M001', 'Test', [
{ id: 'S01', title: 'Done Slice', done: true },
{ id: 'S02', title: 'Next Slice', done: false, depends: ['S01'] },
]));
writeSliceFile(base, 'M001', 'S01', 'UAT', '# UAT\n\n## UAT Type\n\n- UAT mode: human-experience\n');
const state = {
activeMilestone: { id: 'M001', title: 'Test' },
activeSlice: { id: 'S02', title: 'Next Slice' },
activeTask: null,
phase: 'planning',
recentDecisions: [],
blockers: [],
nextAction: 'Plan S02',
registry: [],
};
freshState();
const result = await checkNeedsRunUat(base, 'M001', state as any, { uat_dispatch: true } as any);
assertEq(result, null, '#1277: human-experience UAT returns null (not dispatched)');
} finally {
cleanup(base);
}
}
// ─── 7. Regression #1277: artifact-driven UAT without result → dispatch ──
console.log('\n=== 7. artifact-driven UAT without result → dispatch ===');
{
const base = createBase();
try {
writeMilestoneFile(base, 'M001', 'CONTEXT', '# M001\n\nDesc.\n');
writeMilestoneFile(base, 'M001', 'ROADMAP', standardRoadmap('M001', 'Test', [
{ id: 'S01', title: 'Done Slice', done: true },
{ id: 'S02', title: 'Next Slice', done: false, depends: ['S01'] },
]));
writeSliceFile(base, 'M001', 'S01', 'UAT', '# UAT\n\n## UAT Type\n\n- UAT mode: artifact-driven\n');
// No UAT-RESULT file
const state = {
activeMilestone: { id: 'M001', title: 'Test' },
activeSlice: { id: 'S02', title: 'Next Slice' },
activeTask: null,
phase: 'planning',
recentDecisions: [],
blockers: [],
nextAction: 'Plan S02',
registry: [],
};
freshState();
const result = await checkNeedsRunUat(base, 'M001', state as any, { uat_dispatch: true } as any);
assertTrue(result !== null, 'artifact-driven UAT without result → dispatch (not null)');
if (result) {
assertEq(result.sliceId, 'S01', 'targets S01');
}
} finally {
cleanup(base);
}
}
// ─── 8. Regression #1270: Existing UAT-RESULT never re-dispatches ─────
console.log('\n=== 8. #1270: UAT-RESULT exists → no re-dispatch (any verdict) ===');
{
const base = createBase();
try {
writeMilestoneFile(base, 'M001', 'CONTEXT', '# M001\n\nDesc.\n');
writeMilestoneFile(base, 'M001', 'ROADMAP', standardRoadmap('M001', 'Test', [
{ id: 'S01', title: 'Done Slice', done: true },
{ id: 'S02', title: 'Next Slice', done: false, depends: ['S01'] },
]));
writeSliceFile(base, 'M001', 'S01', 'UAT', '# UAT\n\n## UAT Type\n\n- UAT mode: artifact-driven\n');
writeSliceFile(base, 'M001', 'S01', 'UAT-RESULT', '---\nverdict: FAIL\n---\nFailed.\n');
const state = {
activeMilestone: { id: 'M001', title: 'Test' },
activeSlice: { id: 'S02', title: 'Next Slice' },
activeTask: null,
phase: 'planning',
recentDecisions: [],
blockers: [],
nextAction: 'Plan S02',
registry: [],
};
freshState();
const result = await checkNeedsRunUat(base, 'M001', state as any, { uat_dispatch: true } as any);
assertEq(result, null, '#1270: existing UAT-RESULT with FAIL → null (no re-dispatch)');
} finally {
cleanup(base);
}
}
// ─── 9. Regression #1241: UAT verdict gate blocks non-PASS ────────────
console.log('\n=== 9. #1241: UAT verdict gate blocks progression on non-PASS verdict ===');
{
const base = createBase();
try {
writeMilestoneFile(base, 'M001', 'CONTEXT', '# M001\n\nDesc.\n');
writeMilestoneFile(base, 'M001', 'ROADMAP', standardRoadmap('M001', 'Test', [
{ id: 'S01', title: 'Done Slice', done: true },
{ id: 'S02', title: 'Next Slice', done: false, depends: ['S01'] },
]));
writeSliceFile(base, 'M001', 'S01', 'PLAN', standardPlan('S01', 'Done Slice', [
{ id: 'T01', title: 'Task', done: true },
]));
writeSliceFile(base, 'M001', 'S01', 'UAT', '# UAT\n\n## UAT Type\n\n- UAT mode: artifact-driven\n');
writeSliceFile(base, 'M001', 'S01', 'UAT-RESULT', '---\nverdict: FAIL\n---\nFailed some check.\n');
freshState();
const state = await deriveState(base);
const ctx: DispatchContext = {
basePath: base,
mid: 'M001',
midTitle: 'Test',
state,
prefs: { uat_dispatch: true } as any,
};
const result = await resolveDispatch(ctx);
// The uat-verdict-gate rule should stop progression
assertEq(result.action, 'stop', '#1241: non-PASS verdict → stop (blocks progression)');
} finally {
cleanup(base);
}
}
// ─── 10. #1241: UAT verdict PASS allows progression ───────────────────
console.log('\n=== 10. UAT verdict PASS → allows progression ===');
{
const base = createBase();
try {
writeMilestoneFile(base, 'M001', 'CONTEXT', '# M001\n\nDesc.\n');
writeMilestoneFile(base, 'M001', 'ROADMAP', standardRoadmap('M001', 'Test', [
{ id: 'S01', title: 'Done Slice', done: true },
{ id: 'S02', title: 'Next Slice', done: false, depends: ['S01'] },
]));
writeSliceFile(base, 'M001', 'S01', 'UAT', '# UAT\n\n## UAT Type\n\n- UAT mode: artifact-driven\n');
writeSliceFile(base, 'M001', 'S01', 'UAT-RESULT', '---\nverdict: PASS\n---\nAll good.\n');
freshState();
const state = await deriveState(base);
const ctx: DispatchContext = {
basePath: base,
mid: 'M001',
midTitle: 'Test',
state,
prefs: { uat_dispatch: true } as any,
};
const result = await resolveDispatch(ctx);
// PASS verdict should NOT block — dispatch should continue to plan-slice for S02
assertTrue(result.action !== 'stop' || !('reason' in result && result.reason.includes('verdict')), 'PASS verdict does not block progression');
} finally {
cleanup(base);
}
}
// ─── 11. Complete state derivation: all slices done → completing ───────
console.log('\n=== 11. all slices done, no validation → validating-milestone ===');
{
const base = createBase();
try {
writeMilestoneFile(base, 'M001', 'CONTEXT', '# M001\n\nDesc.\n');
writeMilestoneFile(base, 'M001', 'ROADMAP', standardRoadmap('M001', 'Test', [
{ id: 'S01', title: 'First Slice', done: true },
]));
freshState();
const state = await deriveState(base);
assertEq(state.phase, 'validating-milestone', 'all slices done → validating-milestone');
} finally {
cleanup(base);
}
}
// ─── 12. Complete milestone → complete phase ──────────────────────────
console.log('\n=== 12. validated + summarized milestone → complete ===');
{
const base = createBase();
try {
writeMilestoneFile(base, 'M001', 'CONTEXT', '# M001\n\nDesc.\n');
writeMilestoneFile(base, 'M001', 'ROADMAP', standardRoadmap('M001', 'Test', [
{ id: 'S01', title: 'First Slice', done: true },
]));
writeMilestoneFile(base, 'M001', 'VALIDATION', '---\nverdict: pass\nremediation_round: 0\n---\n# Validation\nAll good.\n');
writeMilestoneFile(base, 'M001', 'SUMMARY', '---\nstatus: complete\n---\n# Summary\nDone.\n');
freshState();
const state = await deriveState(base);
assertEq(state.phase, 'complete', 'validated+summarized → complete');
} finally {
cleanup(base);
}
}
// ─── 13. Multi-milestone: M001 complete, M002 active ─────────────────
console.log('\n=== 13. multi-milestone: M001 complete, M002 becomes active ===');
{
const base = createBase();
try {
// M001 — complete
writeMilestoneFile(base, 'M001', 'CONTEXT', '# M001\n\nDone.\n');
writeMilestoneFile(base, 'M001', 'ROADMAP', standardRoadmap('M001', 'First', [
{ id: 'S01', title: 'Slice', done: true },
]));
writeMilestoneFile(base, 'M001', 'VALIDATION', '---\nverdict: pass\nremediation_round: 0\n---\n');
writeMilestoneFile(base, 'M001', 'SUMMARY', '---\nstatus: complete\n---\n# Summary\n');
// M002 — active
writeMilestoneFile(base, 'M002', 'CONTEXT', '# M002\n\nNext.\n');
freshState();
const state = await deriveState(base);
assertEq(state.activeMilestone?.id, 'M002', 'M002 is the active milestone');
assertEq(state.phase, 'pre-planning', 'M002 is in pre-planning');
assertEq(state.registry.length, 2, 'registry has 2 milestones');
assertEq(state.registry[0].status, 'complete', 'M001 is complete');
assertEq(state.registry[1].status, 'active', 'M002 is active');
} finally {
cleanup(base);
}
}
// ─── 14. Dependency blocking: S02 depends on S01 ─────────────────────
console.log('\n=== 14. slice dependency: S02 blocked until S01 done ===');
{
const base = createBase();
try {
writeMilestoneFile(base, 'M001', 'CONTEXT', '# M001\n\nDesc.\n');
writeMilestoneFile(base, 'M001', 'ROADMAP', standardRoadmap('M001', 'Test', [
{ id: 'S01', title: 'First', done: false },
{ id: 'S02', title: 'Second', done: false, depends: ['S01'] },
]));
freshState();
const state = await deriveState(base);
// Active slice should be S01, not S02
assertEq(state.activeSlice?.id, 'S01', 'S01 is the active slice (S02 is dep-blocked)');
} finally {
cleanup(base);
}
}
// ─── 15. Blocker detection: task with blocker_discovered → replan ─────
console.log('\n=== 15. blocker_discovered in task summary → replanning-slice ===');
{
const base = createBase();
try {
writeMilestoneFile(base, 'M001', 'CONTEXT', '# M001\n\nDesc.\n');
writeMilestoneFile(base, 'M001', 'ROADMAP', standardRoadmap('M001', 'Test', [
{ id: 'S01', title: 'Slice', done: false },
]));
writeSliceFile(base, 'M001', 'S01', 'PLAN', standardPlan('S01', 'Slice', [
{ id: 'T01', title: 'Task One', done: true },
{ id: 'T02', title: 'Task Two', done: false },
]));
writeTaskFile(base, 'M001', 'S01', 'T01', 'PLAN', '# T01\nDo thing.');
writeTaskFile(base, 'M001', 'S01', 'T02', 'PLAN', '# T02\nDo other thing.');
writeTaskFile(base, 'M001', 'S01', 'T01', 'SUMMARY', '---\nblocker_discovered: true\n---\n# T01 Summary\nFound a blocker.');
freshState();
const state = await deriveState(base);
assertEq(state.phase, 'replanning-slice', 'blocker_discovered → replanning-slice');
} finally {
cleanup(base);
}
}
// ─── 16. Blocker + REPLAN exists → loop protection, resume executing ──
console.log('\n=== 16. blocker_discovered + REPLAN exists → loop protection (executing) ===');
{
const base = createBase();
try {
writeMilestoneFile(base, 'M001', 'CONTEXT', '# M001\n\nDesc.\n');
writeMilestoneFile(base, 'M001', 'ROADMAP', standardRoadmap('M001', 'Test', [
{ id: 'S01', title: 'Slice', done: false },
]));
writeSliceFile(base, 'M001', 'S01', 'PLAN', standardPlan('S01', 'Slice', [
{ id: 'T01', title: 'Task One', done: true },
{ id: 'T02', title: 'Task Two', done: false },
]));
writeTaskFile(base, 'M001', 'S01', 'T01', 'PLAN', '# T01\nDo thing.');
writeTaskFile(base, 'M001', 'S01', 'T02', 'PLAN', '# T02\nDo other thing.');
writeTaskFile(base, 'M001', 'S01', 'T01', 'SUMMARY', '---\nblocker_discovered: true\n---\n# T01\nBlocker.');
// REPLAN.md exists → loop protection
writeSliceFile(base, 'M001', 'S01', 'REPLAN', '# Replan\nAlready replanned.\n');
freshState();
const state = await deriveState(base);
assertEq(state.phase, 'executing', 'blocker + REPLAN exists → executing (loop protection)');
} finally {
cleanup(base);
}
}
// ─── 17. Needs-discussion phase ───────────────────────────────────────
console.log('\n=== 17. CONTEXT-DRAFT without CONTEXT → needs-discussion ===');
{
const base = createBase();
try {
const mDir = join(base, '.gsd', 'milestones', 'M001');
mkdirSync(mDir, { recursive: true });
writeFileSync(join(mDir, 'M001-CONTEXT-DRAFT.md'), '# Draft\n\nSome rough ideas.\n');
freshState();
const state = await deriveState(base);
assertEq(state.phase, 'needs-discussion', 'CONTEXT-DRAFT without CONTEXT → needs-discussion');
} finally {
cleanup(base);
}
}
// ─── 18. Idempotency: completed key → skip ───────────────────────────
console.log('\n=== 18. idempotency: completed key → skip ===');
{
const base = createBase();
try {
writeMilestoneFile(base, 'M001', 'CONTEXT', '# M001\n');
writeMilestoneFile(base, 'M001', 'ROADMAP', standardRoadmap('M001', 'Test', [
{ id: 'S01', title: 'Slice', done: false },
]));
// Task must be marked [x] in the plan for verifyExpectedArtifact to return true
writeSliceFile(base, 'M001', 'S01', 'PLAN', standardPlan('S01', 'Slice', [
{ id: 'T01', title: 'Task', done: true },
{ id: 'T02', title: 'Next Task', done: false },
]));
writeTaskFile(base, 'M001', 'S01', 'T01', 'PLAN', '# T01\nDo thing.');
writeTaskFile(base, 'M001', 'S01', 'T02', 'PLAN', '# T02\nNext.');
// Write SUMMARY as the expected artifact for execute-task
writeTaskFile(base, 'M001', 'S01', 'T01', 'SUMMARY', '---\nstatus: done\n---\n# T01 Summary\nDone.');
// Force cache clearance so verifyExpectedArtifact finds the file
freshState();
const session = new AutoSession();
session.basePath = base;
session.completedKeySet.add('execute-task/M001/S01/T01');
const notifications: string[] = [];
const result = checkIdempotency({
s: session,
unitType: 'execute-task',
unitId: 'M001/S01/T01',
basePath: base,
notify: (msg) => notifications.push(msg),
});
assertEq(result.action, 'skip', 'completed key → skip');
assertTrue('reason' in result && result.reason === 'completed', 'reason is completed');
} finally {
cleanup(base);
}
}
// ─── 19. Idempotency: stale key (artifact missing) → rerun ───────────
console.log('\n=== 19. idempotency: stale key (no artifact) → rerun ===');
{
const base = createBase();
try {
writeMilestoneFile(base, 'M001', 'CONTEXT', '# M001\n');
writeMilestoneFile(base, 'M001', 'ROADMAP', standardRoadmap('M001', 'Test', [
{ id: 'S01', title: 'Slice', done: false },
]));
writeSliceFile(base, 'M001', 'S01', 'PLAN', standardPlan('S01', 'Slice', [
{ id: 'T01', title: 'Task', done: false },
]));
writeTaskFile(base, 'M001', 'S01', 'T01', 'PLAN', '# T01\nDo thing.');
// NO summary file — artifact missing
const session = new AutoSession();
session.basePath = base;
session.completedKeySet.add('execute-task/M001/S01/T01');
const notifications: string[] = [];
const result = checkIdempotency({
s: session,
unitType: 'execute-task',
unitId: 'M001/S01/T01',
basePath: base,
notify: (msg) => notifications.push(msg),
});
assertEq(result.action, 'rerun', 'stale key (no artifact) → rerun');
assertTrue(!session.completedKeySet.has('execute-task/M001/S01/T01'), 'stale key removed from set');
} finally {
cleanup(base);
}
}
// ─── 20. Idempotency: consecutive skip loop → evict ──────────────────
console.log('\n=== 20. idempotency: consecutive skip loop → evict ===');
{
const base = createBase();
try {
writeMilestoneFile(base, 'M001', 'CONTEXT', '# M001\n');
writeTaskFile(base, 'M001', 'S01', 'T01', 'SUMMARY', '---\nstatus: done\n---\n# Done');
const session = new AutoSession();
session.basePath = base;
session.completedKeySet.add('execute-task/M001/S01/T01');
// Pre-fill skip count to just below threshold
session.unitConsecutiveSkips.set('execute-task/M001/S01/T01', 3);
const notifications: string[] = [];
const result = checkIdempotency({
s: session,
unitType: 'execute-task',
unitId: 'M001/S01/T01',
basePath: base,
notify: (msg) => notifications.push(msg),
});
assertEq(result.action, 'skip', 'exceeds consecutive skip threshold → skip with eviction');
assertTrue('reason' in result && result.reason === 'evicted', 'reason is evicted');
assertTrue(!session.completedKeySet.has('execute-task/M001/S01/T01'), 'key evicted from completed set');
assertTrue(session.recentlyEvictedKeys.has('execute-task/M001/S01/T01'), 'key tracked in evicted set');
} finally {
cleanup(base);
}
}
report();
}
main().catch((error) => {
console.error(error);
process.exit(1);
});

View file

@ -0,0 +1,317 @@
/**
* cache-staleness-regression.test.ts Regression tests for stale cache bugs.
*
* The GSD parser caches are critical for performance but have caused multiple
* production bugs when not invalidated at the right time.
*
* Regression coverage for:
* #1249 Stale caches in discuss loop slice appears "not discussed"
* #1240 Stale caches after milestone creation "No roadmap yet"
* #1236 Same root cause as #1240
*
* Pattern: derive state write file invalidate cache derive again verify update
*/
import { mkdtempSync, mkdirSync, writeFileSync, rmSync, existsSync } from 'node:fs';
import { join } from 'node:path';
import { tmpdir } from 'node:os';
import { deriveState, invalidateStateCache } from '../state.ts';
import { invalidateAllCaches } from '../cache.ts';
import { createTestContext } from './test-helpers.ts';
const { assertEq, assertTrue, report } = createTestContext();
function createBase(): string {
const base = mkdtempSync(join(tmpdir(), 'gsd-cache-stale-'));
mkdirSync(join(base, '.gsd', 'milestones'), { recursive: true });
return base;
}
function cleanup(base: string): void {
rmSync(base, { recursive: true, force: true });
}
function writeMilestoneFile(base: string, mid: string, suffix: string, content: string): void {
const dir = join(base, '.gsd', 'milestones', mid);
mkdirSync(dir, { recursive: true });
writeFileSync(join(dir, `${mid}-${suffix}.md`), content);
}
function writeSliceFile(base: string, mid: string, sid: string, suffix: string, content: string): void {
const dir = join(base, '.gsd', 'milestones', mid, 'slices', sid);
mkdirSync(dir, { recursive: true });
writeFileSync(join(dir, `${sid}-${suffix}.md`), content);
}
async function main(): Promise<void> {
// ─── 1. Regression #1240: New roadmap detected after cache invalidation ─
console.log('\n=== 1. #1240: roadmap written after first derive → detected after invalidation ===');
{
const base = createBase();
try {
// Step 1: Create milestone with just context (no roadmap)
writeMilestoneFile(base, 'M001', 'CONTEXT', '# M001: Test\n\nBuild a thing.\n');
invalidateAllCaches();
invalidateStateCache();
const state1 = await deriveState(base);
assertEq(state1.phase, 'pre-planning', 'initial: pre-planning (no roadmap)');
// Step 2: Write roadmap (simulating what the LLM does during planning)
const roadmap = [
'# M001: Test',
'',
'## Slices',
'',
'- [ ] **S01: First Slice** `risk:low` `depends:[]`',
'',
'## Boundary Map',
'',
].join('\n');
writeMilestoneFile(base, 'M001', 'ROADMAP', roadmap);
// Step 3: WITHOUT invalidation, the old state might be cached
// The state cache has a 100ms TTL, so wait just past it
await new Promise(r => setTimeout(r, 150));
// Step 4: Invalidate and re-derive — should see the new roadmap
invalidateAllCaches();
invalidateStateCache();
const state2 = await deriveState(base);
assertEq(state2.phase, 'planning', '#1240: after roadmap write + invalidation → planning phase');
assertEq(state2.activeSlice?.id, 'S01', '#1240: S01 is now the active slice');
} finally {
cleanup(base);
}
}
// ─── 2. Regression #1249: Slice context detected after cache invalidation ─
console.log('\n=== 2. #1249: slice context written mid-loop → detected after invalidation ===');
{
const base = createBase();
try {
// Create a milestone in needs-discussion phase (CONTEXT-DRAFT, no CONTEXT)
const mDir = join(base, '.gsd', 'milestones', 'M001');
mkdirSync(mDir, { recursive: true });
writeFileSync(join(mDir, 'M001-CONTEXT-DRAFT.md'), '# Draft\n\nSome ideas.\n');
invalidateAllCaches();
invalidateStateCache();
const state1 = await deriveState(base);
assertEq(state1.phase, 'needs-discussion', 'initial: needs-discussion');
// Simulate: discussion completes, CONTEXT.md is written
writeMilestoneFile(base, 'M001', 'CONTEXT', '# M001: Test\n\nFull context after discussion.\n');
// Wait past TTL
await new Promise(r => setTimeout(r, 150));
// Without invalidation, we'd still see 'needs-discussion'
invalidateAllCaches();
invalidateStateCache();
const state2 = await deriveState(base);
// Should now be pre-planning (has context, but no roadmap yet)
// Actually needs-discussion won't trigger because now CONTEXT exists
// The state should advance past needs-discussion
assertTrue(
state2.phase !== 'needs-discussion',
'#1249: after context write + invalidation → not stuck in needs-discussion',
);
} finally {
cleanup(base);
}
}
// ─── 3. State cache TTL expires naturally ─────────────────────────────
console.log('\n=== 3. state cache TTL: fresh reads after 100ms ===');
{
const base = createBase();
try {
writeMilestoneFile(base, 'M001', 'CONTEXT', '# M001\n\nDesc.\n');
invalidateAllCaches();
invalidateStateCache();
const state1 = await deriveState(base);
assertEq(state1.phase, 'pre-planning', 'initial: pre-planning');
// Write roadmap immediately
writeMilestoneFile(base, 'M001', 'ROADMAP', [
'# M001: Test',
'',
'## Slices',
'',
'- [ ] **S01: Slice** `risk:low` `depends:[]`',
'',
].join('\n'));
// Immediately after writing (within 100ms TTL), the cache might be stale
const state2 = await deriveState(base);
// This MAY still show pre-planning if within TTL — that's expected behavior
// Wait past TTL
await new Promise(r => setTimeout(r, 150));
// ALSO invalidate parse cache (not just state cache)
invalidateAllCaches();
invalidateStateCache();
const state3 = await deriveState(base);
assertEq(state3.phase, 'planning', 'after TTL expiry + invalidation → planning');
} finally {
cleanup(base);
}
}
// ─── 4. Task completion detection after file write ────────────────────
console.log('\n=== 4. task marked done in plan → state advances ===');
{
const base = createBase();
try {
writeMilestoneFile(base, 'M001', 'CONTEXT', '# M001\n\nDesc.\n');
writeMilestoneFile(base, 'M001', 'ROADMAP', [
'# M001: Test',
'',
'## Slices',
'',
'- [ ] **S01: Slice** `risk:low` `depends:[]`',
'',
].join('\n'));
writeSliceFile(base, 'M001', 'S01', 'PLAN', [
'# S01: Slice',
'',
'## Tasks',
'',
'- [ ] **T01: First Task** `est:1h`',
'- [ ] **T02: Second Task** `est:1h`',
].join('\n'));
// Write task plan files
const tasksDir = join(base, '.gsd', 'milestones', 'M001', 'slices', 'S01', 'tasks');
mkdirSync(tasksDir, { recursive: true });
writeFileSync(join(tasksDir, 'T01-PLAN.md'), '# T01\nDo thing.');
writeFileSync(join(tasksDir, 'T02-PLAN.md'), '# T02\nDo other thing.');
invalidateAllCaches();
invalidateStateCache();
const state1 = await deriveState(base);
assertEq(state1.activeTask?.id, 'T01', 'initial: T01 is active task');
// Mark T01 as done by rewriting the plan
writeSliceFile(base, 'M001', 'S01', 'PLAN', [
'# S01: Slice',
'',
'## Tasks',
'',
'- [x] **T01: First Task** `est:1h`',
'- [ ] **T02: Second Task** `est:1h`',
].join('\n'));
await new Promise(r => setTimeout(r, 150));
invalidateAllCaches();
invalidateStateCache();
const state2 = await deriveState(base);
assertEq(state2.activeTask?.id, 'T02', 'after T01 done → T02 is active task');
} finally {
cleanup(base);
}
}
// ─── 5. Slice completion detection ────────────────────────────────────
console.log('\n=== 5. all tasks done → summarizing phase ===');
{
const base = createBase();
try {
writeMilestoneFile(base, 'M001', 'CONTEXT', '# M001\n\nDesc.\n');
writeMilestoneFile(base, 'M001', 'ROADMAP', [
'# M001: Test',
'',
'## Slices',
'',
'- [ ] **S01: First** `risk:low` `depends:[]`',
'- [ ] **S02: Second** `risk:low` `depends:[S01]`',
'',
].join('\n'));
writeSliceFile(base, 'M001', 'S01', 'PLAN', [
'# S01',
'',
'## Tasks',
'',
'- [ ] **T01: Task** `est:1h`',
].join('\n'));
const tasksDir = join(base, '.gsd', 'milestones', 'M001', 'slices', 'S01', 'tasks');
mkdirSync(tasksDir, { recursive: true });
writeFileSync(join(tasksDir, 'T01-PLAN.md'), '# T01\nDo it.');
invalidateAllCaches();
invalidateStateCache();
const state1 = await deriveState(base);
assertEq(state1.phase, 'executing', 'initial: executing');
// Mark task done
writeSliceFile(base, 'M001', 'S01', 'PLAN', [
'# S01',
'',
'## Tasks',
'',
'- [x] **T01: Task** `est:1h`',
].join('\n'));
await new Promise(r => setTimeout(r, 150));
invalidateAllCaches();
invalidateStateCache();
const state2 = await deriveState(base);
assertEq(state2.phase, 'summarizing', 'after all tasks done → summarizing');
} finally {
cleanup(base);
}
}
// ─── 6. Roadmap slice marked done → advance to next slice ─────────────
console.log('\n=== 6. roadmap slice marked [x] → next slice active ===');
{
const base = createBase();
try {
writeMilestoneFile(base, 'M001', 'CONTEXT', '# M001\n\nDesc.\n');
writeMilestoneFile(base, 'M001', 'ROADMAP', [
'# M001: Test',
'',
'## Slices',
'',
'- [ ] **S01: First** `risk:low` `depends:[]`',
'- [ ] **S02: Second** `risk:low` `depends:[S01]`',
'',
].join('\n'));
invalidateAllCaches();
invalidateStateCache();
const state1 = await deriveState(base);
assertEq(state1.activeSlice?.id, 'S01', 'initial: S01 active');
// Mark S01 as done in roadmap
writeMilestoneFile(base, 'M001', 'ROADMAP', [
'# M001: Test',
'',
'## Slices',
'',
'- [x] **S01: First** `risk:low` `depends:[]`',
'- [ ] **S02: Second** `risk:low` `depends:[S01]`',
'',
].join('\n'));
await new Promise(r => setTimeout(r, 150));
invalidateAllCaches();
invalidateStateCache();
const state2 = await deriveState(base);
assertEq(state2.activeSlice?.id, 'S02', 'after S01 done → S02 active');
} finally {
cleanup(base);
}
}
report();
}
main().catch((error) => {
console.error(error);
process.exit(1);
});

View file

@ -0,0 +1,358 @@
/**
* roadmap-parse-regression.test.ts Regression tests for roadmap parsing.
*
* Exercises parseRoadmapSlices() and the prose fallback parser against
* every known LLM-generated roadmap variant that has caused production bugs.
*
* Regression coverage for:
* #807 Prose slice headers not parsed "No slice eligible" block
* #1248 Prose header regex only matched H2 with colon separator
* #1243 Same root cause as #1248
*
* Also covers dependency expansion (range syntax) and edge cases.
*/
import { parseRoadmapSlices, expandDependencies } from '../roadmap-slices.ts';
import { createTestContext } from './test-helpers.ts';
const { assertEq, assertTrue, report } = createTestContext();
async function main(): Promise<void> {
// ═══════════════════════════════════════════════════════════════════════
// A. Standard machine-readable format (should always work)
// ═══════════════════════════════════════════════════════════════════════
console.log('\n=== A. Standard checkbox format ===');
{
const content = [
'# M001: Test Project',
'',
'## Slices',
'',
'- [ ] **S01: First Slice** `risk:low` `depends:[]`',
'- [ ] **S02: Second Slice** `risk:medium` `depends:[S01]`',
'- [x] **S03: Third Slice** `risk:high` `depends:[S01,S02]`',
'',
'## Boundary Map',
'',
].join('\n');
const slices = parseRoadmapSlices(content);
assertEq(slices.length, 3, 'standard format: 3 slices');
assertEq(slices[0].id, 'S01', 'S01 id');
assertEq(slices[0].title, 'First Slice', 'S01 title');
assertEq(slices[0].done, false, 'S01 not done');
assertEq(slices[0].risk, 'low', 'S01 risk');
assertEq(slices[0].depends.length, 0, 'S01 no deps');
assertEq(slices[1].id, 'S02', 'S02 id');
assertEq(slices[1].depends.length, 1, 'S02 has 1 dep');
assertEq(slices[1].depends[0], 'S01', 'S02 depends on S01');
assertEq(slices[2].id, 'S03', 'S03 id');
assertEq(slices[2].done, true, 'S03 is done');
assertEq(slices[2].risk, 'high', 'S03 risk');
assertEq(slices[2].depends.length, 2, 'S03 has 2 deps');
}
// ═══════════════════════════════════════════════════════════════════════
// B. Prose fallback: H2 with colon (the only format the old regex matched)
// ═══════════════════════════════════════════════════════════════════════
console.log('\n=== B. Prose fallback: H2 with colon ===');
{
const content = [
'# M001: Test',
'',
'## S01: Setup Foundation',
'',
'Do the setup work.',
'',
'## S02: Core Features',
'',
'Build the features.',
'',
].join('\n');
const slices = parseRoadmapSlices(content);
assertEq(slices.length, 2, 'prose H2 colon: 2 slices');
assertEq(slices[0].id, 'S01', 'S01 id');
assertEq(slices[0].title, 'Setup Foundation', 'S01 title');
assertEq(slices[1].id, 'S02', 'S02 id');
assertEq(slices[1].title, 'Core Features', 'S02 title');
}
// ═══════════════════════════════════════════════════════════════════════
// C. Regression #1248: H3 headers (the old regex only matched ##)
// ═══════════════════════════════════════════════════════════════════════
console.log('\n=== C. #1248: H3 headers ===');
{
const content = [
'# M001: Test',
'',
'### S01: Setup Foundation',
'',
'Do the setup work.',
'',
'### S02: Core Features',
'',
'Build the features.',
'',
].join('\n');
const slices = parseRoadmapSlices(content);
assertEq(slices.length, 2, '#1248 H3: 2 slices parsed');
assertEq(slices[0].id, 'S01', 'S01 from H3');
assertEq(slices[1].id, 'S02', 'S02 from H3');
}
// ═══════════════════════════════════════════════════════════════════════
// D. Regression #1248: H4 headers
// ═══════════════════════════════════════════════════════════════════════
console.log('\n=== D. #1248: H4 headers ===');
{
const content = [
'# M001: Test',
'',
'#### S01: Setup Foundation',
'',
'#### S02: Core Features',
'',
].join('\n');
const slices = parseRoadmapSlices(content);
assertEq(slices.length, 2, '#1248 H4: 2 slices parsed');
}
// ═══════════════════════════════════════════════════════════════════════
// E. Regression #1248: H1 header (unusual but LLMs produce it)
// ═══════════════════════════════════════════════════════════════════════
console.log('\n=== E. #1248: H1 headers ===');
{
const content = [
'# S01: Setup Foundation',
'',
'Setup stuff.',
'',
'# S02: Core Features',
'',
'Build stuff.',
'',
].join('\n');
const slices = parseRoadmapSlices(content);
assertEq(slices.length, 2, '#1248 H1: 2 slices parsed');
}
// ═══════════════════════════════════════════════════════════════════════
// F. Regression #1248: Bold-wrapped IDs
// ═══════════════════════════════════════════════════════════════════════
console.log('\n=== F. #1248: Bold-wrapped ===');
{
const content1 = '## **S01: Setup Foundation**\n\nDo stuff.\n\n## **S02: Features**\n\nMore stuff.\n';
const slices1 = parseRoadmapSlices(content1);
assertEq(slices1.length, 2, 'bold-wrapped: 2 slices');
assertEq(slices1[0].title, 'Setup Foundation', 'bold-wrapped: title extracted without bold');
const content2 = '## **S01**: Setup Foundation\n\n## **S02**: Features\n';
const slices2 = parseRoadmapSlices(content2);
assertEq(slices2.length, 2, 'bold ID only: 2 slices');
}
// ═══════════════════════════════════════════════════════════════════════
// G. Regression #1248: Dot separator
// ═══════════════════════════════════════════════════════════════════════
console.log('\n=== G. #1248: Dot separator ===');
{
const content = '## S01. Setup Foundation\n\n## S02. Core Features\n';
const slices = parseRoadmapSlices(content);
assertEq(slices.length, 2, 'dot separator: 2 slices');
assertEq(slices[0].title, 'Setup Foundation', 'dot separator: title');
}
// ═══════════════════════════════════════════════════════════════════════
// H. Regression #1248: Em dash separator
// ═══════════════════════════════════════════════════════════════════════
console.log('\n=== H. #1248: Em/en dash separators ===');
{
const content = '## S01 — Setup Foundation\n\n## S02 Core Features\n';
const slices = parseRoadmapSlices(content);
assertEq(slices.length, 2, 'em/en dash: 2 slices');
}
// ═══════════════════════════════════════════════════════════════════════
// I. Regression #1248: Space-only separator (no punctuation)
// ═══════════════════════════════════════════════════════════════════════
console.log('\n=== I. #1248: Space-only separator ===');
{
const content = '## S01 Setup Foundation\n\n## S02 Core Features\n';
const slices = parseRoadmapSlices(content);
assertEq(slices.length, 2, 'space-only: 2 slices');
assertEq(slices[0].title, 'Setup Foundation', 'space-only: title');
}
// ═══════════════════════════════════════════════════════════════════════
// J. Regression #1248: Non-zero-padded IDs
// ═══════════════════════════════════════════════════════════════════════
console.log('\n=== J. #1248: Non-zero-padded IDs ===');
{
const content = '## S1: Setup\n\n## S2: Features\n';
const slices = parseRoadmapSlices(content);
assertEq(slices.length, 2, 'non-padded: 2 slices');
assertEq(slices[0].id, 'S1', 'non-padded: S1');
}
// ═══════════════════════════════════════════════════════════════════════
// K. Regression #1248: "Slice" prefix
// ═══════════════════════════════════════════════════════════════════════
console.log('\n=== K. #1248: "Slice" prefix ===');
{
const content = '## Slice S01: Setup Foundation\n\n## Slice S02: Core Features\n';
const slices = parseRoadmapSlices(content);
assertEq(slices.length, 2, 'Slice prefix: 2 slices');
assertEq(slices[0].id, 'S01', 'Slice prefix: S01');
}
// ═══════════════════════════════════════════════════════════════════════
// L. Prose with "Depends on:" line
// ═══════════════════════════════════════════════════════════════════════
console.log('\n=== L. Prose with Depends on: ===');
{
const content = [
'## S01: Foundation',
'',
'Build the base.',
'',
'## S02: Features',
'',
'**Depends on:** S01',
'',
'Build features.',
].join('\n');
const slices = parseRoadmapSlices(content);
assertEq(slices.length, 2, 'prose deps: 2 slices');
assertEq(slices[1].depends.length, 1, 'S02 has 1 dep');
assertEq(slices[1].depends[0], 'S01', 'S02 depends on S01');
}
// ═══════════════════════════════════════════════════════════════════════
// M. Empty / edge cases
// ═══════════════════════════════════════════════════════════════════════
console.log('\n=== M. Edge cases ===');
{
assertEq(parseRoadmapSlices('').length, 0, 'empty content → 0 slices');
assertEq(parseRoadmapSlices('# Just a title\n\nSome text.').length, 0, 'no slices at all → 0');
// Mixed format: ## Slices section with one checkbox + prose below
const mixed = [
'## Slices',
'',
'- [ ] **S01: Foundation** `risk:low` `depends:[]`',
'',
'## S02: Features',
'',
'Prose content.',
].join('\n');
const mixedSlices = parseRoadmapSlices(mixed);
// The ## Slices section takes priority — prose headers outside it aren't picked up
assertEq(mixedSlices.length, 1, 'mixed: only 1 slice from ## Slices section');
assertEq(mixedSlices[0].id, 'S01', 'mixed: S01 from checkbox');
}
// ═══════════════════════════════════════════════════════════════════════
// N. Dependency range expansion
// ═══════════════════════════════════════════════════════════════════════
console.log('\n=== N. Dependency range expansion ===');
{
assertEq(
expandDependencies(['S01-S04']),
['S01', 'S02', 'S03', 'S04'],
'S01-S04 → 4 individual deps',
);
assertEq(
expandDependencies(['S01..S03']),
['S01', 'S02', 'S03'],
'S01..S03 → 3 individual deps',
);
assertEq(
expandDependencies(['S01']),
['S01'],
'single dep passes through',
);
assertEq(
expandDependencies(['S01', 'S03-S05']),
['S01', 'S03', 'S04', 'S05'],
'mixed single + range',
);
assertEq(
expandDependencies(['']),
[],
'empty string filtered out',
);
}
// ═══════════════════════════════════════════════════════════════════════
// O. No-separator colon-less: "S01:Title" (no space after colon)
// ═══════════════════════════════════════════════════════════════════════
console.log('\n=== O. No space after colon ===');
{
const content = '## S01:Foundation\n\n## S02:Features\n';
const slices = parseRoadmapSlices(content);
// The regex uses [:\s.—–-]* which allows colon with no space
assertEq(slices.length, 2, 'no-space-colon: 2 slices');
}
// ═══════════════════════════════════════════════════════════════════════
// P. Three-digit padded IDs
// ═══════════════════════════════════════════════════════════════════════
console.log('\n=== P. Three-digit padded IDs ===');
{
const content = '## S001: Foundation\n\n## S002: Features\n';
const slices = parseRoadmapSlices(content);
assertEq(slices.length, 2, 'three-digit: 2 slices');
assertEq(slices[0].id, 'S001', 'three-digit: S001');
}
report();
}
main().catch((error) => {
console.error(error);
process.exit(1);
});

View file

@ -0,0 +1,216 @@
/**
* session-lock-regression.test.ts Regression tests for session lock lifecycle.
*
* Regression coverage for:
* #1257 False-positive "Session lock lost" during auto-mode
* #1245 Stranded .gsd.lock/ directory preventing new sessions
* #1251 Same root cause as #1245
*
* Tests the acquire validate release lifecycle and edge cases
* without requiring concurrent processes.
*/
import { mkdtempSync, mkdirSync, writeFileSync, rmSync, existsSync, readFileSync } from 'node:fs';
import { join } from 'node:path';
import { tmpdir } from 'node:os';
import {
acquireSessionLock,
validateSessionLock,
releaseSessionLock,
readSessionLockData,
updateSessionLock,
isSessionLockHeld,
} from '../session-lock.ts';
import { gsdRoot } from '../paths.ts';
import { createTestContext } from './test-helpers.ts';
const { assertEq, assertTrue, report } = createTestContext();
async function main(): Promise<void> {
// ─── 1. Basic acquire/release lifecycle ───────────────────────────────
console.log('\n=== 1. acquire → validate → release lifecycle ===');
{
const base = mkdtempSync(join(tmpdir(), 'gsd-session-lock-'));
mkdirSync(join(base, '.gsd'), { recursive: true });
try {
const result = acquireSessionLock(base);
assertTrue(result.acquired, 'lock acquired successfully');
const valid = validateSessionLock(base);
assertTrue(valid, 'lock validates after acquisition');
assertTrue(isSessionLockHeld(base), 'isSessionLockHeld returns true');
releaseSessionLock(base);
// After release, the lock file should be cleaned up
const lockFile = join(gsdRoot(base), 'auto.lock');
assertTrue(!existsSync(lockFile), 'lock file removed after release');
// The .gsd.lock/ directory should be cleaned up
const lockDir = gsdRoot(base) + '.lock';
assertTrue(!existsSync(lockDir), '.gsd.lock/ directory removed after release (#1245)');
} finally {
rmSync(base, { recursive: true, force: true });
}
}
// ─── 2. Double release is safe ────────────────────────────────────────
console.log('\n=== 2. double release does not throw ===');
{
const base = mkdtempSync(join(tmpdir(), 'gsd-session-lock-'));
mkdirSync(join(base, '.gsd'), { recursive: true });
try {
acquireSessionLock(base);
releaseSessionLock(base);
// Second release should not throw
let threw = false;
try {
releaseSessionLock(base);
} catch {
threw = true;
}
assertTrue(!threw, 'double release does not throw');
} finally {
rmSync(base, { recursive: true, force: true });
}
}
// ─── 3. updateSessionLock preserves lock data ─────────────────────────
console.log('\n=== 3. updateSessionLock writes metadata ===');
{
const base = mkdtempSync(join(tmpdir(), 'gsd-session-lock-'));
mkdirSync(join(base, '.gsd'), { recursive: true });
try {
acquireSessionLock(base);
updateSessionLock(base, 'execute-task', 'M001/S01/T01', 5, '/tmp/session.json');
const data = readSessionLockData(base);
assertTrue(data !== null, 'lock data readable after update');
if (data) {
assertEq(data.pid, process.pid, 'lock data has correct PID');
assertEq(data.unitType, 'execute-task', 'lock data has correct unit type');
assertEq(data.unitId, 'M001/S01/T01', 'lock data has correct unit ID');
assertEq(data.completedUnits, 5, 'lock data has correct completed count');
assertEq(data.sessionFile, '/tmp/session.json', 'lock data has session file');
}
releaseSessionLock(base);
} finally {
rmSync(base, { recursive: true, force: true });
}
}
// ─── 4. Stale lock from dead PID → re-acquirable (#1245) ─────────────
console.log('\n=== 4. stale lock from dead PID → re-acquirable ===');
{
const base = mkdtempSync(join(tmpdir(), 'gsd-session-lock-'));
mkdirSync(join(base, '.gsd'), { recursive: true });
try {
// Write a lock file with a definitely-dead PID
const lockFile = join(gsdRoot(base), 'auto.lock');
const staleLock = {
pid: 99999999, // extremely unlikely to be alive
startedAt: new Date(Date.now() - 3600000).toISOString(),
unitType: 'execute-task',
unitId: 'M001/S01/T01',
unitStartedAt: new Date(Date.now() - 3600000).toISOString(),
completedUnits: 3,
};
writeFileSync(lockFile, JSON.stringify(staleLock, null, 2));
// Should be able to acquire despite the stale lock
const result = acquireSessionLock(base);
assertTrue(result.acquired, '#1245: stale lock from dead PID → re-acquirable');
releaseSessionLock(base);
} finally {
rmSync(base, { recursive: true, force: true });
}
}
// ─── 5. readSessionLockData with no lock → null ───────────────────────
console.log('\n=== 5. readSessionLockData with no lock → null ===');
{
const base = mkdtempSync(join(tmpdir(), 'gsd-session-lock-'));
mkdirSync(join(base, '.gsd'), { recursive: true });
try {
const data = readSessionLockData(base);
assertEq(data, null, 'no lock file → null');
} finally {
rmSync(base, { recursive: true, force: true });
}
}
// ─── 6. validateSessionLock after own acquisition → true ──────────────
console.log('\n=== 6. validateSessionLock after own acquisition → true ===');
{
const base = mkdtempSync(join(tmpdir(), 'gsd-session-lock-'));
mkdirSync(join(base, '.gsd'), { recursive: true });
try {
acquireSessionLock(base);
// Multiple validations should all return true (regression for #1257)
for (let i = 0; i < 5; i++) {
const valid = validateSessionLock(base);
assertTrue(valid, `#1257: validation ${i + 1} returns true for own lock`);
}
releaseSessionLock(base);
} finally {
rmSync(base, { recursive: true, force: true });
}
}
// ─── 7. readSessionLockData with corrupt JSON → null ──────────────────
console.log('\n=== 7. corrupt lock file → null ===');
{
const base = mkdtempSync(join(tmpdir(), 'gsd-session-lock-'));
mkdirSync(join(base, '.gsd'), { recursive: true });
try {
const lockFile = join(gsdRoot(base), 'auto.lock');
writeFileSync(lockFile, 'NOT VALID JSON {{{');
const data = readSessionLockData(base);
assertEq(data, null, 'corrupt JSON → null');
} finally {
rmSync(base, { recursive: true, force: true });
}
}
// ─── 8. Acquire after release is possible ─────────────────────────────
console.log('\n=== 8. acquire after release → re-acquirable ===');
{
const base = mkdtempSync(join(tmpdir(), 'gsd-session-lock-'));
mkdirSync(join(base, '.gsd'), { recursive: true });
try {
const r1 = acquireSessionLock(base);
assertTrue(r1.acquired, 'first acquisition');
releaseSessionLock(base);
const r2 = acquireSessionLock(base);
assertTrue(r2.acquired, 're-acquisition after release');
releaseSessionLock(base);
} finally {
rmSync(base, { recursive: true, force: true });
}
}
report();
}
main().catch((error) => {
console.error(error);
process.exit(1);
});