Merge pull request #4007 from jeremymcs/refactor/state-derive-god-function
refactor: extract deriveStateFromDb logic into composable helpers
This commit is contained in:
commit
31e88c99d2
2 changed files with 729 additions and 363 deletions
|
|
@ -322,17 +322,8 @@ const isStatusDone = isClosedStatus;
|
|||
*
|
||||
* Must produce field-identical GSDState to _deriveStateImpl() for the same project.
|
||||
*/
|
||||
export async function deriveStateFromDb(basePath: string): Promise<GSDState> {
|
||||
const requirements = parseRequirementCounts(await loadFile(resolveGsdRootFile(basePath, "REQUIREMENTS")));
|
||||
|
||||
function reconcileDiskToDb(basePath: string): MilestoneRow[] {
|
||||
let allMilestones = getAllMilestones();
|
||||
|
||||
// Incremental disk→DB sync: milestone directories created outside the DB
|
||||
// write path (via /gsd queue, manual mkdir, or complete-milestone writing the
|
||||
// next CONTEXT.md) are never inserted by the initial migration guard in
|
||||
// auto-start.ts because that guard only runs when gsd.db doesn't exist yet.
|
||||
// Reconcile here so deriveStateFromDb never silently misses queued milestones.
|
||||
// insertMilestone uses INSERT OR IGNORE, so this is safe to call every time.
|
||||
const dbIdSet = new Set(allMilestones.map(m => m.id));
|
||||
const diskIds = findMilestoneIds(basePath);
|
||||
let synced = false;
|
||||
|
|
@ -344,11 +335,6 @@ export async function deriveStateFromDb(basePath: string): Promise<GSDState> {
|
|||
}
|
||||
if (synced) allMilestones = getAllMilestones();
|
||||
|
||||
// Disk→DB slice reconciliation (#2533): slices defined in ROADMAP.md but
|
||||
// missing from the DB cause permanent "No slice eligible" blocks because
|
||||
// the dependency resolver only sees DB rows. Parse each milestone's roadmap
|
||||
// and insert any missing slices, checking SUMMARY files to set correct status.
|
||||
// insertSlice uses INSERT OR IGNORE, so existing rows are never overwritten.
|
||||
for (const mid of diskIds) {
|
||||
if (isGhostMilestone(basePath, mid)) continue;
|
||||
const roadmapPath = resolveMilestoneFile(basePath, mid, "ROADMAP");
|
||||
|
|
@ -373,93 +359,43 @@ export async function deriveStateFromDb(basePath: string): Promise<GSDState> {
|
|||
});
|
||||
}
|
||||
}
|
||||
return allMilestones;
|
||||
}
|
||||
|
||||
// Reconcile: discover milestones that exist on disk but are missing from
|
||||
// the DB. This happens when milestones were created before the DB migration
|
||||
// or were manually added to the filesystem. Without this, disk-only
|
||||
// milestones are invisible after migration (#2416).
|
||||
const dbMilestoneIds = new Set(allMilestones.map(m => m.id));
|
||||
const diskMilestoneIds = findMilestoneIds(basePath);
|
||||
for (const diskId of diskMilestoneIds) {
|
||||
if (!dbMilestoneIds.has(diskId)) {
|
||||
// Synthesize a minimal MilestoneRow for the disk-only milestone.
|
||||
// Title and status will be resolved from disk files in the loop below.
|
||||
allMilestones.push({
|
||||
id: diskId,
|
||||
title: diskId,
|
||||
status: 'active',
|
||||
depends_on: [] as string[],
|
||||
created_at: new Date().toISOString(),
|
||||
} as MilestoneRow);
|
||||
}
|
||||
}
|
||||
// Re-sort so milestones follow queue order (same as dispatch guard) (#2556)
|
||||
const customOrder = loadQueueOrder(basePath);
|
||||
const sortedIds = sortByQueueOrder(allMilestones.map(m => m.id), customOrder);
|
||||
const byId = new Map(allMilestones.map(m => [m.id, m]));
|
||||
allMilestones.length = 0;
|
||||
for (const id of sortedIds) allMilestones.push(byId.get(id)!);
|
||||
|
||||
// Parallel worker isolation: when locked, filter to just the locked milestone
|
||||
const milestoneLock = process.env.GSD_MILESTONE_LOCK;
|
||||
const milestones = milestoneLock
|
||||
? allMilestones.filter(m => m.id === milestoneLock)
|
||||
: allMilestones;
|
||||
|
||||
if (milestones.length === 0) {
|
||||
return {
|
||||
activeMilestone: null,
|
||||
activeSlice: null,
|
||||
activeTask: null,
|
||||
phase: 'pre-planning',
|
||||
recentDecisions: [],
|
||||
blockers: [],
|
||||
nextAction: 'No milestones found. Run /gsd to create one.',
|
||||
registry: [],
|
||||
requirements,
|
||||
progress: { milestones: { done: 0, total: 0 } },
|
||||
};
|
||||
}
|
||||
|
||||
// Phase 1: Build completeness set (which milestones count as "done" for dep resolution)
|
||||
function buildCompletenessSet(basePath: string, milestones: MilestoneRow[]) {
|
||||
const completeMilestoneIds = new Set<string>();
|
||||
const parkedMilestoneIds = new Set<string>();
|
||||
|
||||
for (const m of milestones) {
|
||||
// Check disk for PARKED flag (not stored in DB status reliably — disk is truth for flag files)
|
||||
const parkedFile = resolveMilestoneFile(basePath, m.id, "PARKED");
|
||||
if (parkedFile || m.status === 'parked') {
|
||||
parkedMilestoneIds.add(m.id);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (isStatusDone(m.status)) {
|
||||
completeMilestoneIds.add(m.id);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Check if milestone has a summary on disk (terminal artifact per #864)
|
||||
const summaryFile = resolveMilestoneFile(basePath, m.id, "SUMMARY");
|
||||
if (summaryFile) {
|
||||
completeMilestoneIds.add(m.id);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Milestones with all slices done but no SUMMARY file are in
|
||||
// validating/completing state — intentionally NOT added to
|
||||
// completeMilestoneIds. The SUMMARY file (checked above) is the
|
||||
// terminal artifact that proves completion per #864.
|
||||
}
|
||||
return { completeMilestoneIds, parkedMilestoneIds };
|
||||
}
|
||||
|
||||
// Phase 2: Build registry and find active milestone
|
||||
async function buildRegistryAndFindActive(
|
||||
basePath: string,
|
||||
milestones: MilestoneRow[],
|
||||
completeMilestoneIds: Set<string>,
|
||||
parkedMilestoneIds: Set<string>
|
||||
) {
|
||||
const registry: MilestoneRegistryEntry[] = [];
|
||||
let activeMilestone: ActiveRef | null = null;
|
||||
let activeMilestoneSlices: SliceRow[] = [];
|
||||
let activeMilestoneFound = false;
|
||||
let activeMilestoneHasDraft = false;
|
||||
// Queued shells (DB row, no slices, no content files) are deferred during
|
||||
// the main loop so they don't eclipse real active milestones (#3470).
|
||||
// If no real active milestone is found, the first deferred shell is promoted.
|
||||
let firstDeferredQueuedShell: { id: string; title: string; deps: string[] } | null = null;
|
||||
|
||||
for (const m of milestones) {
|
||||
|
|
@ -468,19 +404,14 @@ export async function deriveStateFromDb(basePath: string): Promise<GSDState> {
|
|||
continue;
|
||||
}
|
||||
|
||||
// Ghost milestone check: no slices in DB AND no substantive files on disk.
|
||||
// Skip queued milestones — they are handled by the deferred-shell logic below (#3470).
|
||||
const slices = getMilestoneSlices(m.id);
|
||||
if (slices.length === 0 && !isStatusDone(m.status) && m.status !== 'queued') {
|
||||
// Check disk for ghost detection
|
||||
if (isGhostMilestone(basePath, m.id)) continue;
|
||||
}
|
||||
|
||||
const summaryFile = resolveMilestoneFile(basePath, m.id, "SUMMARY");
|
||||
|
||||
// Determine if this milestone is complete
|
||||
if (completeMilestoneIds.has(m.id) || (summaryFile !== null)) {
|
||||
// Get title from DB or summary
|
||||
let title = stripMilestonePrefix(m.title) || m.id;
|
||||
if (summaryFile && !m.title) {
|
||||
const summaryContent = await loadFile(summaryFile);
|
||||
|
|
@ -489,14 +420,12 @@ export async function deriveStateFromDb(basePath: string): Promise<GSDState> {
|
|||
}
|
||||
}
|
||||
registry.push({ id: m.id, title, status: 'complete' });
|
||||
completeMilestoneIds.add(m.id); // ensure it's in the set
|
||||
completeMilestoneIds.add(m.id);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Not complete — determine if it should be active
|
||||
const allSlicesDone = slices.length > 0 && slices.every(s => isStatusDone(s.status));
|
||||
|
||||
// Get title — prefer DB, fall back to context file extraction
|
||||
let title = stripMilestonePrefix(m.title) || m.id;
|
||||
if (title === m.id) {
|
||||
const contextFile = resolveMilestoneFile(basePath, m.id, "CONTEXT");
|
||||
|
|
@ -507,7 +436,6 @@ export async function deriveStateFromDb(basePath: string): Promise<GSDState> {
|
|||
}
|
||||
|
||||
if (!activeMilestoneFound) {
|
||||
// Check milestone-level dependencies
|
||||
const deps = m.depends_on;
|
||||
const depsUnmet = deps.some(dep => !completeMilestoneIds.has(dep));
|
||||
|
||||
|
|
@ -516,11 +444,6 @@ export async function deriveStateFromDb(basePath: string): Promise<GSDState> {
|
|||
continue;
|
||||
}
|
||||
|
||||
// Defer queued shell milestones with no substantive content (#3470).
|
||||
// A queued milestone with no slices and no context/draft file is a
|
||||
// placeholder that should not block later real active milestones.
|
||||
// If no real active milestone is found after the loop, the first
|
||||
// deferred shell is promoted to active (#2921).
|
||||
if (m.status === 'queued' && slices.length === 0) {
|
||||
const contextFile = resolveMilestoneFile(basePath, m.id, "CONTEXT");
|
||||
const draftFile = resolveMilestoneFile(basePath, m.id, "CONTEXT-DRAFT");
|
||||
|
|
@ -533,14 +456,12 @@ export async function deriveStateFromDb(basePath: string): Promise<GSDState> {
|
|||
}
|
||||
}
|
||||
|
||||
// Handle all-slices-done case (validating/completing)
|
||||
if (allSlicesDone) {
|
||||
const validationFile = resolveMilestoneFile(basePath, m.id, "VALIDATION");
|
||||
const validationContent = validationFile ? await loadFile(validationFile) : null;
|
||||
const validationTerminal = validationContent ? isValidationTerminal(validationContent) : false;
|
||||
|
||||
if (!validationTerminal || (validationTerminal && !summaryFile)) {
|
||||
// Validating or completing — still active
|
||||
activeMilestone = { id: m.id, title };
|
||||
activeMilestoneSlices = slices;
|
||||
activeMilestoneFound = true;
|
||||
|
|
@ -549,7 +470,6 @@ export async function deriveStateFromDb(basePath: string): Promise<GSDState> {
|
|||
}
|
||||
}
|
||||
|
||||
// Check for context draft (needs-discussion phase)
|
||||
const contextFile = resolveMilestoneFile(basePath, m.id, "CONTEXT");
|
||||
const draftFile = resolveMilestoneFile(basePath, m.id, "CONTEXT-DRAFT");
|
||||
if (!contextFile && draftFile) activeMilestoneHasDraft = true;
|
||||
|
|
@ -559,13 +479,11 @@ export async function deriveStateFromDb(basePath: string): Promise<GSDState> {
|
|||
activeMilestoneFound = true;
|
||||
registry.push({ id: m.id, title, status: 'active', ...(deps.length > 0 ? { dependsOn: deps } : {}) });
|
||||
} else {
|
||||
// After active milestone found — rest are pending
|
||||
const deps = m.depends_on;
|
||||
registry.push({ id: m.id, title, status: 'pending', ...(deps.length > 0 ? { dependsOn: deps } : {}) });
|
||||
}
|
||||
}
|
||||
|
||||
// Promote deferred queued shell if no real active milestone was found (#3470/#2921).
|
||||
if (!activeMilestoneFound && firstDeferredQueuedShell) {
|
||||
const shell = firstDeferredQueuedShell;
|
||||
activeMilestone = { id: shell.id, title: shell.title };
|
||||
|
|
@ -575,74 +493,264 @@ export async function deriveStateFromDb(basePath: string): Promise<GSDState> {
|
|||
if (entry) entry.status = 'active';
|
||||
}
|
||||
|
||||
const milestoneProgress = {
|
||||
done: registry.filter(e => e.status === 'complete').length,
|
||||
total: registry.length,
|
||||
};
|
||||
return { registry, activeMilestone, activeMilestoneSlices, activeMilestoneHasDraft };
|
||||
}
|
||||
|
||||
// ── No active milestone ──────────────────────────────────────────────
|
||||
if (!activeMilestone) {
|
||||
const pendingEntries = registry.filter(e => e.status === 'pending');
|
||||
const parkedEntries = registry.filter(e => e.status === 'parked');
|
||||
function handleNoActiveMilestone(
|
||||
registry: MilestoneRegistryEntry[],
|
||||
requirements: any,
|
||||
milestoneProgress: { done: number, total: number }
|
||||
): GSDState {
|
||||
const pendingEntries = registry.filter(e => e.status === 'pending');
|
||||
const parkedEntries = registry.filter(e => e.status === 'parked');
|
||||
|
||||
if (pendingEntries.length > 0) {
|
||||
const blockerDetails = pendingEntries
|
||||
.filter(e => e.dependsOn && e.dependsOn.length > 0)
|
||||
.map(e => `${e.id} is waiting on unmet deps: ${e.dependsOn!.join(', ')}`);
|
||||
return {
|
||||
activeMilestone: null, activeSlice: null, activeTask: null,
|
||||
phase: 'blocked',
|
||||
recentDecisions: [], blockers: blockerDetails.length > 0
|
||||
? blockerDetails
|
||||
: ['All remaining milestones are dep-blocked but no deps listed — check CONTEXT.md files'],
|
||||
nextAction: 'Resolve milestone dependencies before proceeding.',
|
||||
registry, requirements,
|
||||
progress: { milestones: milestoneProgress },
|
||||
};
|
||||
}
|
||||
|
||||
if (parkedEntries.length > 0) {
|
||||
const parkedIds = parkedEntries.map(e => e.id).join(', ');
|
||||
return {
|
||||
activeMilestone: null, activeSlice: null, activeTask: null,
|
||||
phase: 'pre-planning',
|
||||
recentDecisions: [], blockers: [],
|
||||
nextAction: `All remaining milestones are parked (${parkedIds}). Run /gsd unpark <id> or create a new milestone.`,
|
||||
registry, requirements,
|
||||
progress: { milestones: milestoneProgress },
|
||||
};
|
||||
}
|
||||
|
||||
if (registry.length === 0) {
|
||||
return {
|
||||
activeMilestone: null, activeSlice: null, activeTask: null,
|
||||
phase: 'pre-planning',
|
||||
recentDecisions: [], blockers: [],
|
||||
nextAction: 'No milestones found. Run /gsd to create one.',
|
||||
registry: [], requirements,
|
||||
progress: { milestones: { done: 0, total: 0 } },
|
||||
};
|
||||
}
|
||||
|
||||
// All milestones complete
|
||||
const lastEntry = registry[registry.length - 1];
|
||||
const activeReqs = requirements.active ?? 0;
|
||||
const completionNote = activeReqs > 0
|
||||
? `All milestones complete. ${activeReqs} active requirement${activeReqs === 1 ? '' : 's'} in REQUIREMENTS.md ${activeReqs === 1 ? 'has' : 'have'} not been mapped to a milestone.`
|
||||
: 'All milestones complete.';
|
||||
if (pendingEntries.length > 0) {
|
||||
const blockerDetails = pendingEntries
|
||||
.filter(e => e.dependsOn && e.dependsOn.length > 0)
|
||||
.map(e => `${e.id} is waiting on unmet deps: ${e.dependsOn!.join(', ')}`);
|
||||
return {
|
||||
activeMilestone: null,
|
||||
lastCompletedMilestone: lastEntry ? { id: lastEntry.id, title: lastEntry.title } : null,
|
||||
activeSlice: null, activeTask: null,
|
||||
phase: 'complete',
|
||||
recentDecisions: [], blockers: [],
|
||||
nextAction: completionNote,
|
||||
activeMilestone: null, activeSlice: null, activeTask: null,
|
||||
phase: 'blocked',
|
||||
recentDecisions: [], blockers: blockerDetails.length > 0
|
||||
? blockerDetails
|
||||
: ['All remaining milestones are dep-blocked but no deps listed — check CONTEXT.md files'],
|
||||
nextAction: 'Resolve milestone dependencies before proceeding.',
|
||||
registry, requirements,
|
||||
progress: { milestones: milestoneProgress },
|
||||
};
|
||||
}
|
||||
|
||||
// ── Active milestone has no slices or no roadmap ────────────────────
|
||||
if (parkedEntries.length > 0) {
|
||||
const parkedIds = parkedEntries.map(e => e.id).join(', ');
|
||||
return {
|
||||
activeMilestone: null, activeSlice: null, activeTask: null,
|
||||
phase: 'pre-planning',
|
||||
recentDecisions: [], blockers: [],
|
||||
nextAction: `All remaining milestones are parked (${parkedIds}). Run /gsd unpark <id> or create a new milestone.`,
|
||||
registry, requirements,
|
||||
progress: { milestones: milestoneProgress },
|
||||
};
|
||||
}
|
||||
|
||||
if (registry.length === 0) {
|
||||
return {
|
||||
activeMilestone: null, activeSlice: null, activeTask: null,
|
||||
phase: 'pre-planning',
|
||||
recentDecisions: [], blockers: [],
|
||||
nextAction: 'No milestones found. Run /gsd to create one.',
|
||||
registry: [], requirements,
|
||||
progress: { milestones: { done: 0, total: 0 } },
|
||||
};
|
||||
}
|
||||
|
||||
const lastEntry = registry[registry.length - 1];
|
||||
const activeReqs = requirements.active ?? 0;
|
||||
const completionNote = activeReqs > 0
|
||||
? `All milestones complete. ${activeReqs} active requirement${activeReqs === 1 ? '' : 's'} in REQUIREMENTS.md ${activeReqs === 1 ? 'has' : 'have'} not been mapped to a milestone.`
|
||||
: 'All milestones complete.';
|
||||
return {
|
||||
activeMilestone: null,
|
||||
lastCompletedMilestone: lastEntry ? { id: lastEntry.id, title: lastEntry.title } : null,
|
||||
activeSlice: null, activeTask: null,
|
||||
phase: 'complete',
|
||||
recentDecisions: [], blockers: [],
|
||||
nextAction: completionNote,
|
||||
registry, requirements,
|
||||
progress: { milestones: milestoneProgress },
|
||||
};
|
||||
}
|
||||
|
||||
async function handleAllSlicesDone(
|
||||
basePath: string,
|
||||
activeMilestone: ActiveRef,
|
||||
registry: MilestoneRegistryEntry[],
|
||||
requirements: any,
|
||||
milestoneProgress: { done: number, total: number },
|
||||
sliceProgress: { done: number, total: number }
|
||||
): Promise<GSDState> {
|
||||
const validationFile = resolveMilestoneFile(basePath, activeMilestone.id, "VALIDATION");
|
||||
const validationContent = validationFile ? await loadFile(validationFile) : null;
|
||||
const validationTerminal = validationContent ? isValidationTerminal(validationContent) : false;
|
||||
const verdict = validationContent ? extractVerdict(validationContent) : undefined;
|
||||
|
||||
if (!validationTerminal || verdict === 'needs-remediation') {
|
||||
return {
|
||||
activeMilestone, activeSlice: null, activeTask: null,
|
||||
phase: 'validating-milestone',
|
||||
recentDecisions: [], blockers: [],
|
||||
nextAction: `Validate milestone ${activeMilestone.id} before completion.`,
|
||||
registry, requirements,
|
||||
progress: { milestones: milestoneProgress, slices: sliceProgress },
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
activeMilestone, activeSlice: null, activeTask: null,
|
||||
phase: 'completing-milestone',
|
||||
recentDecisions: [], blockers: [],
|
||||
nextAction: `All slices complete in ${activeMilestone.id}. Write milestone summary.`,
|
||||
registry, requirements,
|
||||
progress: { milestones: milestoneProgress, slices: sliceProgress },
|
||||
};
|
||||
}
|
||||
|
||||
function resolveSliceDependencies(activeMilestoneSlices: SliceRow[]): { activeSlice: ActiveRef | null, activeSliceRow: SliceRow | null } {
|
||||
const doneSliceIds = new Set(
|
||||
activeMilestoneSlices.filter(s => isStatusDone(s.status)).map(s => s.id)
|
||||
);
|
||||
|
||||
const sliceLock = process.env.GSD_SLICE_LOCK;
|
||||
if (sliceLock) {
|
||||
const lockedSlice = activeMilestoneSlices.find(s => s.id === sliceLock);
|
||||
if (lockedSlice) {
|
||||
return { activeSlice: { id: lockedSlice.id, title: lockedSlice.title }, activeSliceRow: lockedSlice };
|
||||
} else {
|
||||
logWarning("state", `GSD_SLICE_LOCK=${sliceLock} not found in active slices — worker has no assigned work`);
|
||||
return { activeSlice: null, activeSliceRow: null };
|
||||
}
|
||||
}
|
||||
|
||||
for (const s of activeMilestoneSlices) {
|
||||
if (isStatusDone(s.status)) continue;
|
||||
if (isDeferredStatus(s.status)) continue;
|
||||
if (s.depends.every(dep => doneSliceIds.has(dep))) {
|
||||
return { activeSlice: { id: s.id, title: s.title }, activeSliceRow: s };
|
||||
}
|
||||
}
|
||||
return { activeSlice: null, activeSliceRow: null };
|
||||
}
|
||||
|
||||
async function reconcileSliceTasks(
|
||||
basePath: string,
|
||||
milestoneId: string,
|
||||
sliceId: string,
|
||||
planFile: string
|
||||
): Promise<TaskRow[]> {
|
||||
let tasks = getSliceTasks(milestoneId, sliceId);
|
||||
|
||||
if (tasks.length === 0 && planFile) {
|
||||
try {
|
||||
const planContent = await loadFile(planFile);
|
||||
if (planContent) {
|
||||
const diskPlan = parsePlan(planContent);
|
||||
if (diskPlan.tasks.length > 0) {
|
||||
for (let i = 0; i < diskPlan.tasks.length; i++) {
|
||||
const t = diskPlan.tasks[i];
|
||||
try {
|
||||
insertTask({
|
||||
id: t.id,
|
||||
sliceId,
|
||||
milestoneId,
|
||||
title: t.title,
|
||||
status: t.done ? 'complete' : 'pending',
|
||||
sequence: i + 1,
|
||||
});
|
||||
} catch (insertErr) {
|
||||
logWarning("reconcile", `failed to insert task ${t.id} from plan file: ${insertErr instanceof Error ? insertErr.message : String(insertErr)}`);
|
||||
}
|
||||
}
|
||||
tasks = getSliceTasks(milestoneId, sliceId);
|
||||
logWarning("reconcile", `imported ${tasks.length} tasks from plan file for ${milestoneId}/${sliceId} — DB was empty (#3600)`, { mid: milestoneId, sid: sliceId });
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
logError("reconcile", `plan-file task import failed for ${milestoneId}/${sliceId}: ${err instanceof Error ? err.message : String(err)}`);
|
||||
}
|
||||
}
|
||||
|
||||
let reconciled = false;
|
||||
for (const t of tasks) {
|
||||
if (isStatusDone(t.status)) continue;
|
||||
const summaryPath = resolveTaskFile(basePath, milestoneId, sliceId, t.id, "SUMMARY");
|
||||
if (summaryPath && existsSync(summaryPath)) {
|
||||
try {
|
||||
updateTaskStatus(milestoneId, sliceId, t.id, "complete");
|
||||
logWarning("reconcile", `task ${milestoneId}/${sliceId}/${t.id} status reconciled from "${t.status}" to "complete" (#2514)`, { mid: milestoneId, sid: sliceId, tid: t.id });
|
||||
reconciled = true;
|
||||
} catch (e) {
|
||||
logError("reconcile", `failed to update task ${t.id}`, { tid: t.id, error: (e as Error).message });
|
||||
}
|
||||
}
|
||||
}
|
||||
if (reconciled) {
|
||||
tasks = getSliceTasks(milestoneId, sliceId);
|
||||
}
|
||||
return tasks;
|
||||
}
|
||||
|
||||
async function detectBlockers(basePath: string, milestoneId: string, sliceId: string, tasks: TaskRow[]): Promise<string | null> {
|
||||
const completedTasks = tasks.filter(t => isStatusDone(t.status));
|
||||
for (const ct of completedTasks) {
|
||||
if (ct.blocker_discovered) {
|
||||
return ct.id;
|
||||
}
|
||||
const summaryFile = resolveTaskFile(basePath, milestoneId, sliceId, ct.id, "SUMMARY");
|
||||
if (!summaryFile) continue;
|
||||
const summaryContent = await loadFile(summaryFile);
|
||||
if (!summaryContent) continue;
|
||||
const summary = parseSummary(summaryContent);
|
||||
if (summary.frontmatter.blocker_discovered) {
|
||||
return ct.id;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function checkReplanTrigger(basePath: string, milestoneId: string, sliceId: string): boolean {
|
||||
const sliceRow = getSlice(milestoneId, sliceId);
|
||||
const dbTriggered = !!sliceRow?.replan_triggered_at;
|
||||
const diskTriggered = !dbTriggered &&
|
||||
!!resolveSliceFile(basePath, milestoneId, sliceId, "REPLAN-TRIGGER");
|
||||
return dbTriggered || diskTriggered;
|
||||
}
|
||||
|
||||
async function checkInterruptedWork(basePath: string, milestoneId: string, sliceId: string): Promise<boolean> {
|
||||
const sDir = resolveSlicePath(basePath, milestoneId, sliceId);
|
||||
const continueFile = sDir ? resolveSliceFile(basePath, milestoneId, sliceId, "CONTINUE") : null;
|
||||
return !!(continueFile && await loadFile(continueFile)) ||
|
||||
!!(sDir && await loadFile(join(sDir, "continue.md")));
|
||||
}
|
||||
|
||||
export async function deriveStateFromDb(basePath: string): Promise<GSDState> {
|
||||
const requirements = parseRequirementCounts(await loadFile(resolveGsdRootFile(basePath, "REQUIREMENTS")));
|
||||
|
||||
let allMilestones = reconcileDiskToDb(basePath);
|
||||
|
||||
const customOrder = loadQueueOrder(basePath);
|
||||
const sortedIds = sortByQueueOrder(allMilestones.map(m => m.id), customOrder);
|
||||
const byId = new Map(allMilestones.map(m => [m.id, m]));
|
||||
allMilestones.length = 0;
|
||||
for (const id of sortedIds) allMilestones.push(byId.get(id)!);
|
||||
|
||||
const milestoneLock = process.env.GSD_MILESTONE_LOCK;
|
||||
const milestones = milestoneLock
|
||||
? allMilestones.filter(m => m.id === milestoneLock)
|
||||
: allMilestones;
|
||||
|
||||
if (milestones.length === 0) {
|
||||
return {
|
||||
activeMilestone: null, activeSlice: null, activeTask: null,
|
||||
phase: 'pre-planning', recentDecisions: [], blockers: [],
|
||||
nextAction: 'No milestones found. Run /gsd to create one.',
|
||||
registry: [], requirements,
|
||||
progress: { milestones: { done: 0, total: 0 } },
|
||||
};
|
||||
}
|
||||
|
||||
const { completeMilestoneIds, parkedMilestoneIds } = buildCompletenessSet(basePath, milestones);
|
||||
|
||||
const registryContext = await buildRegistryAndFindActive(basePath, milestones, completeMilestoneIds, parkedMilestoneIds);
|
||||
const { registry, activeMilestone, activeMilestoneSlices, activeMilestoneHasDraft } = registryContext;
|
||||
|
||||
const milestoneProgress = {
|
||||
done: registry.filter(e => e.status === 'complete').length,
|
||||
total: registry.length,
|
||||
};
|
||||
|
||||
if (!activeMilestone) {
|
||||
return handleNoActiveMilestone(registry, requirements, milestoneProgress);
|
||||
}
|
||||
|
||||
const hasRoadmap = resolveMilestoneFile(basePath, activeMilestone.id, "ROADMAP") !== null;
|
||||
|
||||
if (activeMilestoneSlices.length === 0) {
|
||||
|
|
@ -659,195 +767,60 @@ export async function deriveStateFromDb(basePath: string): Promise<GSDState> {
|
|||
};
|
||||
}
|
||||
|
||||
// Has roadmap file but zero slices in DB — pre-planning (zero-slice roadmap guard)
|
||||
return {
|
||||
activeMilestone, activeSlice: null, activeTask: null,
|
||||
phase: 'pre-planning',
|
||||
recentDecisions: [], blockers: [],
|
||||
phase: 'pre-planning', recentDecisions: [], blockers: [],
|
||||
nextAction: `Milestone ${activeMilestone.id} has a roadmap but no slices defined. Add slices to the roadmap.`,
|
||||
registry, requirements,
|
||||
progress: {
|
||||
milestones: milestoneProgress,
|
||||
slices: { done: 0, total: 0 },
|
||||
},
|
||||
progress: { milestones: milestoneProgress, slices: { done: 0, total: 0 } },
|
||||
};
|
||||
}
|
||||
|
||||
// ── All slices done → validating/completing ─────────────────────────
|
||||
const allSlicesDone = activeMilestoneSlices.every(s => isStatusDone(s.status));
|
||||
if (allSlicesDone) {
|
||||
const validationFile = resolveMilestoneFile(basePath, activeMilestone.id, "VALIDATION");
|
||||
const validationContent = validationFile ? await loadFile(validationFile) : null;
|
||||
const validationTerminal = validationContent ? isValidationTerminal(validationContent) : false;
|
||||
const verdict = validationContent ? extractVerdict(validationContent) : undefined;
|
||||
const sliceProgress = {
|
||||
done: activeMilestoneSlices.length,
|
||||
total: activeMilestoneSlices.length,
|
||||
};
|
||||
|
||||
// Force re-validation when verdict is needs-remediation — remediation slices
|
||||
// may have completed since the stale validation was written (#3596).
|
||||
if (!validationTerminal || verdict === 'needs-remediation') {
|
||||
return {
|
||||
activeMilestone, activeSlice: null, activeTask: null,
|
||||
phase: 'validating-milestone',
|
||||
recentDecisions: [], blockers: [],
|
||||
nextAction: `Validate milestone ${activeMilestone.id} before completion.`,
|
||||
registry, requirements,
|
||||
progress: { milestones: milestoneProgress, slices: sliceProgress },
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
activeMilestone, activeSlice: null, activeTask: null,
|
||||
phase: 'completing-milestone',
|
||||
recentDecisions: [], blockers: [],
|
||||
nextAction: `All slices complete in ${activeMilestone.id}. Write milestone summary.`,
|
||||
registry, requirements,
|
||||
progress: { milestones: milestoneProgress, slices: sliceProgress },
|
||||
};
|
||||
}
|
||||
|
||||
// ── Find active slice (first incomplete with deps satisfied) ─────────
|
||||
const sliceProgress = {
|
||||
done: activeMilestoneSlices.filter(s => isStatusDone(s.status)).length,
|
||||
total: activeMilestoneSlices.length,
|
||||
};
|
||||
|
||||
const doneSliceIds = new Set(
|
||||
activeMilestoneSlices.filter(s => isStatusDone(s.status)).map(s => s.id)
|
||||
);
|
||||
if (allSlicesDone) {
|
||||
return handleAllSlicesDone(basePath, activeMilestone, registry, requirements, milestoneProgress, sliceProgress);
|
||||
}
|
||||
|
||||
let activeSlice: ActiveRef | null = null;
|
||||
let activeSliceRow: SliceRow | null = null;
|
||||
|
||||
// ── Slice-level parallel worker isolation ─────────────────────────────
|
||||
// When GSD_SLICE_LOCK is set, this process is a parallel worker scoped
|
||||
// to a single slice. Override activeSlice to only the locked slice ID.
|
||||
const sliceLock = process.env.GSD_SLICE_LOCK;
|
||||
if (sliceLock) {
|
||||
const lockedSlice = activeMilestoneSlices.find(s => s.id === sliceLock);
|
||||
if (lockedSlice) {
|
||||
activeSlice = { id: lockedSlice.id, title: lockedSlice.title };
|
||||
activeSliceRow = lockedSlice;
|
||||
} else {
|
||||
logWarning("state", `GSD_SLICE_LOCK=${sliceLock} not found in active slices — worker has no assigned work`);
|
||||
// Don't silently continue — this is a dispatch error
|
||||
const activeSliceContext = resolveSliceDependencies(activeMilestoneSlices);
|
||||
if (!activeSliceContext.activeSlice) {
|
||||
// If locked slice wasn't found, it returns null but logs warning, we need to return 'blocked'
|
||||
if (process.env.GSD_SLICE_LOCK) {
|
||||
return {
|
||||
activeMilestone, activeSlice: null, activeTask: null,
|
||||
phase: 'blocked',
|
||||
recentDecisions: [], blockers: [`GSD_SLICE_LOCK=${sliceLock} not found in active milestone slices`],
|
||||
phase: 'blocked', recentDecisions: [], blockers: [`GSD_SLICE_LOCK=${process.env.GSD_SLICE_LOCK} not found in active milestone slices`],
|
||||
nextAction: 'Slice lock references a non-existent slice — check orchestrator dispatch.',
|
||||
registry, requirements,
|
||||
progress: { milestones: milestoneProgress, slices: sliceProgress },
|
||||
};
|
||||
}
|
||||
} else {
|
||||
for (const s of activeMilestoneSlices) {
|
||||
if (isStatusDone(s.status)) continue;
|
||||
// #2661: Skip deferred slices — a decision explicitly deferred this work.
|
||||
// Without this guard the dispatcher would keep dispatching deferred slices
|
||||
// because DECISIONS.md is only contextual, not authoritative for dispatch.
|
||||
if (isDeferredStatus(s.status)) continue;
|
||||
if (s.depends.every(dep => doneSliceIds.has(dep))) {
|
||||
activeSlice = { id: s.id, title: s.title };
|
||||
activeSliceRow = s;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!activeSlice) {
|
||||
return {
|
||||
activeMilestone, activeSlice: null, activeTask: null,
|
||||
phase: 'blocked',
|
||||
recentDecisions: [], blockers: ['No slice eligible — check dependency ordering'],
|
||||
phase: 'blocked', recentDecisions: [], blockers: ['No slice eligible — check dependency ordering'],
|
||||
nextAction: 'Resolve dependency blockers or plan next slice.',
|
||||
registry, requirements,
|
||||
progress: { milestones: milestoneProgress, slices: sliceProgress },
|
||||
};
|
||||
}
|
||||
const { activeSlice } = activeSliceContext;
|
||||
|
||||
// ── Check for slice plan file on disk ────────────────────────────────
|
||||
const planFile = resolveSliceFile(basePath, activeMilestone.id, activeSlice.id, "PLAN");
|
||||
if (!planFile) {
|
||||
return {
|
||||
activeMilestone, activeSlice, activeTask: null,
|
||||
phase: 'planning',
|
||||
recentDecisions: [], blockers: [],
|
||||
phase: 'planning', recentDecisions: [], blockers: [],
|
||||
nextAction: `Plan slice ${activeSlice.id} (${activeSlice.title}).`,
|
||||
registry, requirements,
|
||||
progress: { milestones: milestoneProgress, slices: sliceProgress },
|
||||
};
|
||||
}
|
||||
|
||||
// ── Get tasks from DB ────────────────────────────────────────────────
|
||||
let tasks = getSliceTasks(activeMilestone.id, activeSlice.id);
|
||||
|
||||
// ── Reconcile missing tasks: plan file has tasks but DB is empty (#3600) ──
|
||||
// When the planning agent writes S##-PLAN.md with task entries but never
|
||||
// calls the gsd_plan_slice persistence tool, the DB has zero task rows
|
||||
// even though the plan file contains valid tasks. Without this reconciliation,
|
||||
// deriveState returns phase='planning' forever — the dispatcher re-dispatches
|
||||
// plan-slice in an infinite loop.
|
||||
if (tasks.length === 0 && planFile) {
|
||||
try {
|
||||
const planContent = await loadFile(planFile);
|
||||
if (planContent) {
|
||||
const diskPlan = parsePlan(planContent);
|
||||
if (diskPlan.tasks.length > 0) {
|
||||
for (let i = 0; i < diskPlan.tasks.length; i++) {
|
||||
const t = diskPlan.tasks[i];
|
||||
try {
|
||||
insertTask({
|
||||
id: t.id,
|
||||
sliceId: activeSlice.id,
|
||||
milestoneId: activeMilestone.id,
|
||||
title: t.title,
|
||||
status: t.done ? 'complete' : 'pending',
|
||||
sequence: i + 1,
|
||||
});
|
||||
} catch (insertErr) {
|
||||
// Task may already exist from a partial previous import — skip
|
||||
logWarning("reconcile", `failed to insert task ${t.id} from plan file: ${insertErr instanceof Error ? insertErr.message : String(insertErr)}`);
|
||||
}
|
||||
}
|
||||
tasks = getSliceTasks(activeMilestone.id, activeSlice.id);
|
||||
logWarning("reconcile", `imported ${tasks.length} tasks from plan file for ${activeMilestone.id}/${activeSlice.id} — DB was empty (#3600)`, { mid: activeMilestone.id, sid: activeSlice.id });
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
// Non-fatal — fall through to the existing "empty plan" logic
|
||||
logError("reconcile", `plan-file task import failed for ${activeMilestone.id}/${activeSlice.id}: ${err instanceof Error ? err.message : String(err)}`);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Reconcile stale task status (#2514) ──────────────────────────────
|
||||
// When a session disconnects after the agent writes SUMMARY + VERIFY
|
||||
// artifacts but before postUnitPostVerification updates the DB, tasks
|
||||
// remain "pending" in the DB despite being complete on disk. Without
|
||||
// reconciliation, deriveState keeps returning the stale task as active,
|
||||
// causing the dispatcher to re-dispatch the same completed task forever.
|
||||
let reconciled = false;
|
||||
for (const t of tasks) {
|
||||
if (isStatusDone(t.status)) continue;
|
||||
const summaryPath = resolveTaskFile(basePath, activeMilestone.id, activeSlice.id, t.id, "SUMMARY");
|
||||
if (summaryPath && existsSync(summaryPath)) {
|
||||
try {
|
||||
updateTaskStatus(activeMilestone.id, activeSlice.id, t.id, "complete");
|
||||
logWarning("reconcile", `task ${activeMilestone.id}/${activeSlice.id}/${t.id} status reconciled from "${t.status}" to "complete" (#2514)`, { mid: activeMilestone.id, sid: activeSlice.id, tid: t.id });
|
||||
reconciled = true;
|
||||
} catch (e) {
|
||||
// DB write failed — continue with stale status rather than crash
|
||||
logError("reconcile", `failed to update task ${t.id}`, { tid: t.id, error: (e as Error).message });
|
||||
}
|
||||
}
|
||||
}
|
||||
// Re-fetch tasks if any were reconciled so downstream logic sees fresh status
|
||||
if (reconciled) {
|
||||
tasks = getSliceTasks(activeMilestone.id, activeSlice.id);
|
||||
}
|
||||
|
||||
const tasks = await reconcileSliceTasks(basePath, activeMilestone.id, activeSlice.id, planFile);
|
||||
|
||||
const taskProgress = {
|
||||
done: tasks.filter(t => isStatusDone(t.status)).length,
|
||||
total: tasks.length,
|
||||
|
|
@ -856,23 +829,19 @@ export async function deriveStateFromDb(basePath: string): Promise<GSDState> {
|
|||
const activeTaskRow = tasks.find(t => !isStatusDone(t.status));
|
||||
|
||||
if (!activeTaskRow && tasks.length > 0) {
|
||||
// All tasks done but slice not marked complete → summarizing
|
||||
return {
|
||||
activeMilestone, activeSlice, activeTask: null,
|
||||
phase: 'summarizing',
|
||||
recentDecisions: [], blockers: [],
|
||||
phase: 'summarizing', recentDecisions: [], blockers: [],
|
||||
nextAction: `All tasks done in ${activeSlice.id}. Write slice summary and complete slice.`,
|
||||
registry, requirements,
|
||||
progress: { milestones: milestoneProgress, slices: sliceProgress, tasks: taskProgress },
|
||||
};
|
||||
}
|
||||
|
||||
// Empty plan — no tasks defined yet
|
||||
if (!activeTaskRow) {
|
||||
return {
|
||||
activeMilestone, activeSlice, activeTask: null,
|
||||
phase: 'planning',
|
||||
recentDecisions: [], blockers: [],
|
||||
phase: 'planning', recentDecisions: [], blockers: [],
|
||||
nextAction: `Slice ${activeSlice.id} has a plan file but no tasks. Add tasks to the plan.`,
|
||||
registry, requirements,
|
||||
progress: { milestones: milestoneProgress, slices: sliceProgress, tasks: taskProgress },
|
||||
|
|
@ -881,15 +850,13 @@ export async function deriveStateFromDb(basePath: string): Promise<GSDState> {
|
|||
|
||||
const activeTask: ActiveRef = { id: activeTaskRow.id, title: activeTaskRow.title };
|
||||
|
||||
// ── Task plan file check (#909) ─────────────────────────────────────
|
||||
const tasksDir = resolveTasksDir(basePath, activeMilestone.id, activeSlice.id);
|
||||
if (tasksDir && existsSync(tasksDir) && tasks.length > 0) {
|
||||
const allFiles = readdirSync(tasksDir).filter(f => f.endsWith(".md"));
|
||||
if (allFiles.length === 0) {
|
||||
return {
|
||||
activeMilestone, activeSlice, activeTask: null,
|
||||
phase: 'planning',
|
||||
recentDecisions: [], blockers: [],
|
||||
phase: 'planning', recentDecisions: [], blockers: [],
|
||||
nextAction: `Task plan files missing for ${activeSlice.id}. Run plan-slice to generate task plans.`,
|
||||
registry, requirements,
|
||||
progress: { milestones: milestoneProgress, slices: sliceProgress, tasks: taskProgress },
|
||||
|
|
@ -897,51 +864,24 @@ export async function deriveStateFromDb(basePath: string): Promise<GSDState> {
|
|||
}
|
||||
}
|
||||
|
||||
// ── Quality gate evaluation check ──────────────────────────────────
|
||||
// If slice-scoped gates (Q3/Q4) are still pending, pause before execution
|
||||
// so the gate-evaluate dispatch rule can run parallel sub-agents.
|
||||
// Slices with zero gate rows (pre-feature or simple) skip straight through.
|
||||
const pendingGateCount = getPendingSliceGateCount(activeMilestone.id, activeSlice.id);
|
||||
if (pendingGateCount > 0) {
|
||||
return {
|
||||
activeMilestone, activeSlice, activeTask: null,
|
||||
phase: 'evaluating-gates',
|
||||
recentDecisions: [], blockers: [],
|
||||
phase: 'evaluating-gates', recentDecisions: [], blockers: [],
|
||||
nextAction: `Evaluate ${pendingGateCount} quality gate(s) for ${activeSlice.id} before execution.`,
|
||||
registry, requirements,
|
||||
progress: { milestones: milestoneProgress, slices: sliceProgress, tasks: taskProgress },
|
||||
};
|
||||
}
|
||||
|
||||
// ── Blocker detection: check completed tasks for blocker_discovered ──
|
||||
const completedTasks = tasks.filter(t => isStatusDone(t.status));
|
||||
let blockerTaskId: string | null = null;
|
||||
for (const ct of completedTasks) {
|
||||
if (ct.blocker_discovered) {
|
||||
blockerTaskId = ct.id;
|
||||
break;
|
||||
}
|
||||
// Also check disk summary in case DB doesn't have the flag
|
||||
const summaryFile = resolveTaskFile(basePath, activeMilestone.id, activeSlice.id, ct.id, "SUMMARY");
|
||||
if (!summaryFile) continue;
|
||||
const summaryContent = await loadFile(summaryFile);
|
||||
if (!summaryContent) continue;
|
||||
const summary = parseSummary(summaryContent);
|
||||
if (summary.frontmatter.blocker_discovered) {
|
||||
blockerTaskId = ct.id;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
const blockerTaskId = await detectBlockers(basePath, activeMilestone.id, activeSlice.id, tasks);
|
||||
if (blockerTaskId) {
|
||||
// Loop protection: if replan_history has entries for this slice, a replan
|
||||
// was already performed — don't re-enter replanning phase.
|
||||
const replanHistory = getReplanHistory(activeMilestone.id, activeSlice.id);
|
||||
if (replanHistory.length === 0) {
|
||||
return {
|
||||
activeMilestone, activeSlice, activeTask,
|
||||
phase: 'replanning-slice',
|
||||
recentDecisions: [],
|
||||
phase: 'replanning-slice', recentDecisions: [],
|
||||
blockers: [`Task ${blockerTaskId} discovered a blocker requiring slice replan`],
|
||||
nextAction: `Task ${blockerTaskId} reported blocker_discovered. Replan slice ${activeSlice.id} before continuing.`,
|
||||
activeWorkspace: undefined,
|
||||
|
|
@ -951,22 +891,14 @@ export async function deriveStateFromDb(basePath: string): Promise<GSDState> {
|
|||
}
|
||||
}
|
||||
|
||||
// ── REPLAN-TRIGGER detection ─────────────────────────────────────────
|
||||
if (!blockerTaskId) {
|
||||
const sliceRow = getSlice(activeMilestone.id, activeSlice.id);
|
||||
// Check DB column first, fall back to disk trigger file when DB write
|
||||
// was best-effort and failed (triage-resolution.ts dual-write gap).
|
||||
const dbTriggered = !!sliceRow?.replan_triggered_at;
|
||||
const diskTriggered = !dbTriggered &&
|
||||
!!resolveSliceFile(basePath, activeMilestone.id, activeSlice.id, "REPLAN-TRIGGER");
|
||||
if (dbTriggered || diskTriggered) {
|
||||
// Loop protection: if replan_history has entries, replan was already done
|
||||
const isTriggered = checkReplanTrigger(basePath, activeMilestone.id, activeSlice.id);
|
||||
if (isTriggered) {
|
||||
const replanHistory = getReplanHistory(activeMilestone.id, activeSlice.id);
|
||||
if (replanHistory.length === 0) {
|
||||
return {
|
||||
activeMilestone, activeSlice, activeTask,
|
||||
phase: 'replanning-slice',
|
||||
recentDecisions: [],
|
||||
phase: 'replanning-slice', recentDecisions: [],
|
||||
blockers: ['Triage replan trigger detected — slice replan required'],
|
||||
nextAction: `Triage replan triggered for slice ${activeSlice.id}. Replan before continuing.`,
|
||||
activeWorkspace: undefined,
|
||||
|
|
@ -977,16 +909,11 @@ export async function deriveStateFromDb(basePath: string): Promise<GSDState> {
|
|||
}
|
||||
}
|
||||
|
||||
// ── Check for interrupted work ───────────────────────────────────────
|
||||
const sDir = resolveSlicePath(basePath, activeMilestone.id, activeSlice.id);
|
||||
const continueFile = sDir ? resolveSliceFile(basePath, activeMilestone.id, activeSlice.id, "CONTINUE") : null;
|
||||
const hasInterrupted = !!(continueFile && await loadFile(continueFile)) ||
|
||||
!!(sDir && await loadFile(join(sDir, "continue.md")));
|
||||
const hasInterrupted = await checkInterruptedWork(basePath, activeMilestone.id, activeSlice.id);
|
||||
|
||||
return {
|
||||
activeMilestone, activeSlice, activeTask,
|
||||
phase: 'executing',
|
||||
recentDecisions: [], blockers: [],
|
||||
phase: 'executing', recentDecisions: [], blockers: [],
|
||||
nextAction: hasInterrupted
|
||||
? `Resume interrupted work on ${activeTask.id}: ${activeTask.title} in slice ${activeSlice.id}. Read continue.md first.`
|
||||
: `Execute ${activeTask.id}: ${activeTask.title} in slice ${activeSlice.id}.`,
|
||||
|
|
@ -995,11 +922,14 @@ export async function deriveStateFromDb(basePath: string): Promise<GSDState> {
|
|||
};
|
||||
}
|
||||
|
||||
|
||||
// LEGACY: Filesystem-based state derivation for unmigrated projects.
|
||||
// DB-backed projects use deriveStateFromDb() above. Target: extract to
|
||||
// state-legacy.ts when all projects are DB-backed.
|
||||
export async function _deriveStateImpl(basePath: string): Promise<GSDState> {
|
||||
const milestoneIds = findMilestoneIds(basePath);
|
||||
const diskIds = findMilestoneIds(basePath);
|
||||
const customOrder = loadQueueOrder(basePath);
|
||||
const milestoneIds = sortByQueueOrder(diskIds, customOrder);
|
||||
|
||||
// ── Parallel worker isolation ──────────────────────────────────────────
|
||||
// When GSD_MILESTONE_LOCK is set, this process is a parallel worker
|
||||
|
|
|
|||
436
src/resources/extensions/gsd/tests/derive-state-helpers.test.ts
Normal file
436
src/resources/extensions/gsd/tests/derive-state-helpers.test.ts
Normal file
|
|
@ -0,0 +1,436 @@
|
|||
// GSD Extension — Tests for extracted deriveStateFromDb helper functions
|
||||
// Copyright (c) 2026 Jeremy McSpadden <jeremy@fluxlabs.net>
|
||||
//
|
||||
// Tests the composable helpers extracted from deriveStateFromDb:
|
||||
// reconcileDiskToDb, buildCompletenessSet, buildRegistryAndFindActive,
|
||||
// handleNoActiveMilestone, resolveSliceDependencies, reconcileSliceTasks,
|
||||
// detectBlockers, checkReplanTrigger, checkInterruptedWork
|
||||
//
|
||||
// Helpers are private — exercised through deriveStateFromDb integration.
|
||||
|
||||
import { describe, test, beforeEach, afterEach } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { mkdtempSync, mkdirSync, rmSync, writeFileSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
import { tmpdir } from 'node:os';
|
||||
|
||||
import { invalidateStateCache, deriveStateFromDb } from '../state.ts';
|
||||
import {
|
||||
openDatabase,
|
||||
closeDatabase,
|
||||
insertMilestone,
|
||||
insertSlice,
|
||||
insertTask,
|
||||
updateTaskStatus,
|
||||
} from '../gsd-db.ts';
|
||||
|
||||
// ─── Fixture Helpers ───────────────────────────────────────────────────────
|
||||
|
||||
function createFixtureBase(): string {
|
||||
const base = mkdtempSync(join(tmpdir(), 'gsd-helpers-'));
|
||||
mkdirSync(join(base, '.gsd', 'milestones'), { recursive: true });
|
||||
return base;
|
||||
}
|
||||
|
||||
function writeFile(base: string, relativePath: string, content: string): void {
|
||||
const full = join(base, '.gsd', relativePath);
|
||||
mkdirSync(join(full, '..'), { recursive: true });
|
||||
writeFileSync(full, content);
|
||||
}
|
||||
|
||||
function cleanup(base: string): void {
|
||||
rmSync(base, { recursive: true, force: true });
|
||||
}
|
||||
|
||||
const ROADMAP_CONTENT = `# M001: Test Milestone
|
||||
|
||||
**Vision:** Test helpers.
|
||||
|
||||
## Slices
|
||||
|
||||
- [ ] **S01: First Slice** \`risk:low\` \`depends:[]\`
|
||||
> After this: Slice done.
|
||||
|
||||
- [ ] **S02: Second Slice** \`risk:low\` \`depends:[S01]\`
|
||||
> After this: All done.
|
||||
`;
|
||||
|
||||
const PLAN_CONTENT = `# S01: First Slice
|
||||
|
||||
**Goal:** Test executing.
|
||||
**Demo:** Tests pass.
|
||||
|
||||
## Tasks
|
||||
|
||||
- [ ] **T01: First Task** \`est:10m\`
|
||||
First task description.
|
||||
|
||||
- [x] **T02: Done Task** \`est:10m\`
|
||||
Already done.
|
||||
`;
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
// Tests
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
|
||||
describe('derive-state-helpers', () => {
|
||||
|
||||
// ─── handleNoActiveMilestone: all parked ─────────────────────────────
|
||||
test('handleNoActiveMilestone: all milestones parked returns pre-planning with unpark hint', async () => {
|
||||
const base = createFixtureBase();
|
||||
try {
|
||||
writeFile(base, 'milestones/M001/M001-CONTEXT.md', '# M001\n\nContext.');
|
||||
writeFile(base, 'milestones/M001/M001-PARKED.md', 'Parked.');
|
||||
writeFile(base, 'milestones/M002/M002-CONTEXT.md', '# M002\n\nContext.');
|
||||
writeFile(base, 'milestones/M002/M002-PARKED.md', 'Also parked.');
|
||||
|
||||
openDatabase(':memory:');
|
||||
insertMilestone({ id: 'M001', title: 'First', status: 'parked' });
|
||||
insertMilestone({ id: 'M002', title: 'Second', status: 'parked' });
|
||||
|
||||
invalidateStateCache();
|
||||
const state = await deriveStateFromDb(base);
|
||||
|
||||
assert.equal(state.phase, 'pre-planning', 'all-parked: phase is pre-planning');
|
||||
assert.equal(state.activeMilestone, null, 'all-parked: no active milestone');
|
||||
assert.ok(state.nextAction.includes('parked'), 'all-parked: nextAction mentions parked');
|
||||
assert.ok(state.nextAction.includes('unpark'), 'all-parked: nextAction hints unpark');
|
||||
assert.equal(state.registry.length, 2, 'all-parked: both in registry');
|
||||
assert.ok(state.registry.every(e => e.status === 'parked'), 'all-parked: all registry entries parked');
|
||||
} finally {
|
||||
closeDatabase();
|
||||
cleanup(base);
|
||||
}
|
||||
});
|
||||
|
||||
// ─── handleNoActiveMilestone: all complete with active requirements ──
|
||||
test('handleNoActiveMilestone: all complete with unmapped requirements', async () => {
|
||||
const base = createFixtureBase();
|
||||
try {
|
||||
writeFile(base, 'milestones/M001/M001-SUMMARY.md', '# M001 Summary\n\nDone.');
|
||||
writeFile(base, 'REQUIREMENTS.md', `# Requirements\n\n## Active\n\n### R001 — Unmapped\n- Status: active\n- Description: Not mapped.\n`);
|
||||
|
||||
openDatabase(':memory:');
|
||||
insertMilestone({ id: 'M001', title: 'First', status: 'complete' });
|
||||
|
||||
invalidateStateCache();
|
||||
const state = await deriveStateFromDb(base);
|
||||
|
||||
assert.equal(state.phase, 'complete', 'complete-reqs: phase is complete');
|
||||
assert.ok(state.nextAction.includes('1 active requirement'), 'complete-reqs: nextAction notes unmapped reqs');
|
||||
assert.equal(state.requirements?.active, 1, 'complete-reqs: requirements.active = 1');
|
||||
} finally {
|
||||
closeDatabase();
|
||||
cleanup(base);
|
||||
}
|
||||
});
|
||||
|
||||
// ─── resolveSliceDependencies: GSD_SLICE_LOCK with missing slice ────
|
||||
test('resolveSliceDependencies: GSD_SLICE_LOCK pointing to non-existent slice returns blocked', async () => {
|
||||
const base = createFixtureBase();
|
||||
const origLock = process.env.GSD_SLICE_LOCK;
|
||||
try {
|
||||
writeFile(base, 'milestones/M001/M001-ROADMAP.md', ROADMAP_CONTENT);
|
||||
writeFile(base, 'milestones/M001/slices/S01/S01-PLAN.md', PLAN_CONTENT);
|
||||
writeFile(base, 'milestones/M001/slices/S01/tasks/.gitkeep', '');
|
||||
writeFile(base, 'milestones/M001/slices/S01/tasks/T01-PLAN.md', '# T01 Plan');
|
||||
|
||||
openDatabase(':memory:');
|
||||
insertMilestone({ id: 'M001', title: 'Test', status: 'active' });
|
||||
insertSlice({ id: 'S01', milestoneId: 'M001', title: 'First', status: 'active', risk: 'low', depends: [] });
|
||||
insertTask({ id: 'T01', sliceId: 'S01', milestoneId: 'M001', title: 'First Task', status: 'pending' });
|
||||
|
||||
process.env.GSD_SLICE_LOCK = 'S99';
|
||||
|
||||
invalidateStateCache();
|
||||
const state = await deriveStateFromDb(base);
|
||||
|
||||
assert.equal(state.phase, 'blocked', 'slice-lock-miss: phase is blocked');
|
||||
assert.ok(state.blockers.some(b => b.includes('GSD_SLICE_LOCK=S99')), 'slice-lock-miss: blocker mentions lock');
|
||||
} finally {
|
||||
if (origLock !== undefined) process.env.GSD_SLICE_LOCK = origLock;
|
||||
else delete process.env.GSD_SLICE_LOCK;
|
||||
closeDatabase();
|
||||
cleanup(base);
|
||||
}
|
||||
});
|
||||
|
||||
// ─── resolveSliceDependencies: GSD_SLICE_LOCK with valid slice ──────
|
||||
test('resolveSliceDependencies: GSD_SLICE_LOCK targeting valid slice bypasses deps', async () => {
|
||||
const base = createFixtureBase();
|
||||
const origLock = process.env.GSD_SLICE_LOCK;
|
||||
try {
|
||||
writeFile(base, 'milestones/M001/M001-ROADMAP.md', ROADMAP_CONTENT);
|
||||
// S02 depends on S01 but we lock to S02 directly
|
||||
writeFile(base, 'milestones/M001/slices/S02/S02-PLAN.md', `# S02\n\n**Goal:** Test.\n**Demo:** Pass.\n\n## Tasks\n\n- [ ] **T01: Task** \`est:5m\`\n Do thing.\n`);
|
||||
writeFile(base, 'milestones/M001/slices/S02/tasks/.gitkeep', '');
|
||||
writeFile(base, 'milestones/M001/slices/S02/tasks/T01-PLAN.md', '# T01 Plan');
|
||||
|
||||
openDatabase(':memory:');
|
||||
insertMilestone({ id: 'M001', title: 'Test', status: 'active' });
|
||||
insertSlice({ id: 'S01', milestoneId: 'M001', title: 'First', status: 'pending', risk: 'low', depends: [] });
|
||||
insertSlice({ id: 'S02', milestoneId: 'M001', title: 'Second', status: 'pending', risk: 'low', depends: ['S01'] });
|
||||
insertTask({ id: 'T01', sliceId: 'S02', milestoneId: 'M001', title: 'Task', status: 'pending' });
|
||||
|
||||
process.env.GSD_SLICE_LOCK = 'S02';
|
||||
|
||||
invalidateStateCache();
|
||||
const state = await deriveStateFromDb(base);
|
||||
|
||||
assert.equal(state.activeSlice?.id, 'S02', 'slice-lock-valid: activeSlice is S02 (locked)');
|
||||
assert.equal(state.phase, 'executing', 'slice-lock-valid: phase is executing');
|
||||
} finally {
|
||||
if (origLock !== undefined) process.env.GSD_SLICE_LOCK = origLock;
|
||||
else delete process.env.GSD_SLICE_LOCK;
|
||||
closeDatabase();
|
||||
cleanup(base);
|
||||
}
|
||||
});
|
||||
|
||||
// ─── reconcileSliceTasks: plan file imports tasks when DB empty ──────
|
||||
test('reconcileSliceTasks: imports tasks from plan file when DB has zero tasks (#3600)', async () => {
|
||||
const base = createFixtureBase();
|
||||
try {
|
||||
writeFile(base, 'milestones/M001/M001-ROADMAP.md', ROADMAP_CONTENT);
|
||||
writeFile(base, 'milestones/M001/slices/S01/S01-PLAN.md', PLAN_CONTENT);
|
||||
writeFile(base, 'milestones/M001/slices/S01/tasks/.gitkeep', '');
|
||||
writeFile(base, 'milestones/M001/slices/S01/tasks/T01-PLAN.md', '# T01 Plan');
|
||||
|
||||
openDatabase(':memory:');
|
||||
insertMilestone({ id: 'M001', title: 'Test', status: 'active' });
|
||||
insertSlice({ id: 'S01', milestoneId: 'M001', title: 'First', status: 'active', risk: 'low', depends: [] });
|
||||
insertSlice({ id: 'S02', milestoneId: 'M001', title: 'Second', status: 'pending', risk: 'low', depends: ['S01'] });
|
||||
// No tasks inserted — reconcileSliceTasks should import from plan file
|
||||
|
||||
invalidateStateCache();
|
||||
const state = await deriveStateFromDb(base);
|
||||
|
||||
// Plan has T01 (pending) and T02 (done) — reconciliation imports both
|
||||
assert.equal(state.phase, 'executing', 'task-reconcile: phase is executing (tasks imported)');
|
||||
assert.equal(state.activeTask?.id, 'T01', 'task-reconcile: activeTask is T01');
|
||||
assert.equal(state.progress?.tasks?.total, 2, 'task-reconcile: total tasks = 2');
|
||||
assert.equal(state.progress?.tasks?.done, 1, 'task-reconcile: done tasks = 1 (T02 was [x])');
|
||||
} finally {
|
||||
closeDatabase();
|
||||
cleanup(base);
|
||||
}
|
||||
});
|
||||
|
||||
// ─── reconcileSliceTasks: stale task reconciled from disk summary ────
|
||||
test('reconcileSliceTasks: stale pending task reconciled to complete when disk SUMMARY exists (#2514)', async () => {
|
||||
const base = createFixtureBase();
|
||||
try {
|
||||
writeFile(base, 'milestones/M001/M001-ROADMAP.md', ROADMAP_CONTENT);
|
||||
writeFile(base, 'milestones/M001/slices/S01/S01-PLAN.md', PLAN_CONTENT);
|
||||
writeFile(base, 'milestones/M001/slices/S01/tasks/.gitkeep', '');
|
||||
writeFile(base, 'milestones/M001/slices/S01/tasks/T01-PLAN.md', '# T01 Plan');
|
||||
// T01 has a summary on disk but DB still says pending
|
||||
writeFile(base, 'milestones/M001/slices/S01/tasks/T01-SUMMARY.md', '# T01 Summary\n\nDone on disk.');
|
||||
|
||||
openDatabase(':memory:');
|
||||
insertMilestone({ id: 'M001', title: 'Test', status: 'active' });
|
||||
insertSlice({ id: 'S01', milestoneId: 'M001', title: 'First', status: 'active', risk: 'low', depends: [] });
|
||||
insertSlice({ id: 'S02', milestoneId: 'M001', title: 'Second', status: 'pending', risk: 'low', depends: ['S01'] });
|
||||
insertTask({ id: 'T01', sliceId: 'S01', milestoneId: 'M001', title: 'First Task', status: 'pending' });
|
||||
insertTask({ id: 'T02', sliceId: 'S01', milestoneId: 'M001', title: 'Done Task', status: 'complete' });
|
||||
|
||||
invalidateStateCache();
|
||||
const state = await deriveStateFromDb(base);
|
||||
|
||||
// T01 should have been reconciled to complete (SUMMARY exists on disk)
|
||||
// Both tasks complete → phase should be summarizing
|
||||
assert.equal(state.phase, 'summarizing', 'stale-task: phase is summarizing (T01 reconciled)');
|
||||
assert.equal(state.activeTask, null, 'stale-task: no active task (all done)');
|
||||
assert.equal(state.progress?.tasks?.done, 2, 'stale-task: tasks.done = 2');
|
||||
} finally {
|
||||
closeDatabase();
|
||||
cleanup(base);
|
||||
}
|
||||
});
|
||||
|
||||
// ─── detectBlockers: blocker_discovered triggers replanning ──────────
|
||||
test('detectBlockers: task with blocker_discovered triggers replanning-slice', async () => {
|
||||
const base = createFixtureBase();
|
||||
try {
|
||||
writeFile(base, 'milestones/M001/M001-ROADMAP.md', ROADMAP_CONTENT);
|
||||
writeFile(base, 'milestones/M001/slices/S01/S01-PLAN.md', PLAN_CONTENT);
|
||||
writeFile(base, 'milestones/M001/slices/S01/tasks/.gitkeep', '');
|
||||
writeFile(base, 'milestones/M001/slices/S01/tasks/T01-PLAN.md', '# T01 Plan');
|
||||
// T02 completed with blocker discovered — written in summary frontmatter
|
||||
writeFile(base, 'milestones/M001/slices/S01/tasks/T02-SUMMARY.md',
|
||||
'---\nblocker_discovered: true\n---\n\n# T02 Summary\n\nFound a blocker.');
|
||||
|
||||
openDatabase(':memory:');
|
||||
insertMilestone({ id: 'M001', title: 'Test', status: 'active' });
|
||||
insertSlice({ id: 'S01', milestoneId: 'M001', title: 'First', status: 'active', risk: 'low', depends: [] });
|
||||
insertSlice({ id: 'S02', milestoneId: 'M001', title: 'Second', status: 'pending', risk: 'low', depends: ['S01'] });
|
||||
insertTask({ id: 'T01', sliceId: 'S01', milestoneId: 'M001', title: 'First Task', status: 'pending' });
|
||||
insertTask({ id: 'T02', sliceId: 'S01', milestoneId: 'M001', title: 'Done Task', status: 'complete' });
|
||||
|
||||
invalidateStateCache();
|
||||
const state = await deriveStateFromDb(base);
|
||||
|
||||
assert.equal(state.phase, 'replanning-slice', 'blocker: phase is replanning-slice');
|
||||
assert.ok(state.blockers.some(b => b.includes('T02')), 'blocker: blockers mention T02');
|
||||
} finally {
|
||||
closeDatabase();
|
||||
cleanup(base);
|
||||
}
|
||||
});
|
||||
|
||||
// ─── checkInterruptedWork: continue.md triggers resume hint ─────────
|
||||
test('checkInterruptedWork: continue.md present triggers resume nextAction', async () => {
|
||||
const base = createFixtureBase();
|
||||
try {
|
||||
writeFile(base, 'milestones/M001/M001-ROADMAP.md', ROADMAP_CONTENT);
|
||||
writeFile(base, 'milestones/M001/slices/S01/S01-PLAN.md', PLAN_CONTENT);
|
||||
writeFile(base, 'milestones/M001/slices/S01/tasks/.gitkeep', '');
|
||||
writeFile(base, 'milestones/M001/slices/S01/tasks/T01-PLAN.md', '# T01 Plan');
|
||||
writeFile(base, 'milestones/M001/slices/S01/S01-CONTINUE.md', 'Resume from here.');
|
||||
|
||||
openDatabase(':memory:');
|
||||
insertMilestone({ id: 'M001', title: 'Test', status: 'active' });
|
||||
insertSlice({ id: 'S01', milestoneId: 'M001', title: 'First', status: 'active', risk: 'low', depends: [] });
|
||||
insertSlice({ id: 'S02', milestoneId: 'M001', title: 'Second', status: 'pending', risk: 'low', depends: ['S01'] });
|
||||
insertTask({ id: 'T01', sliceId: 'S01', milestoneId: 'M001', title: 'First Task', status: 'pending' });
|
||||
insertTask({ id: 'T02', sliceId: 'S01', milestoneId: 'M001', title: 'Done Task', status: 'complete' });
|
||||
|
||||
invalidateStateCache();
|
||||
const state = await deriveStateFromDb(base);
|
||||
|
||||
assert.equal(state.phase, 'executing', 'continue: phase is still executing');
|
||||
assert.ok(state.nextAction.includes('Resume interrupted work'), 'continue: nextAction mentions resume');
|
||||
assert.ok(state.nextAction.includes('continue.md'), 'continue: nextAction mentions continue.md');
|
||||
} finally {
|
||||
closeDatabase();
|
||||
cleanup(base);
|
||||
}
|
||||
});
|
||||
|
||||
// ─── buildCompletenessSet: SUMMARY-on-disk marks complete ───────────
|
||||
test('buildCompletenessSet: milestone with SUMMARY on disk treated as complete', async () => {
|
||||
const base = createFixtureBase();
|
||||
try {
|
||||
// M001 has summary on disk but DB status is still 'active'
|
||||
writeFile(base, 'milestones/M001/M001-ROADMAP.md', ROADMAP_CONTENT);
|
||||
writeFile(base, 'milestones/M001/M001-SUMMARY.md', '# M001 Summary\n\nDone.');
|
||||
// M002 is the real active milestone
|
||||
writeFile(base, 'milestones/M002/M002-CONTEXT.md', '# M002\n\nActive.');
|
||||
|
||||
openDatabase(':memory:');
|
||||
insertMilestone({ id: 'M001', title: 'First', status: 'active' });
|
||||
insertMilestone({ id: 'M002', title: 'Second', status: 'active' });
|
||||
|
||||
invalidateStateCache();
|
||||
const state = await deriveStateFromDb(base);
|
||||
|
||||
// M001 should be complete (summary on disk), M002 should be active
|
||||
const m1 = state.registry.find(e => e.id === 'M001');
|
||||
assert.equal(m1?.status, 'complete', 'summary-disk: M001 marked complete via disk SUMMARY');
|
||||
assert.equal(state.activeMilestone?.id, 'M002', 'summary-disk: M002 is active');
|
||||
} finally {
|
||||
closeDatabase();
|
||||
cleanup(base);
|
||||
}
|
||||
});
|
||||
|
||||
// ─── reconcileDiskToDb: disk slices synced into DB (#2533) ──────────
|
||||
test('reconcileDiskToDb: slices in ROADMAP.md but missing from DB are auto-inserted (#2533)', async () => {
|
||||
const base = createFixtureBase();
|
||||
try {
|
||||
writeFile(base, 'milestones/M001/M001-ROADMAP.md', ROADMAP_CONTENT);
|
||||
|
||||
openDatabase(':memory:');
|
||||
insertMilestone({ id: 'M001', title: 'Test', status: 'active' });
|
||||
// No slices inserted — reconcileDiskToDb should insert from roadmap
|
||||
|
||||
invalidateStateCache();
|
||||
const state = await deriveStateFromDb(base);
|
||||
|
||||
// Slices should have been reconciled from roadmap, S01 should be the active slice
|
||||
assert.equal(state.activeMilestone?.id, 'M001', 'slice-reconcile: M001 is active');
|
||||
assert.equal(state.activeSlice?.id, 'S01', 'slice-reconcile: S01 reconciled and active');
|
||||
assert.ok((state.progress?.slices?.total ?? 0) >= 2, 'slice-reconcile: at least 2 slices reconciled');
|
||||
} finally {
|
||||
closeDatabase();
|
||||
cleanup(base);
|
||||
}
|
||||
});
|
||||
|
||||
// ─── Queue order: milestones sorted by custom queue order ───────────
|
||||
test('deriveStateFromDb respects custom queue order from QUEUE-ORDER.json', async () => {
|
||||
const base = createFixtureBase();
|
||||
try {
|
||||
// M003 should come first per queue order, M001 second
|
||||
const queueOrder = JSON.stringify({ order: ['M003', 'M001', 'M002'], updatedAt: new Date().toISOString() });
|
||||
writeFileSync(join(base, '.gsd', 'QUEUE-ORDER.json'), queueOrder);
|
||||
writeFile(base, 'milestones/M001/M001-CONTEXT.md', '# M001\n\nContext.');
|
||||
writeFile(base, 'milestones/M002/M002-CONTEXT.md', '# M002\n\nContext.');
|
||||
writeFile(base, 'milestones/M003/M003-CONTEXT.md', '# M003\n\nContext.');
|
||||
|
||||
openDatabase(':memory:');
|
||||
// Insert in natural order — queue ordering should override
|
||||
insertMilestone({ id: 'M001', title: 'First', status: 'active' });
|
||||
insertMilestone({ id: 'M002', title: 'Second', status: 'active' });
|
||||
insertMilestone({ id: 'M003', title: 'Third', status: 'active' });
|
||||
|
||||
invalidateStateCache();
|
||||
const state = await deriveStateFromDb(base);
|
||||
|
||||
// M003 should be the active milestone (first in queue)
|
||||
assert.equal(state.activeMilestone?.id, 'M003', 'queue-order: M003 is active (first in queue)');
|
||||
assert.equal(state.registry[0]?.id, 'M003', 'queue-order: registry[0] is M003');
|
||||
} finally {
|
||||
closeDatabase();
|
||||
cleanup(base);
|
||||
}
|
||||
});
|
||||
|
||||
// ─── handleAllSlicesDone: needs-remediation re-triggers validation ──
|
||||
test('handleAllSlicesDone: needs-remediation verdict triggers validating-milestone', async () => {
|
||||
const base = createFixtureBase();
|
||||
try {
|
||||
const doneRoadmap = `# M001: Remediation Test\n\n**Vision:** Test.\n\n## Slices\n\n- [x] **S01: Done** \`risk:low\` \`depends:[]\`\n > Done.\n`;
|
||||
writeFile(base, 'milestones/M001/M001-ROADMAP.md', doneRoadmap);
|
||||
writeFile(base, 'milestones/M001/M001-VALIDATION.md',
|
||||
'---\nverdict: needs-remediation\nremediation_round: 1\n---\n\n# Validation\nNeeds remediation.');
|
||||
|
||||
openDatabase(':memory:');
|
||||
insertMilestone({ id: 'M001', title: 'Remediation Test', status: 'active' });
|
||||
insertSlice({ id: 'S01', milestoneId: 'M001', title: 'Done', status: 'complete', risk: 'low', depends: [] });
|
||||
|
||||
invalidateStateCache();
|
||||
const state = await deriveStateFromDb(base);
|
||||
|
||||
assert.equal(state.phase, 'validating-milestone', 'remediation: phase is validating-milestone');
|
||||
assert.equal(state.activeMilestone?.id, 'M001', 'remediation: activeMilestone is M001');
|
||||
} finally {
|
||||
closeDatabase();
|
||||
cleanup(base);
|
||||
}
|
||||
});
|
||||
|
||||
// ─── Deferred queued shell: shell milestone deferred, real one promoted ──
|
||||
test('buildRegistryAndFindActive: queued shell deferred, later real milestone becomes active (#3470)', async () => {
|
||||
const base = createFixtureBase();
|
||||
try {
|
||||
// M001: queued shell — no content, no slices
|
||||
mkdirSync(join(base, '.gsd', 'milestones', 'M001'), { recursive: true });
|
||||
// M002: real milestone with context
|
||||
writeFile(base, 'milestones/M002/M002-CONTEXT.md', '# M002: Real\n\nActive milestone.');
|
||||
|
||||
openDatabase(':memory:');
|
||||
insertMilestone({ id: 'M001', title: 'Shell', status: 'queued' });
|
||||
insertMilestone({ id: 'M002', title: 'Real', status: 'active' });
|
||||
|
||||
invalidateStateCache();
|
||||
const state = await deriveStateFromDb(base);
|
||||
|
||||
// M002 should be active (M001 queued shell deferred)
|
||||
assert.equal(state.activeMilestone?.id, 'M002', 'deferred-shell: M002 is active (shell deferred)');
|
||||
} finally {
|
||||
closeDatabase();
|
||||
cleanup(base);
|
||||
}
|
||||
});
|
||||
});
|
||||
Loading…
Add table
Reference in a new issue