diff --git a/src/resources/extensions/gsd/tests/auto-dispatch-loop.test.ts b/src/resources/extensions/gsd/tests/auto-dispatch-loop.test.ts new file mode 100644 index 000000000..3503e2ee0 --- /dev/null +++ b/src/resources/extensions/gsd/tests/auto-dispatch-loop.test.ts @@ -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> { + 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 { + + // ─── 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); +}); diff --git a/src/resources/extensions/gsd/tests/cache-staleness-regression.test.ts b/src/resources/extensions/gsd/tests/cache-staleness-regression.test.ts new file mode 100644 index 000000000..b9d513f7c --- /dev/null +++ b/src/resources/extensions/gsd/tests/cache-staleness-regression.test.ts @@ -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 { + + // ─── 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); +}); diff --git a/src/resources/extensions/gsd/tests/roadmap-parse-regression.test.ts b/src/resources/extensions/gsd/tests/roadmap-parse-regression.test.ts new file mode 100644 index 000000000..e2d70a75b --- /dev/null +++ b/src/resources/extensions/gsd/tests/roadmap-parse-regression.test.ts @@ -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 { + + // ═══════════════════════════════════════════════════════════════════════ + // 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); +}); diff --git a/src/resources/extensions/gsd/tests/session-lock-regression.test.ts b/src/resources/extensions/gsd/tests/session-lock-regression.test.ts new file mode 100644 index 000000000..00c1ac031 --- /dev/null +++ b/src/resources/extensions/gsd/tests/session-lock-regression.test.ts @@ -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 { + + // ─── 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); +});