Merge pull request #1687 from dpearson2699/fix/stale-interrupted-session-resume

fix stale interrupted-session resume prompts
This commit is contained in:
Jeremy McSpadden 2026-04-10 07:36:47 -05:00 committed by GitHub
commit e7e27a8ad3
9 changed files with 1704 additions and 147 deletions

View file

@ -15,6 +15,7 @@ import type {
} from "@gsd/pi-coding-agent";
import { deriveState } from "./state.js";
import { loadFile, getManifestStatus } from "./files.js";
import type { InterruptedSessionAssessment } from "./interrupted-session.js";
import {
loadEffectiveGSDPreferences,
resolveSkillDiscoveryMode,
@ -23,16 +24,9 @@ import {
import { ensureGsdSymlink, isInheritedRepo, validateProjectId } from "./repo-identity.js";
import { migrateToExternalState, recoverFailedMigration } from "./migrate-external.js";
import { collectSecretsFromManifest } from "../get-secrets-from-user.js";
import { gsdRoot, resolveMilestoneFile, milestonesDir } from "./paths.js";
import { gsdRoot, resolveMilestoneFile } from "./paths.js";
import { invalidateAllCaches } from "./cache.js";
import { synthesizeCrashRecovery } from "./session-forensics.js";
import {
writeLock,
clearLock,
readCrashLock,
formatCrashInfo,
isLockProcessAlive,
} from "./crash-recovery.js";
import { writeLock, clearLock } from "./crash-recovery.js";
import {
acquireSessionLock,
releaseSessionLock,
@ -248,6 +242,7 @@ export async function bootstrapAutoSession(
verboseMode: boolean,
requestedStepMode: boolean,
deps: BootstrapDeps,
interrupted: InterruptedSessionAssessment,
): Promise<boolean> {
const {
shouldUseWorktreeIsolation,
@ -361,51 +356,6 @@ export async function bootstrapAutoSession(
loadEffectiveGSDPreferences()?.preferences?.git ?? {},
);
// Check for crash from previous session. Skip our own fresh bootstrap lock.
const crashLock = readCrashLock(base);
if (crashLock && crashLock.pid !== process.pid) {
if (isLockProcessAlive(crashLock)) {
ctx.ui.notify(
`Another auto-mode session (PID ${crashLock.pid}) appears to be running.\nStop it with \`kill ${crashLock.pid}\` before starting a new session.`,
"error",
);
return releaseLockAndReturn();
}
const recoveredMid = parseUnitId(crashLock.unitId).milestone;
const milestoneAlreadyComplete = recoveredMid
? !!resolveMilestoneFile(base, recoveredMid, "SUMMARY")
: false;
if (milestoneAlreadyComplete) {
ctx.ui.notify(
`Crash recovery: discarding stale context for ${crashLock.unitId} — milestone ${recoveredMid} is already complete.`,
"info",
);
} else {
const activityDir = join(gsdRoot(base), "activity");
const recovery = synthesizeCrashRecovery(
base,
crashLock.unitType,
crashLock.unitId,
crashLock.sessionFile,
activityDir,
);
if (recovery && recovery.trace.toolCallCount > 0) {
s.pendingCrashRecovery = recovery.prompt;
ctx.ui.notify(
`${formatCrashInfo(crashLock)}\nRecovered ${recovery.trace.toolCallCount} tool calls from crashed session. Resuming with full context.`,
"warning",
);
} else {
ctx.ui.notify(
`${formatCrashInfo(crashLock)}\nNo session data recovered. Resuming from disk state.`,
"warning",
);
}
}
clearLock(base);
}
// ── Debug mode ──
if (!isDebugEnabled() && process.env.GSD_DEBUG === "1") {
enableDebug(base);
@ -425,6 +375,10 @@ export async function bootstrapAutoSession(
ctx.ui.notify(`Debug logging enabled → ${getDebugLogPath()}`, "info");
}
if (interrupted.classification !== "recoverable") {
s.pendingCrashRecovery = null;
}
// Invalidate caches before initial state derivation
invalidateAllCaches();

View file

@ -19,6 +19,11 @@ import type {
import { deriveState } from "./state.js";
import { parseUnitId } from "./unit-id.js";
import type { GSDState } from "./types.js";
import {
assessInterruptedSession,
readPausedSessionMetadata,
type InterruptedSessionAssessment,
} from "./interrupted-session.js";
import { getManifestStatus } from "./files.js";
export { inlinePriorMilestoneSummary } from "./files.js";
import { collectSecretsFromManifest } from "../get-secrets-from-user.js";
@ -46,6 +51,7 @@ import {
clearLock,
readCrashLock,
isLockProcessAlive,
formatCrashInfo,
} from "./crash-recovery.js";
import {
acquireSessionLock,
@ -921,6 +927,8 @@ export async function pauseAuto(
stepMode: s.stepMode,
pausedAt: new Date().toISOString(),
sessionFile: s.pausedSessionFile,
unitType: s.currentUnit?.type ?? undefined,
unitId: s.currentUnit?.id ?? undefined,
activeEngineId: s.activeEngineId,
activeRunDir: s.activeRunDir,
autoStartTime: s.autoStartTime,
@ -1142,7 +1150,10 @@ export async function startAuto(
pi: ExtensionAPI,
base: string,
verboseMode: boolean,
options?: { step?: boolean },
options?: {
step?: boolean;
interrupted?: InterruptedSessionAssessment;
},
): Promise<void> {
if (s.active) {
debugLog("startAuto", { phase: "already-active", skipping: true });
@ -1150,41 +1161,60 @@ export async function startAuto(
}
const requestedStepMode = options?.step ?? false;
const interruptedAssessment = options?.interrupted ?? null;
// Escape stale worktree cwd from a previous milestone (#608).
base = escapeStaleWorktree(base);
const freshStartAssessment = interruptedAssessment
?? await assessInterruptedSession(base);
if (freshStartAssessment.classification === "running") {
const pid = freshStartAssessment.lock?.pid;
ctx.ui.notify(
pid
? `Another auto-mode session (PID ${pid}) appears to be running.\nStop it with \`kill ${pid}\` before starting a new session.`
: "Another auto-mode session appears to be running.",
"error",
);
return;
}
// If resuming from paused state, just re-activate and dispatch next unit.
// Check persisted paused-session first (#1383) — survives /exit.
if (!s.paused) {
try {
const meta = freshStartAssessment.pausedSession ?? readPausedSessionMetadata(base);
const pausedPath = join(gsdRoot(base), "runtime", "paused-session.json");
if (existsSync(pausedPath)) {
const meta = JSON.parse(readFileSync(pausedPath, "utf-8"));
if (meta.activeEngineId && meta.activeEngineId !== "dev") {
// Custom workflow resume — restore engine state
s.activeEngineId = meta.activeEngineId;
s.activeRunDir = meta.activeRunDir ?? null;
s.originalBasePath = meta.originalBasePath || base;
s.stepMode = meta.stepMode ?? requestedStepMode;
s.autoStartTime = meta.autoStartTime || Date.now();
s.paused = true;
// Don't delete pause file yet — defer until lock is acquired.
// If lock fails, the file must survive for retry.
s.pausedSessionFile = pausedPath;
ctx.ui.notify(
`Resuming paused custom workflow${meta.activeRunDir ? ` (${meta.activeRunDir})` : ""}.`,
"info",
if (meta?.activeEngineId && meta.activeEngineId !== "dev") {
// Custom workflow resume — restore engine state
s.activeEngineId = meta.activeEngineId;
s.activeRunDir = meta.activeRunDir ?? null;
s.originalBasePath = meta.originalBasePath || base;
s.stepMode = meta.stepMode ?? requestedStepMode;
s.autoStartTime = meta.autoStartTime || Date.now();
s.paused = true;
try { unlinkSync(pausedPath); } catch (e) { logWarning("session", `pause file cleanup failed: ${e instanceof Error ? e.message : String(e)}`, { file: "auto.ts" }); }
ctx.ui.notify(
`Resuming paused custom workflow${meta.activeRunDir ? ` (${meta.activeRunDir})` : ""}.`,
"info",
);
} else if (meta?.milestoneId) {
const shouldResumePausedSession =
freshStartAssessment.classification === "recoverable"
&& (
freshStartAssessment.hasResumableDiskState
|| !!freshStartAssessment.recoveryPrompt
|| !!freshStartAssessment.lock
);
} else if (meta.milestoneId) {
if (shouldResumePausedSession) {
// Validate the milestone still exists and isn't already complete (#1664).
const mDir = resolveMilestonePath(base, meta.milestoneId);
const summaryFile = resolveMilestoneFile(base, meta.milestoneId, "SUMMARY");
if (!mDir || summaryFile) {
// Stale milestone — clean up and fall through to fresh bootstrap
try { unlinkSync(pausedPath); } catch (err) { /* non-fatal */
logWarning("session", `pause file cleanup failed: ${err instanceof Error ? err.message : String(err)}`, { file: "auto.ts" });
}
try { unlinkSync(pausedPath); } catch (err) {
logWarning("session", `pause file cleanup failed: ${err instanceof Error ? err.message : String(err)}`, { file: "auto.ts" });
}
ctx.ui.notify(
`Paused milestone ${meta.milestoneId} is ${!mDir ? "missing" : "already complete"}. Starting fresh.`,
"info",
@ -1193,22 +1223,54 @@ export async function startAuto(
s.currentMilestoneId = meta.milestoneId;
s.originalBasePath = meta.originalBasePath || base;
s.stepMode = meta.stepMode ?? requestedStepMode;
s.pausedSessionFile = meta.sessionFile ?? null;
s.pausedUnitType = meta.unitType ?? null;
s.pausedUnitId = meta.unitId ?? null;
s.autoStartTime = meta.autoStartTime || Date.now();
s.paused = true;
// Don't delete pause file yet — defer until lock is acquired.
// If lock fails, the file must survive for retry.
s.pausedSessionFile = pausedPath;
try { unlinkSync(pausedPath); } catch (e) { logWarning("session", `pause file cleanup failed: ${e instanceof Error ? e.message : String(e)}`, { file: "auto.ts" }); }
ctx.ui.notify(
`Resuming paused session for ${meta.milestoneId}${meta.worktreePath ? ` (worktree)` : ""}.`,
`Resuming paused session for ${meta.milestoneId}${meta.worktreePath && existsSync(meta.worktreePath) ? ` (worktree)` : ""}.`,
"info",
);
}
} else if (existsSync(pausedPath)) {
try { unlinkSync(pausedPath); } catch (e) { logWarning("session", `stale pause file cleanup failed: ${e instanceof Error ? e.message : String(e)}`, { file: "auto.ts" }); }
}
}
} catch (err) {
// Malformed or missing — proceed with fresh bootstrap
logWarning("session", `paused-session restore failed: ${err instanceof Error ? err.message : String(err)}`, { file: "auto.ts" });
}
// Guard against zero/missing autoStartTime after resume (#3585)
if (!s.autoStartTime || s.autoStartTime <= 0) s.autoStartTime = Date.now();
}
if (!s.paused) {
s.stepMode = requestedStepMode;
}
if (freshStartAssessment.lock) {
clearLock(base);
}
if (!s.paused) {
s.pendingCrashRecovery =
freshStartAssessment.classification === "recoverable"
? freshStartAssessment.recoveryPrompt
: null;
if (freshStartAssessment.classification === "recoverable" && freshStartAssessment.lock) {
const info = formatCrashInfo(freshStartAssessment.lock);
if (freshStartAssessment.recoveryToolCallCount > 0) {
ctx.ui.notify(
`${info}\nRecovered ${freshStartAssessment.recoveryToolCallCount} tool calls from crashed session. Resuming with full context.`,
"warning",
);
} else if (freshStartAssessment.hasResumableDiskState) {
ctx.ui.notify(`${info}\nResuming from disk state.`, "warning");
}
}
}
if (s.paused) {
@ -1233,26 +1295,19 @@ export async function startAuto(
s.active = true;
s.verbose = verboseMode;
s.stepMode = requestedStepMode;
// Preserve the original cmdCtx (ExtensionCommandContext with newSession)
// when resuming from a provider-error pause. The resume callback receives
// an ExtensionContext (from the agent_end hook) which lacks newSession —
// using it would crash runUnit with "newSession is not a function".
// Only override if the new ctx actually has newSession (user-initiated resume).
if ("newSession" in ctx && typeof (ctx as any).newSession === "function") {
s.cmdCtx = ctx;
} else if (!s.cmdCtx) {
// No saved cmdCtx — this shouldn't happen, but handle gracefully
s.cmdCtx = ctx as ExtensionCommandContext;
}
// else: keep existing s.cmdCtx which has the real newSession
s.cmdCtx = ctx;
s.basePath = base;
setLogBasePath(base);
if (!s.autoStartTime || s.autoStartTime <= 0) s.autoStartTime = Date.now();
s.unitDispatchCount.clear();
s.unitLifetimeDispatches.clear();
if (!getLedger()) initMetrics(base);
if (s.currentMilestoneId) setActiveMilestoneId(base, s.currentMilestoneId);
// Re-register health level notification callback lost across process restart
setLevelChangeCallback((_from, to, summary) => {
const level = to === "red" ? "error" : to === "yellow" ? "warning" : "info";
ctx.ui.notify(summary, level as "info" | "warning" | "error");
});
// ── Auto-worktree: re-enter worktree on resume ──
if (
s.currentMilestoneId &&
@ -1311,8 +1366,8 @@ export async function startAuto(
const activityDir = join(gsdRoot(s.basePath), "activity");
const recovery = synthesizeCrashRecovery(
s.basePath,
s.currentUnit?.type ?? "unknown",
s.currentUnit?.id ?? "unknown",
s.currentUnit?.type ?? s.pausedUnitType ?? "unknown",
s.currentUnit?.id ?? s.pausedUnitId ?? "unknown",
s.pausedSessionFile ?? undefined,
activityDir,
);
@ -1360,6 +1415,7 @@ export async function startAuto(
verboseMode,
requestedStepMode,
bootstrapDeps,
freshStartAssessment,
);
if (!ready) return;
@ -1473,27 +1529,6 @@ function ensurePreconditions(
}
}
// ─── Diagnostics ──────────────────────────────────────────────────────────────
/** Build recovery context from module state for recoverTimedOutUnit */
function buildRecoveryContext(): import("./auto-timeout-recovery.js").RecoveryContext {
return {
basePath: s.basePath,
verbose: s.verbose,
currentUnitStartedAt: s.currentUnit?.startedAt ?? Date.now(),
unitRecoveryCount: s.unitRecoveryCount,
};
}
/**
* Test-only: expose skip-loop state for unit tests.
* Not part of the public API.
*/
/**
* Dispatch a hook unit directly, bypassing normal pre-dispatch hooks.
* Used for manual hook triggers via /gsd run-hook.
*/
export async function dispatchHookUnit(
ctx: ExtensionContext,
pi: ExtensionAPI,

View file

@ -119,6 +119,8 @@ export class AutoSession {
pendingVerificationRetry: PendingVerificationRetry | null = null;
readonly verificationRetryCount = new Map<string, number>();
pausedSessionFile: string | null = null;
pausedUnitType: string | null = null;
pausedUnitId: string | null = null;
resourceVersionOnStart: string | null = null;
lastStateRebuildAt = 0;
@ -223,6 +225,8 @@ export class AutoSession {
this.pendingVerificationRetry = null;
this.verificationRetryCount.clear();
this.pausedSessionFile = null;
this.pausedUnitType = null;
this.pausedUnitId = null;
this.resourceVersionOnStart = null;
this.lastStateRebuildAt = 0;

View file

@ -16,7 +16,12 @@ import { buildSkillActivationBlock } from "./auto-prompts.js";
import { deriveState } from "./state.js";
import { invalidateAllCaches } from "./cache.js";
import { startAuto } from "./auto.js";
import { readCrashLock, clearLock, formatCrashInfo } from "./crash-recovery.js";
import { clearLock } from "./crash-recovery.js";
import {
assessInterruptedSession,
formatInterruptedSessionRunningMessage,
formatInterruptedSessionSummary,
} from "./interrupted-session.js";
import { listUnitRuntimeRecords, clearUnitRuntimeRecord } from "./unit-runtime.js";
import { resolveExpectedArtifactPath } from "./auto.js";
import {
@ -1314,36 +1319,45 @@ export async function showSmartEntry(
// ── Self-heal stale runtime records from crashed auto-mode sessions ──
selfHealRuntimeRecords(basePath, ctx);
// Check for crash from previous auto-mode session.
// Skip if the lock was written by the current process — acquireSessionLock()
// writes to the same file, so we'd always false-positive (#1398).
const crashLock = readCrashLock(basePath);
if (crashLock && crashLock.pid !== process.pid) {
const interrupted = await assessInterruptedSession(basePath);
if (interrupted.classification === "running") {
ctx.ui.notify(formatInterruptedSessionRunningMessage(interrupted), "error");
return;
}
if (interrupted.classification === "stale") {
clearLock(basePath);
// Bootstrap crash with zero completed units = no work was lost.
// Auto-discard instead of prompting the user — this commonly happens
// when the user exits during init wizard or discuss phase before any
// real auto-mode work begins.
const isBootstrapCrash = crashLock.unitType === "starting"
&& crashLock.unitId === "bootstrap";
if (!isBootstrapCrash) {
const resume = await showNextAction(ctx, {
title: "GSD — Interrupted Session Detected",
summary: [formatCrashInfo(crashLock)],
actions: [
{ id: "resume", label: "Resume with /gsd auto", description: "Pick up where it left off", recommended: true },
{ id: "continue", label: "Continue manually", description: "Open the wizard as normal" },
],
});
if (resume === "resume") {
await startAuto(ctx, pi, basePath, false);
return;
if (interrupted.pausedSession) {
try {
unlinkSync(join(gsdRoot(basePath), "runtime", "paused-session.json"));
} catch (e) {
logWarning("guided", `stale pause file cleanup failed: ${(e as Error).message}`, { file: "guided-flow.ts" });
}
}
} else if (interrupted.classification === "recoverable") {
if (interrupted.lock) clearLock(basePath);
const resumeLabel = interrupted.pausedSession?.stepMode
? "Resume with /gsd next"
: "Resume with /gsd auto";
const resume = await showNextAction(ctx, {
title: "GSD — Interrupted Session Detected",
summary: formatInterruptedSessionSummary(interrupted),
actions: [
{ id: "resume", label: resumeLabel, description: "Pick up where it left off", recommended: true },
{ id: "continue", label: "Continue manually", description: "Open the wizard as normal" },
],
});
if (resume === "resume") {
await startAuto(ctx, pi, basePath, false, {
interrupted,
step: interrupted.pausedSession?.stepMode ?? false,
});
return;
}
}
// Always derive from the project root — the assessment may have derived
// state from a worktree path that was cleaned up in the stale branch above.
const state = await deriveState(basePath);
// Rebuild STATE.md from derived state before any dispatch (#3475).

View file

@ -0,0 +1,224 @@
import { existsSync, readFileSync } from "node:fs";
import { join } from "node:path";
import { verifyExpectedArtifact } from "./auto-recovery.js";
import {
formatCrashInfo,
isLockProcessAlive,
readCrashLock,
type LockData,
} from "./crash-recovery.js";
import { gsdRoot } from "./paths.js";
import {
synthesizeCrashRecovery,
type RecoveryBriefing,
} from "./session-forensics.js";
import { deriveState } from "./state.js";
import type { GSDState } from "./types.js";
export type InterruptedSessionClassification =
| "none"
| "running"
| "recoverable"
| "stale";
export interface PausedSessionMetadata {
milestoneId?: string;
worktreePath?: string | null;
originalBasePath?: string;
stepMode?: boolean;
pausedAt?: string;
sessionFile?: string | null;
unitType?: string;
unitId?: string;
activeEngineId?: string;
activeRunDir?: string | null;
autoStartTime?: number;
}
export interface InterruptedSessionAssessment {
classification: InterruptedSessionClassification;
lock: LockData | null;
pausedSession: PausedSessionMetadata | null;
state: GSDState | null;
recovery: RecoveryBriefing | null;
recoveryPrompt: string | null;
recoveryToolCallCount: number;
artifactSatisfied: boolean;
hasResumableDiskState: boolean;
isBootstrapCrash: boolean;
}
export function readPausedSessionMetadata(
basePath: string,
): PausedSessionMetadata | null {
const pausedPath = join(gsdRoot(basePath), "runtime", "paused-session.json");
if (!existsSync(pausedPath)) return null;
try {
return JSON.parse(readFileSync(pausedPath, "utf-8")) as PausedSessionMetadata;
} catch {
return null;
}
}
export function isBootstrapCrashLock(lock: LockData | null): boolean {
return !!(
lock &&
lock.unitType === "starting" &&
lock.unitId === "bootstrap"
);
}
export function hasResumableDerivedState(state: GSDState | null): boolean {
return !!(state?.activeMilestone && state.phase !== "complete");
}
export async function assessInterruptedSession(
basePath: string,
): Promise<InterruptedSessionAssessment> {
const pausedSession = readPausedSessionMetadata(basePath);
const worktreeExists = pausedSession?.worktreePath
? existsSync(pausedSession.worktreePath)
: false;
const assessmentBasePath = worktreeExists ? pausedSession!.worktreePath! : basePath;
const rawLock = readCrashLock(basePath);
const lock = rawLock && rawLock.pid !== process.pid ? rawLock : null;
if (!lock && !pausedSession) {
return {
classification: "none",
lock: null,
pausedSession: null,
state: null,
recovery: null,
recoveryPrompt: null,
recoveryToolCallCount: 0,
artifactSatisfied: false,
hasResumableDiskState: false,
isBootstrapCrash: false,
};
}
if (lock && isLockProcessAlive(lock)) {
return {
classification: "running",
lock,
pausedSession,
state: null,
recovery: null,
recoveryPrompt: null,
recoveryToolCallCount: 0,
artifactSatisfied: false,
hasResumableDiskState: false,
isBootstrapCrash: false,
};
}
const isBootstrapCrash = isBootstrapCrashLock(lock);
const state = await deriveState(assessmentBasePath);
const hasResumableDiskState = hasResumableDerivedState(state);
const artifactSatisfied = !!(
lock &&
!isBootstrapCrash &&
verifyExpectedArtifact(lock.unitType, lock.unitId, assessmentBasePath)
);
let recovery: RecoveryBriefing | null = null;
if (lock && !isBootstrapCrash && !artifactSatisfied) {
recovery = synthesizeCrashRecovery(
assessmentBasePath,
lock.unitType,
lock.unitId,
lock.sessionFile,
join(gsdRoot(assessmentBasePath), "activity"),
);
}
const recoveryToolCallCount = recovery?.trace.toolCallCount ?? 0;
const recoveryPrompt = recoveryToolCallCount > 0 ? recovery!.prompt : null;
if (isBootstrapCrash) {
return {
classification: pausedSession ? "recoverable" : "stale",
lock,
pausedSession,
state,
recovery,
recoveryPrompt,
recoveryToolCallCount,
artifactSatisfied,
hasResumableDiskState,
isBootstrapCrash: true,
};
}
if (!hasResumableDiskState && pausedSession && !lock && recoveryToolCallCount === 0) {
return {
classification: "stale",
lock,
pausedSession,
state,
recovery,
recoveryPrompt,
recoveryToolCallCount,
artifactSatisfied,
hasResumableDiskState,
isBootstrapCrash: false,
};
}
if (lock && artifactSatisfied && !hasResumableDiskState && recoveryToolCallCount === 0) {
return {
classification: "stale",
lock,
pausedSession,
state,
recovery,
recoveryPrompt,
recoveryToolCallCount,
artifactSatisfied,
hasResumableDiskState,
isBootstrapCrash: false,
};
}
const hasStrongRecoverySignal =
hasResumableDiskState || recoveryToolCallCount > 0;
return {
classification: hasStrongRecoverySignal ? "recoverable" : "stale",
lock,
pausedSession,
state,
recovery,
recoveryPrompt,
recoveryToolCallCount,
artifactSatisfied,
hasResumableDiskState,
isBootstrapCrash: false,
};
}
export function formatInterruptedSessionSummary(
assessment: InterruptedSessionAssessment,
): string[] {
if (assessment.lock) return [formatCrashInfo(assessment.lock)];
if (assessment.pausedSession?.milestoneId) {
return [
`Paused auto-mode session detected for ${assessment.pausedSession.milestoneId}.`,
];
}
return ["Paused auto-mode session detected."];
}
export function formatInterruptedSessionRunningMessage(
assessment: InterruptedSessionAssessment,
): string {
const pid = assessment.lock?.pid;
return pid
? `Another auto-mode session (PID ${pid}) appears to be running.\nStop it with \`kill ${pid}\` before starting a new session.`
: "Another auto-mode session appears to be running.";
}

View file

@ -1,14 +1,30 @@
import test, { afterEach } from "node:test";
import assert from "node:assert/strict";
import { mkdtempSync, mkdirSync, rmSync } from "node:fs";
import { mkdtempSync, mkdirSync, rmSync, writeFileSync, existsSync, readFileSync } from "node:fs";
import { join } from "node:path";
import { tmpdir } from "node:os";
import { randomUUID } from "node:crypto";
import { verifyExpectedArtifact } from "../auto-recovery.ts";
import { verifyExpectedArtifact, hasImplementationArtifacts, resolveExpectedArtifactPath, diagnoseExpectedArtifact, buildLoopRemediationSteps } from "../auto-recovery.ts";
import { openDatabase, closeDatabase, insertMilestone, insertSlice, insertGateRow } from "../gsd-db.ts";
import { clearParseCache } from "../files.ts";
import { parseRoadmap } from "../parsers-legacy.ts";
import { invalidateAllCaches } from "../cache.ts";
import { deriveState, invalidateStateCache } from "../state.ts";
const tmpDirs: string[] = [];
function makeTmpBase(): string {
const base = join(tmpdir(), `gsd-test-${randomUUID()}`);
// Create .gsd/milestones/M001/slices/S01/tasks/ structure
mkdirSync(join(base, ".gsd", "milestones", "M001", "slices", "S01", "tasks"), { recursive: true });
return base;
}
function cleanup(base: string): void {
try { rmSync(base, { recursive: true, force: true }); } catch { /* */ }
}
function makeTmpProject(): string {
const dir = mkdtempSync(join(tmpdir(), "auto-recovery-"));
mkdirSync(join(dir, ".gsd"), { recursive: true });
@ -39,6 +55,656 @@ afterEach(() => {
tmpDirs.length = 0;
});
test("resolveExpectedArtifactPath returns correct path for execute-task", () => {
const base = makeTmpBase();
try {
const result = resolveExpectedArtifactPath("execute-task", "M001/S01/T01", base);
assert.ok(result);
assert.ok(result!.includes("tasks"));
assert.ok(result!.includes("SUMMARY"));
} finally {
cleanup(base);
}
});
test("resolveExpectedArtifactPath returns correct path for complete-slice", () => {
const base = makeTmpBase();
try {
const result = resolveExpectedArtifactPath("complete-slice", "M001/S01", base);
assert.ok(result);
assert.ok(result!.includes("SUMMARY"));
} finally {
cleanup(base);
}
});
test("resolveExpectedArtifactPath returns correct path for plan-slice", () => {
const base = makeTmpBase();
try {
const result = resolveExpectedArtifactPath("plan-slice", "M001/S01", base);
assert.ok(result);
assert.ok(result!.includes("PLAN"));
} finally {
cleanup(base);
}
});
test("resolveExpectedArtifactPath returns null for unknown type", () => {
const base = makeTmpBase();
try {
const result = resolveExpectedArtifactPath("unknown-type", "M001", base);
assert.equal(result, null);
} finally {
cleanup(base);
}
});
test("resolveExpectedArtifactPath returns correct path for all milestone-level types", () => {
const base = makeTmpBase();
try {
const planResult = resolveExpectedArtifactPath("plan-milestone", "M001", base);
assert.ok(planResult);
assert.ok(planResult!.includes("ROADMAP"));
const completeResult = resolveExpectedArtifactPath("complete-milestone", "M001", base);
assert.ok(completeResult);
assert.ok(completeResult!.includes("SUMMARY"));
} finally {
cleanup(base);
}
});
test("resolveExpectedArtifactPath returns correct path for all slice-level types", () => {
const base = makeTmpBase();
try {
const researchResult = resolveExpectedArtifactPath("research-slice", "M001/S01", base);
assert.ok(researchResult);
assert.ok(researchResult!.includes("RESEARCH"));
const assessResult = resolveExpectedArtifactPath("reassess-roadmap", "M001/S01", base);
assert.ok(assessResult);
assert.ok(assessResult!.includes("ASSESSMENT"));
const uatResult = resolveExpectedArtifactPath("run-uat", "M001/S01", base);
assert.ok(uatResult);
assert.ok(uatResult!.includes("ASSESSMENT"));
} finally {
cleanup(base);
}
});
// ─── diagnoseExpectedArtifact ─────────────────────────────────────────────
test("diagnoseExpectedArtifact returns description for known types", () => {
const base = makeTmpBase();
try {
const research = diagnoseExpectedArtifact("research-milestone", "M001", base);
assert.ok(research);
assert.ok(research!.includes("research"));
const plan = diagnoseExpectedArtifact("plan-slice", "M001/S01", base);
assert.ok(plan);
assert.ok(plan!.includes("plan"));
const task = diagnoseExpectedArtifact("execute-task", "M001/S01/T01", base);
assert.ok(task);
assert.ok(task!.includes("T01"));
} finally {
cleanup(base);
}
});
test("diagnoseExpectedArtifact returns null for unknown type", () => {
const base = makeTmpBase();
try {
assert.equal(diagnoseExpectedArtifact("unknown", "M001", base), null);
} finally {
cleanup(base);
}
});
// ─── buildLoopRemediationSteps ────────────────────────────────────────────
test("buildLoopRemediationSteps returns steps for execute-task", () => {
const base = makeTmpBase();
try {
const steps = buildLoopRemediationSteps("execute-task", "M001/S01/T01", base);
assert.ok(steps);
assert.ok(steps!.includes("T01"));
assert.ok(steps!.includes("gsd undo-task"));
} finally {
cleanup(base);
}
});
test("buildLoopRemediationSteps returns steps for plan-slice", () => {
const base = makeTmpBase();
try {
const steps = buildLoopRemediationSteps("plan-slice", "M001/S01", base);
assert.ok(steps);
assert.ok(steps!.includes("PLAN"));
assert.ok(steps!.includes("gsd recover"));
} finally {
cleanup(base);
}
});
test("buildLoopRemediationSteps returns steps for complete-slice", () => {
const base = makeTmpBase();
try {
const steps = buildLoopRemediationSteps("complete-slice", "M001/S01", base);
assert.ok(steps);
assert.ok(steps!.includes("S01"));
assert.ok(steps!.includes("gsd reset-slice"));
} finally {
cleanup(base);
}
});
test("buildLoopRemediationSteps returns null for unknown type", () => {
const base = makeTmpBase();
try {
assert.equal(buildLoopRemediationSteps("unknown", "M001", base), null);
} finally {
cleanup(base);
}
});
// ─── verifyExpectedArtifact: parse cache collision regression ─────────────
test("verifyExpectedArtifact detects roadmap [x] change despite parse cache", () => {
// Regression test: cacheKey collision when [ ] → [x] doesn't change
// file length or first/last 100 chars. Without the fix, parseRoadmap
// returns stale cached data with done=false even though the file has [x].
const base = makeTmpBase();
try {
// Build a roadmap long enough that the [x] change is outside the first/last 100 chars
const padding = "A".repeat(200);
const roadmapBefore = [
`# M001: Test Milestone ${padding}`,
"",
"## Slices",
"",
"- [ ] **S01: First slice** `risk:low`",
"",
`## Footer ${padding}`,
].join("\n");
const roadmapAfter = roadmapBefore.replace("- [ ] **S01:", "- [x] **S01:");
// Verify lengths are identical (the key collision condition)
assert.equal(roadmapBefore.length, roadmapAfter.length);
// Populate parse cache with the pre-edit roadmap
const before = parseRoadmap(roadmapBefore);
const sliceBefore = before.slices.find(s => s.id === "S01");
assert.ok(sliceBefore);
assert.equal(sliceBefore!.done, false);
// Now write the post-edit roadmap to disk and create required artifacts
const roadmapPath = join(base, ".gsd", "milestones", "M001", "M001-ROADMAP.md");
writeFileSync(roadmapPath, roadmapAfter);
const summaryPath = join(base, ".gsd", "milestones", "M001", "slices", "S01", "S01-SUMMARY.md");
writeFileSync(summaryPath, "# Summary\nDone.");
const uatPath = join(base, ".gsd", "milestones", "M001", "slices", "S01", "S01-UAT.md");
writeFileSync(uatPath, "# UAT\nPassed.");
// verifyExpectedArtifact should see the [x] despite the parse cache
// having the [ ] version. The fix clears the parse cache inside verify.
const verified = verifyExpectedArtifact("complete-slice", "M001/S01", base);
assert.equal(verified, true, "verifyExpectedArtifact should return true when roadmap has [x]");
} finally {
clearParseCache();
cleanup(base);
}
});
// ─── verifyExpectedArtifact: plan-slice empty scaffold regression (#699) ──
test("verifyExpectedArtifact rejects plan-slice with empty scaffold", () => {
const base = makeTmpBase();
try {
const sliceDir = join(base, ".gsd", "milestones", "M001", "slices", "S01");
mkdirSync(sliceDir, { recursive: true });
writeFileSync(join(sliceDir, "S01-PLAN.md"), "# S01: Test Slice\n\n## Tasks\n\n");
assert.strictEqual(
verifyExpectedArtifact("plan-slice", "M001/S01", base),
false,
"Empty scaffold should not be treated as completed artifact",
);
} finally {
cleanup(base);
}
});
test("verifyExpectedArtifact accepts plan-slice with actual tasks", () => {
const base = makeTmpBase();
try {
const sliceDir = join(base, ".gsd", "milestones", "M001", "slices", "S01");
const tasksDir = join(sliceDir, "tasks");
mkdirSync(tasksDir, { recursive: true });
writeFileSync(join(sliceDir, "S01-PLAN.md"), [
"# S01: Test Slice",
"",
"## Tasks",
"",
"- [ ] **T01: Implement feature** `est:2h`",
"- [ ] **T02: Write tests** `est:1h`",
].join("\n"));
writeFileSync(join(tasksDir, "T01-PLAN.md"), "# T01 Plan");
writeFileSync(join(tasksDir, "T02-PLAN.md"), "# T02 Plan");
assert.strictEqual(
verifyExpectedArtifact("plan-slice", "M001/S01", base),
true,
"Plan with task entries should be treated as completed artifact",
);
} finally {
cleanup(base);
}
});
test("verifyExpectedArtifact accepts plan-slice with completed tasks", () => {
const base = makeTmpBase();
try {
const sliceDir = join(base, ".gsd", "milestones", "M001", "slices", "S01");
const tasksDir = join(sliceDir, "tasks");
mkdirSync(tasksDir, { recursive: true });
writeFileSync(join(sliceDir, "S01-PLAN.md"), [
"# S01: Test Slice",
"",
"## Tasks",
"",
"- [x] **T01: Implement feature** `est:2h`",
"- [ ] **T02: Write tests** `est:1h`",
].join("\n"));
writeFileSync(join(tasksDir, "T01-PLAN.md"), "# T01 Plan");
writeFileSync(join(tasksDir, "T02-PLAN.md"), "# T02 Plan");
assert.strictEqual(
verifyExpectedArtifact("plan-slice", "M001/S01", base),
true,
"Plan with completed task entries should be treated as completed artifact",
);
} finally {
cleanup(base);
}
});
test("verifyExpectedArtifact treats complete-slice as satisfied when summary, UAT, and roadmap checkbox exist", () => {
const base = makeTmpBase();
try {
const milestoneDir = join(base, ".gsd", "milestones", "M001");
const sliceDir = join(milestoneDir, "slices", "S01");
mkdirSync(sliceDir, { recursive: true });
writeFileSync(join(milestoneDir, "M001-ROADMAP.md"), [
"# M001: Test Milestone",
"",
"## Slices",
"",
"- [x] **S01: First slice** `risk:low`",
"",
"## Boundary Map",
"",
"- S01 → terminal",
" - Produces: done",
" - Consumes: nothing",
].join("\n"));
writeFileSync(join(sliceDir, "S01-SUMMARY.md"), "# Summary\nDone.\n");
writeFileSync(join(sliceDir, "S01-UAT.md"), "# UAT\nPassed.\n");
assert.equal(
verifyExpectedArtifact("complete-slice", "M001/S01", base),
true,
"complete-slice should verify when expected artifact and state mutation are already satisfied",
);
} finally {
cleanup(base);
}
});
test("verifyExpectedArtifact rejects complete-slice when roadmap checkbox is still unchecked", () => {
const base = makeTmpBase();
try {
const milestoneDir = join(base, ".gsd", "milestones", "M001");
const sliceDir = join(milestoneDir, "slices", "S01");
mkdirSync(sliceDir, { recursive: true });
writeFileSync(join(milestoneDir, "M001-ROADMAP.md"), [
"# M001: Test Milestone",
"",
"## Slices",
"",
"- [ ] **S01: First slice** `risk:low`",
"",
"## Boundary Map",
"",
"- S01 → terminal",
" - Produces: done",
" - Consumes: nothing",
].join("\n"));
writeFileSync(join(sliceDir, "S01-SUMMARY.md"), "# Summary\nDone.\n");
writeFileSync(join(sliceDir, "S01-UAT.md"), "# UAT\nPassed.\n");
assert.equal(
verifyExpectedArtifact("complete-slice", "M001/S01", base),
false,
"complete-slice should remain unsatisfied when roadmap state still requires the unit to run",
);
} finally {
cleanup(base);
}
});
// ─── verifyExpectedArtifact: plan-slice task plan check (#739) ────────────
test("verifyExpectedArtifact plan-slice passes when all task plan files exist", () => {
const base = makeTmpBase();
try {
const tasksDir = join(base, ".gsd", "milestones", "M001", "slices", "S01", "tasks");
const planPath = join(base, ".gsd", "milestones", "M001", "slices", "S01", "S01-PLAN.md");
const planContent = [
"# S01: Test Slice",
"",
"## Tasks",
"",
"- [ ] **T01: First task** `est:1h`",
"- [ ] **T02: Second task** `est:2h`",
].join("\n");
writeFileSync(planPath, planContent);
writeFileSync(join(tasksDir, "T01-PLAN.md"), "# T01 Plan\n\nDo the thing.");
writeFileSync(join(tasksDir, "T02-PLAN.md"), "# T02 Plan\n\nDo the other thing.");
const result = verifyExpectedArtifact("plan-slice", "M001/S01", base);
assert.equal(result, true, "should pass when all task plan files exist");
} finally {
cleanup(base);
}
});
test("verifyExpectedArtifact plan-slice fails when a task plan file is missing (#739)", () => {
const base = makeTmpBase();
try {
const tasksDir = join(base, ".gsd", "milestones", "M001", "slices", "S01", "tasks");
const planPath = join(base, ".gsd", "milestones", "M001", "slices", "S01", "S01-PLAN.md");
const planContent = [
"# S01: Test Slice",
"",
"## Tasks",
"",
"- [ ] **T01: First task** `est:1h`",
"- [ ] **T02: Second task** `est:2h`",
].join("\n");
writeFileSync(planPath, planContent);
// Only write T01-PLAN.md — T02 is missing
writeFileSync(join(tasksDir, "T01-PLAN.md"), "# T01 Plan\n\nDo the thing.");
const result = verifyExpectedArtifact("plan-slice", "M001/S01", base);
assert.equal(result, false, "should fail when T02-PLAN.md is missing");
} finally {
cleanup(base);
}
});
test("verifyExpectedArtifact plan-slice fails for plan with no tasks (#699)", () => {
const base = makeTmpBase();
try {
const planPath = join(base, ".gsd", "milestones", "M001", "slices", "S01", "S01-PLAN.md");
const planContent = [
"# S01: Test Slice",
"",
"## Goal",
"",
"Just some documentation updates, no tasks.",
].join("\n");
writeFileSync(planPath, planContent);
const result = verifyExpectedArtifact("plan-slice", "M001/S01", base);
assert.equal(result, false, "should fail when plan has no task entries (empty scaffold, #699)");
} finally {
cleanup(base);
}
});
// ─── verifyExpectedArtifact: heading-style plan tasks (#1691) ─────────────
test("verifyExpectedArtifact accepts plan-slice with heading-style tasks (### T01 --)", () => {
const base = makeTmpBase();
try {
const sliceDir = join(base, ".gsd", "milestones", "M001", "slices", "S01");
const tasksDir = join(sliceDir, "tasks");
mkdirSync(tasksDir, { recursive: true });
writeFileSync(join(sliceDir, "S01-PLAN.md"), [
"# S01: Test Slice",
"",
"## Tasks",
"",
"### T01 -- Implement feature",
"",
"Feature description.",
"",
"### T02 -- Write tests",
"",
"Test description.",
].join("\n"));
writeFileSync(join(tasksDir, "T01-PLAN.md"), "# T01 Plan");
writeFileSync(join(tasksDir, "T02-PLAN.md"), "# T02 Plan");
assert.strictEqual(
verifyExpectedArtifact("plan-slice", "M001/S01", base),
true,
"Heading-style plan with task entries should be treated as completed artifact",
);
} finally {
cleanup(base);
}
});
test("verifyExpectedArtifact accepts plan-slice with colon-style heading tasks (### T01:)", () => {
const base = makeTmpBase();
try {
const sliceDir = join(base, ".gsd", "milestones", "M001", "slices", "S01");
const tasksDir = join(sliceDir, "tasks");
mkdirSync(tasksDir, { recursive: true });
writeFileSync(join(sliceDir, "S01-PLAN.md"), [
"# S01: Test Slice",
"",
"## Tasks",
"",
"### T01: Implement feature",
"",
"Feature description.",
].join("\n"));
writeFileSync(join(tasksDir, "T01-PLAN.md"), "# T01 Plan");
assert.strictEqual(
verifyExpectedArtifact("plan-slice", "M001/S01", base),
true,
"Colon heading-style plan should be treated as completed artifact",
);
} finally {
cleanup(base);
}
});
test("verifyExpectedArtifact execute-task requires checked checkbox or DB status for heading-style plan entry (#1691, #3607)", () => {
const base = makeTmpBase();
try {
const sliceDir = join(base, ".gsd", "milestones", "M001", "slices", "S01");
const tasksDir = join(sliceDir, "tasks");
mkdirSync(tasksDir, { recursive: true });
writeFileSync(join(sliceDir, "S01-PLAN.md"), [
"# S01: Test Slice",
"",
"## Tasks",
"",
"### T01 -- Implement feature",
"",
"Feature description.",
].join("\n"));
writeFileSync(join(tasksDir, "T01-SUMMARY.md"), "# T01 Summary\n\nDone.");
// Without DB or checked checkbox, heading-style plans cannot verify
// execute-task completion (summary file alone is insufficient, #3607)
assert.strictEqual(
verifyExpectedArtifact("execute-task", "M001/S01/T01", base),
false,
"execute-task requires DB status or checked checkbox, not just heading + summary (#3607)",
);
} finally {
cleanup(base);
}
});
// ─── #793: invalidateAllCaches unblocks skip-loop ─────────────────────────
// When the skip-loop breaker fires, it must call invalidateAllCaches() (not
// just invalidateStateCache()) to clear path/parse caches that deriveState
// depends on. Without this, even after cache invalidation, deriveState reads
// stale directory listings and returns the same unit, looping forever.
test("#793: invalidateAllCaches clears all caches so deriveState sees fresh disk state", async () => {
const base = makeTmpBase();
try {
const mid = "M001";
const sid = "S01";
const planDir = join(base, ".gsd", "milestones", mid, "slices", sid);
const tasksDir = join(planDir, "tasks");
mkdirSync(tasksDir, { recursive: true });
mkdirSync(join(base, ".gsd", "milestones", mid), { recursive: true });
writeFileSync(
join(base, ".gsd", "milestones", mid, `${mid}-ROADMAP.md`),
`# M001: Test Milestone\n\n**Vision:** test.\n\n## Slices\n\n- [ ] **${sid}: Slice One** \`risk:low\` \`depends:[]\`\n > After this: done.\n`,
);
const planUnchecked = `# ${sid}: Slice One\n\n**Goal:** test.\n\n## Tasks\n\n- [ ] **T01: Task One** \`est:10m\`\n- [ ] **T02: Task Two** \`est:10m\`\n`;
writeFileSync(join(planDir, `${sid}-PLAN.md`), planUnchecked);
writeFileSync(join(tasksDir, "T01-PLAN.md"), "# T01: Task One\n\n**Goal:** t\n\n## Steps\n- step\n\n## Verification\n- v\n");
writeFileSync(join(tasksDir, "T02-PLAN.md"), "# T02: Task Two\n\n**Goal:** t\n\n## Steps\n- step\n\n## Verification\n- v\n");
// Warm all caches
const state1 = await deriveState(base);
assert.equal(state1.activeTask?.id, "T01", "initial: T01 is active");
// Simulate task completion on disk (what the LLM does)
const planChecked = `# ${sid}: Slice One\n\n**Goal:** test.\n\n## Tasks\n\n- [x] **T01: Task One** \`est:10m\`\n- [ ] **T02: Task Two** \`est:10m\`\n`;
writeFileSync(join(planDir, `${sid}-PLAN.md`), planChecked);
writeFileSync(join(tasksDir, "T01-SUMMARY.md"), "---\nid: T01\n---\n# Summary\n");
// invalidateStateCache alone: _stateCache cleared but path/parse caches warm
invalidateStateCache();
// invalidateAllCaches: all caches cleared — deriveState must re-read disk
invalidateAllCaches();
const state2 = await deriveState(base);
// After full invalidation, T01 should be complete and T02 should be next
assert.notEqual(state2.activeTask?.id, "T01", "#793: T01 not re-dispatched after full invalidation");
// Verify the caches are truly cleared by calling clearParseCache and clearPathCache
// do not throw (they should be no-ops after invalidateAllCaches already cleared them)
clearParseCache(); // no-op, but should not throw
assert.ok(true, "clearParseCache after invalidateAllCaches is safe");
} finally {
cleanup(base);
}
});
// ─── hasImplementationArtifacts (#1703) ───────────────────────────────────
import { execFileSync } from "node:child_process";
function makeGitBase(): string {
const base = join(tmpdir(), `gsd-test-git-${randomUUID()}`);
mkdirSync(base, { recursive: true });
execFileSync("git", ["init", "--initial-branch=main"], { cwd: base, stdio: "ignore" });
execFileSync("git", ["config", "user.email", "test@test.com"], { cwd: base, stdio: "ignore" });
execFileSync("git", ["config", "user.name", "Test"], { cwd: base, stdio: "ignore" });
// Create initial commit so HEAD exists
writeFileSync(join(base, ".gitkeep"), "");
execFileSync("git", ["add", "."], { cwd: base, stdio: "ignore" });
execFileSync("git", ["commit", "-m", "initial"], { cwd: base, stdio: "ignore" });
return base;
}
test("hasImplementationArtifacts returns false when only .gsd/ files committed (#1703)", () => {
const base = makeGitBase();
try {
// Create a feature branch and commit only .gsd/ files
execFileSync("git", ["checkout", "-b", "feat/test-milestone"], { cwd: base, stdio: "ignore" });
mkdirSync(join(base, ".gsd", "milestones", "M001"), { recursive: true });
writeFileSync(join(base, ".gsd", "milestones", "M001", "M001-ROADMAP.md"), "# Roadmap");
writeFileSync(join(base, ".gsd", "milestones", "M001", "M001-SUMMARY.md"), "# Summary");
execFileSync("git", ["add", "."], { cwd: base, stdio: "ignore" });
execFileSync("git", ["commit", "-m", "chore: add plan files"], { cwd: base, stdio: "ignore" });
const result = hasImplementationArtifacts(base);
assert.equal(result, "absent", "should return absent when only .gsd/ files were committed");
} finally {
cleanup(base);
}
});
test("hasImplementationArtifacts returns true when implementation files committed (#1703)", () => {
const base = makeGitBase();
try {
// Create a feature branch with both .gsd/ and implementation files
execFileSync("git", ["checkout", "-b", "feat/test-impl"], { cwd: base, stdio: "ignore" });
mkdirSync(join(base, ".gsd", "milestones", "M001"), { recursive: true });
writeFileSync(join(base, ".gsd", "milestones", "M001", "M001-ROADMAP.md"), "# Roadmap");
mkdirSync(join(base, "src"), { recursive: true });
writeFileSync(join(base, "src", "feature.ts"), "export function feature() {}");
execFileSync("git", ["add", "."], { cwd: base, stdio: "ignore" });
execFileSync("git", ["commit", "-m", "feat: add feature"], { cwd: base, stdio: "ignore" });
const result = hasImplementationArtifacts(base);
assert.equal(result, "present", "should return present when implementation files are present");
} finally {
cleanup(base);
}
});
test("hasImplementationArtifacts returns true on non-git directory (fail-open)", () => {
const base = join(tmpdir(), `gsd-test-nogit-${randomUUID()}`);
mkdirSync(base, { recursive: true });
try {
const result = hasImplementationArtifacts(base);
assert.equal(result, "unknown", "should return unknown (fail-open) in non-git directory");
} finally {
cleanup(base);
}
});
// ─── verifyExpectedArtifact: complete-milestone requires impl artifacts (#1703) ──
test("verifyExpectedArtifact complete-milestone fails with only .gsd/ files (#1703)", () => {
const base = makeGitBase();
try {
// Create feature branch with only .gsd/ files
execFileSync("git", ["checkout", "-b", "feat/ms-only-gsd"], { cwd: base, stdio: "ignore" });
mkdirSync(join(base, ".gsd", "milestones", "M001"), { recursive: true });
writeFileSync(join(base, ".gsd", "milestones", "M001", "M001-SUMMARY.md"), "# Milestone Summary\nDone.");
execFileSync("git", ["add", "."], { cwd: base, stdio: "ignore" });
execFileSync("git", ["commit", "-m", "chore: milestone plan files"], { cwd: base, stdio: "ignore" });
const result = verifyExpectedArtifact("complete-milestone", "M001", base);
assert.equal(result, false, "complete-milestone should fail verification when only .gsd/ files present");
} finally {
cleanup(base);
}
});
test("verifyExpectedArtifact complete-milestone passes with impl files (#1703)", () => {
const base = makeGitBase();
try {
// Create feature branch with implementation files AND milestone summary
execFileSync("git", ["checkout", "-b", "feat/ms-with-impl"], { cwd: base, stdio: "ignore" });
mkdirSync(join(base, ".gsd", "milestones", "M001"), { recursive: true });
writeFileSync(join(base, ".gsd", "milestones", "M001", "M001-SUMMARY.md"), "# Milestone Summary\nDone.");
mkdirSync(join(base, "src"), { recursive: true });
writeFileSync(join(base, "src", "app.ts"), "console.log('hello');");
execFileSync("git", ["add", "."], { cwd: base, stdio: "ignore" });
execFileSync("git", ["commit", "-m", "feat: implementation"], { cwd: base, stdio: "ignore" });
const result = verifyExpectedArtifact("complete-milestone", "M001", base);
assert.equal(result, true, "complete-milestone should pass verification with implementation files");
} finally {
cleanup(base);
}
});
test("verifyExpectedArtifact checks pending gate-evaluate artifacts without ESM require failures", () => {
const base = makeTmpProject();

View file

@ -1,6 +1,6 @@
import test from "node:test";
import assert from "node:assert/strict";
import { mkdirSync, existsSync, readFileSync, rmSync } from "node:fs";
import { mkdirSync, rmSync, writeFileSync } from "node:fs";
import { join } from "node:path";
import { tmpdir } from "node:os";
import { randomUUID } from "node:crypto";
@ -13,6 +13,14 @@ import {
formatCrashInfo,
type LockData,
} from "../crash-recovery.ts";
import {
assessInterruptedSession,
hasResumableDerivedState,
isBootstrapCrashLock,
readPausedSessionMetadata,
} from "../interrupted-session.ts";
import { gsdRoot } from "../paths.ts";
import type { GSDState } from "../types.ts";
function makeTmpBase(): string {
const base = join(tmpdir(), `gsd-test-${randomUUID()}`);
@ -24,6 +32,376 @@ function cleanup(base: string): void {
try { rmSync(base, { recursive: true, force: true }); } catch { /* */ }
}
function writeTestLock(
base: string,
unitType: string,
unitId: string,
sessionFile?: string,
): void {
writeFileSync(
join(gsdRoot(base), "auto.lock"),
JSON.stringify({
pid: 999999999,
startedAt: new Date().toISOString(),
unitType,
unitId,
unitStartedAt: new Date().toISOString(),
sessionFile,
}, null, 2),
"utf-8",
);
}
function writeRoadmap(base: string, checked = false): void {
const milestoneDir = join(base, ".gsd", "milestones", "M001");
mkdirSync(join(milestoneDir, "slices", "S01", "tasks"), { recursive: true });
writeFileSync(
join(milestoneDir, "M001-ROADMAP.md"),
[
"# M001: Test Milestone",
"",
"## Vision",
"",
"Test milestone.",
"",
"## Success Criteria",
"",
"- It works.",
"",
"## Slices",
"",
`- [${checked ? "x" : " "}] **S01: Test slice** \`risk:low\``,
" After this: Demo",
"",
"## Boundary Map",
"",
"- S01 → terminal",
" - Produces: done",
" - Consumes: nothing",
].join("\n"),
"utf-8",
);
}
function writeCompleteSliceArtifacts(base: string): void {
const sliceDir = join(base, ".gsd", "milestones", "M001", "slices", "S01");
mkdirSync(sliceDir, { recursive: true });
writeFileSync(join(sliceDir, "S01-SUMMARY.md"), "# Summary\nDone.\n", "utf-8");
writeFileSync(join(sliceDir, "S01-UAT.md"), "# UAT\nPassed.\n", "utf-8");
}
function writeCompleteMilestoneSummary(base: string): void {
const milestoneDir = join(base, ".gsd", "milestones", "M001");
mkdirSync(milestoneDir, { recursive: true });
writeFileSync(join(milestoneDir, "M001-SUMMARY.md"), "# Milestone Summary\nDone.\n", "utf-8");
}
function writePausedSession(
base: string,
milestoneId = "M001",
stepMode = false,
worktreePath?: string,
unitType?: string,
unitId?: string,
): void {
const runtimeDir = join(base, ".gsd", "runtime");
mkdirSync(runtimeDir, { recursive: true });
writeFileSync(
join(runtimeDir, "paused-session.json"),
JSON.stringify({ milestoneId, originalBasePath: base, stepMode, worktreePath, unitType, unitId }, null, 2),
"utf-8",
);
}
function writeActivityLog(base: string, entries: Record<string, unknown>[]): void {
const activityDir = join(base, ".gsd", "activity");
mkdirSync(activityDir, { recursive: true });
writeFileSync(
join(activityDir, "001-execute-task-M001-S01-T01.jsonl"),
entries.map((entry) => JSON.stringify(entry)).join("\n") + "\n",
"utf-8",
);
}
function makeState(phase: GSDState["phase"], activeMilestone = true): GSDState {
return {
activeMilestone: activeMilestone ? { id: "M001", title: "Test" } : null,
activeSlice: null,
activeTask: null,
phase,
recentDecisions: [],
blockers: [],
nextAction: "",
registry: [],
};
}
// ─── interrupted-session helpers ───────────────────────────────────────────
test("hasResumableDerivedState treats only unfinished active work as resumable", () => {
assert.equal(hasResumableDerivedState(makeState("executing")), true);
assert.equal(hasResumableDerivedState(makeState("complete")), false);
assert.equal(hasResumableDerivedState(makeState("pre-planning", false)), false);
});
test("isBootstrapCrashLock detects starting/bootstrap special case", () => {
const bootstrap: LockData = {
pid: 999999999,
startedAt: new Date().toISOString(),
unitType: "starting",
unitId: "bootstrap",
unitStartedAt: new Date().toISOString(),
};
assert.equal(isBootstrapCrashLock(bootstrap), true);
assert.equal(isBootstrapCrashLock({ ...bootstrap, unitType: "execute-task" }), false);
});
test("readPausedSessionMetadata reads paused-session metadata when present", () => {
const base = makeTmpBase();
try {
writePausedSession(base, "M009");
const meta = readPausedSessionMetadata(base);
assert.equal(meta?.milestoneId, "M009");
} finally {
cleanup(base);
}
});
test("readPausedSessionMetadata preserves unitType and unitId through round-trip", () => {
const base = makeTmpBase();
try {
writePausedSession(base, "M001", false, undefined, "execute-task", "M001/S01/T02");
const meta = readPausedSessionMetadata(base);
assert.equal(meta?.unitType, "execute-task");
assert.equal(meta?.unitId, "M001/S01/T02");
} finally {
cleanup(base);
}
});
test("readPausedSessionMetadata handles legacy metadata without unitType/unitId", () => {
const base = makeTmpBase();
try {
// Write metadata without unitType/unitId (simulates older version)
const runtimeDir = join(base, ".gsd", "runtime");
mkdirSync(runtimeDir, { recursive: true });
writeFileSync(
join(runtimeDir, "paused-session.json"),
JSON.stringify({ milestoneId: "M001", originalBasePath: base }),
"utf-8",
);
const meta = readPausedSessionMetadata(base);
assert.equal(meta?.milestoneId, "M001");
assert.equal(meta?.unitType, undefined);
assert.equal(meta?.unitId, undefined);
} finally {
cleanup(base);
}
});
test("assessInterruptedSession returns none when no lock and no paused session exist", async () => {
const base = makeTmpBase();
try {
const assessment = await assessInterruptedSession(base);
assert.equal(assessment.classification, "none");
assert.equal(assessment.lock, null);
assert.equal(assessment.pausedSession, null);
assert.equal(assessment.state, null);
assert.equal(assessment.recovery, null);
assert.equal(assessment.recoveryPrompt, null);
assert.equal(assessment.recoveryToolCallCount, 0);
assert.equal(assessment.artifactSatisfied, false);
assert.equal(assessment.hasResumableDiskState, false);
assert.equal(assessment.isBootstrapCrash, false);
} finally {
cleanup(base);
}
});
test("assessInterruptedSession classifies stale complete repo as stale and suppresses recovery", async () => {
const base = makeTmpBase();
try {
writeRoadmap(base, true);
writeCompleteSliceArtifacts(base);
writeCompleteMilestoneSummary(base);
writeTestLock(base, "execute-task", "M001/S01/T01");
const assessment = await assessInterruptedSession(base);
assert.equal(assessment.classification, "stale");
assert.equal(assessment.hasResumableDiskState, false);
assert.equal(assessment.recoveryPrompt, null);
} finally {
cleanup(base);
}
});
test("assessInterruptedSession suppresses prompt when expected artifact already exists and no resumable state remains", async () => {
const base = makeTmpBase();
try {
writeRoadmap(base, true);
writeCompleteSliceArtifacts(base);
writeCompleteMilestoneSummary(base);
writeTestLock(base, "complete-slice", "M001/S01");
const assessment = await assessInterruptedSession(base);
assert.equal(assessment.classification, "stale");
assert.equal(assessment.artifactSatisfied, true);
} finally {
cleanup(base);
}
});
test("assessInterruptedSession keeps paused-session resume recoverable when disk state is unfinished", async () => {
const base = makeTmpBase();
try {
writeRoadmap(base, false);
writePausedSession(base);
writeTestLock(base, "execute-task", "M001/S01/T01");
const assessment = await assessInterruptedSession(base);
assert.equal(assessment.classification, "recoverable");
assert.equal(assessment.pausedSession?.milestoneId, "M001");
} finally {
cleanup(base);
}
});
test("assessInterruptedSession marks stale paused-session metadata as stale when no work remains", async () => {
const base = makeTmpBase();
try {
writeRoadmap(base, true);
writeCompleteSliceArtifacts(base);
writeCompleteMilestoneSummary(base);
writePausedSession(base, "M999");
const assessment = await assessInterruptedSession(base);
assert.equal(assessment.classification, "stale");
assert.equal(assessment.hasResumableDiskState, false);
} finally {
cleanup(base);
}
});
test("assessInterruptedSession classifies paused session without lock as recoverable when disk state is resumable", async () => {
const base = makeTmpBase();
try {
writeRoadmap(base, false);
writePausedSession(base, "M001", true);
const assessment = await assessInterruptedSession(base);
assert.equal(assessment.classification, "recoverable");
assert.equal(assessment.lock, null);
assert.equal(assessment.pausedSession?.milestoneId, "M001");
assert.equal(assessment.hasResumableDiskState, true);
assert.equal(assessment.isBootstrapCrash, false);
} finally {
cleanup(base);
}
});
test("assessInterruptedSession falls back to basePath when worktreePath no longer exists", async () => {
const base = makeTmpBase();
try {
writeRoadmap(base, false);
// Reference a worktree that doesn't exist on disk
writePausedSession(base, "M001", false, "/nonexistent/worktree");
const assessment = await assessInterruptedSession(base);
// Should use basePath (which has an unfinished roadmap) instead of the missing worktree
assert.equal(assessment.classification, "recoverable");
assert.equal(assessment.hasResumableDiskState, true);
} finally {
cleanup(base);
}
});
test("assessInterruptedSession prefers paused worktree state when worktreePath is recorded", async () => {
const base = makeTmpBase();
const worktree = join(base, "worktree-copy");
try {
writeRoadmap(base, false);
writeRoadmap(worktree, true);
writeCompleteSliceArtifacts(worktree);
writeCompleteMilestoneSummary(worktree);
writePausedSession(base, "M001", false, worktree);
const assessment = await assessInterruptedSession(base);
assert.equal(assessment.classification, "stale");
assert.equal(assessment.hasResumableDiskState, false);
} finally {
cleanup(base);
}
});
test("assessInterruptedSession keeps unfinished derived state recoverable without trace", async () => {
const base = makeTmpBase();
try {
writeRoadmap(base, false);
writeTestLock(base, "plan-slice", "M001/S01");
const assessment = await assessInterruptedSession(base);
assert.equal(assessment.classification, "recoverable");
assert.equal(assessment.hasResumableDiskState, true);
assert.equal(assessment.recoveryPrompt, null);
} finally {
cleanup(base);
}
});
test("assessInterruptedSession preserves crash trace when activity log has tool calls", async () => {
const base = makeTmpBase();
try {
writeRoadmap(base, false);
writeTestLock(base, "execute-task", "M001/S01/T01");
writeActivityLog(base, [
{
type: "message",
message: {
role: "assistant",
content: [
{
type: "toolCall",
id: "1",
name: "bash",
arguments: { command: "npm test" },
},
],
},
},
{
type: "message",
message: {
role: "toolResult",
toolCallId: "1",
toolName: "bash",
isError: false,
content: [{ type: "text", text: "ok" }],
},
},
]);
const assessment = await assessInterruptedSession(base);
assert.equal(assessment.classification, "recoverable");
assert.ok(assessment.recoveryToolCallCount > 0);
assert.ok(assessment.recoveryPrompt?.includes("Recovery Briefing"));
} finally {
cleanup(base);
}
});
test("assessInterruptedSession treats bootstrap crash as stale without paused metadata", async () => {
const base = makeTmpBase();
try {
writeTestLock(base, "starting", "bootstrap");
const assessment = await assessInterruptedSession(base);
assert.equal(assessment.classification, "stale");
assert.equal(assessment.isBootstrapCrash, true);
} finally {
cleanup(base);
}
});
// ─── writeLock / readCrashLock ────────────────────────────────────────────
test("writeLock creates lock file and readCrashLock reads it", (t) => {
@ -84,7 +462,7 @@ test("#2470: isLockProcessAlive returns true for own PID (we hold the lock)", ()
test("isLockProcessAlive returns false for dead PID", () => {
const lock: LockData = {
pid: 999999999, // almost certainly not running
pid: 999999999,
startedAt: new Date().toISOString(),
unitType: "execute-task",
unitId: "M001/S01/T01",

View file

@ -0,0 +1,146 @@
import test from "node:test";
import assert from "node:assert/strict";
import { mkdirSync, rmSync, writeFileSync } from "node:fs";
import { join } from "node:path";
import { tmpdir } from "node:os";
import { randomUUID } from "node:crypto";
import { assessInterruptedSession } from "../interrupted-session.ts";
function makeTmpBase(): string {
const base = join(tmpdir(), `gsd-auto-interrupted-${randomUUID()}`);
mkdirSync(join(base, ".gsd"), { recursive: true });
return base;
}
function cleanup(base: string): void {
try { rmSync(base, { recursive: true, force: true }); } catch { /* */ }
}
function writeRoadmap(base: string, checked = false): void {
const milestoneDir = join(base, ".gsd", "milestones", "M001");
mkdirSync(join(milestoneDir, "slices", "S01", "tasks"), { recursive: true });
writeFileSync(
join(milestoneDir, "M001-ROADMAP.md"),
[
"# M001: Test Milestone",
"",
"## Vision",
"",
"Test milestone.",
"",
"## Success Criteria",
"",
"- It works.",
"",
"## Slices",
"",
`- [${checked ? "x" : " "}] **S01: Test slice** \`risk:low\``,
" After this: Demo",
"",
"## Boundary Map",
"",
"- S01 → terminal",
" - Produces: done",
" - Consumes: nothing",
].join("\n"),
"utf-8",
);
}
function writeCompleteArtifacts(base: string): void {
const milestoneDir = join(base, ".gsd", "milestones", "M001");
const sliceDir = join(milestoneDir, "slices", "S01");
mkdirSync(sliceDir, { recursive: true });
writeFileSync(join(sliceDir, "S01-SUMMARY.md"), "# Summary\nDone.\n", "utf-8");
writeFileSync(join(sliceDir, "S01-UAT.md"), "# UAT\nPassed.\n", "utf-8");
writeFileSync(join(milestoneDir, "M001-SUMMARY.md"), "# Milestone Summary\nDone.\n", "utf-8");
}
function writeLock(base: string, unitType: string, unitId: string): void {
writeFileSync(
join(base, ".gsd", "auto.lock"),
JSON.stringify({
pid: 999999999,
startedAt: new Date().toISOString(),
unitType,
unitId,
unitStartedAt: new Date().toISOString(),
}, null, 2),
"utf-8",
);
}
function writePausedSession(base: string, milestoneId = "M001", stepMode = false): void {
const runtimeDir = join(base, ".gsd", "runtime");
mkdirSync(runtimeDir, { recursive: true });
writeFileSync(
join(runtimeDir, "paused-session.json"),
JSON.stringify({ milestoneId, originalBasePath: base, stepMode }, null, 2),
"utf-8",
);
}
test("direct /gsd auto stale complete repo yields stale classification with no recovery payload", async () => {
const base = makeTmpBase();
try {
writeRoadmap(base, true);
writeCompleteArtifacts(base);
writeLock(base, "execute-task", "M001/S01/T01");
const assessment = await assessInterruptedSession(base);
assert.equal(assessment.classification, "stale");
assert.equal(assessment.recoveryPrompt, null);
assert.equal(assessment.hasResumableDiskState, false);
} finally {
cleanup(base);
}
});
test("direct /gsd auto paused-session metadata remains recoverable when work is unfinished", async () => {
const base = makeTmpBase();
try {
writeRoadmap(base, false);
writePausedSession(base, "M001", false);
writeLock(base, "execute-task", "M001/S01/T01");
const assessment = await assessInterruptedSession(base);
assert.equal(assessment.classification, "recoverable");
assert.equal(assessment.pausedSession?.milestoneId, "M001");
} finally {
cleanup(base);
}
});
test("direct /gsd auto stale paused-session metadata is treated as stale when no resumable work remains", async () => {
const base = makeTmpBase();
try {
writeRoadmap(base, true);
writeCompleteArtifacts(base);
writePausedSession(base, "M999", true);
const assessment = await assessInterruptedSession(base);
assert.equal(assessment.classification, "stale");
assert.equal(assessment.hasResumableDiskState, false);
} finally {
cleanup(base);
}
});
test("direct /gsd auto source only resumes paused-session metadata for recoverable state with real recovery signals", async () => {
const source = await import(`node:fs/promises`).then((fs) =>
fs.readFile(new URL("../auto.ts", import.meta.url), "utf-8")
);
assert.ok(source.includes('const shouldResumePausedSession ='));
assert.ok(source.includes('freshStartAssessment.classification === "recoverable"'));
assert.ok(source.includes('&& ('));
assert.ok(source.includes('freshStartAssessment.hasResumableDiskState'));
assert.ok(source.includes('|| !!freshStartAssessment.recoveryPrompt'));
assert.ok(source.includes('|| !!freshStartAssessment.lock'));
});
test("auto module imports successfully after interrupted-session changes", async () => {
const mod = await import(`../auto.ts?ts=${Date.now()}-${Math.random()}`);
assert.equal(typeof mod.startAuto, "function");
assert.equal(typeof mod.pauseAuto, "function");
});

View file

@ -0,0 +1,136 @@
import test from "node:test";
import assert from "node:assert/strict";
import { mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
import { join } from "node:path";
import { tmpdir } from "node:os";
import { randomUUID } from "node:crypto";
import { assessInterruptedSession } from "../interrupted-session.ts";
function makeTmpBase(): string {
const base = join(tmpdir(), `gsd-smart-entry-${randomUUID()}`);
mkdirSync(join(base, ".gsd"), { recursive: true });
return base;
}
function cleanup(base: string): void {
try { rmSync(base, { recursive: true, force: true }); } catch { /* */ }
}
function writeRoadmap(base: string, checked = false): void {
const milestoneDir = join(base, ".gsd", "milestones", "M001");
mkdirSync(join(milestoneDir, "slices", "S01", "tasks"), { recursive: true });
writeFileSync(
join(milestoneDir, "M001-ROADMAP.md"),
[
"# M001: Test Milestone",
"",
"## Vision",
"",
"Test milestone.",
"",
"## Success Criteria",
"",
"- It works.",
"",
"## Slices",
"",
`- [${checked ? "x" : " "}] **S01: Test slice** \`risk:low\``,
" After this: Demo",
"",
"## Boundary Map",
"",
"- S01 → terminal",
" - Produces: done",
" - Consumes: nothing",
].join("\n"),
"utf-8",
);
}
function writeCompleteArtifacts(base: string): void {
const milestoneDir = join(base, ".gsd", "milestones", "M001");
const sliceDir = join(milestoneDir, "slices", "S01");
mkdirSync(sliceDir, { recursive: true });
writeFileSync(join(sliceDir, "S01-SUMMARY.md"), "# Summary\nDone.\n", "utf-8");
writeFileSync(join(sliceDir, "S01-UAT.md"), "# UAT\nPassed.\n", "utf-8");
writeFileSync(join(milestoneDir, "M001-SUMMARY.md"), "# Milestone Summary\nDone.\n", "utf-8");
}
function writePausedSession(base: string, milestoneId = "M001", stepMode = false): void {
const runtimeDir = join(base, ".gsd", "runtime");
mkdirSync(runtimeDir, { recursive: true });
writeFileSync(
join(runtimeDir, "paused-session.json"),
JSON.stringify({ milestoneId, originalBasePath: base, stepMode }, null, 2),
"utf-8",
);
}
function writeLock(base: string, unitType: string, unitId: string): void {
writeFileSync(
join(base, ".gsd", "auto.lock"),
JSON.stringify({
pid: 999999999,
startedAt: new Date().toISOString(),
unitType,
unitId,
unitStartedAt: new Date().toISOString(),
}, null, 2),
"utf-8",
);
}
test("guided-flow stale complete scenario classifies as stale so the resume prompt can be suppressed", async () => {
const base = makeTmpBase();
try {
writeRoadmap(base, true);
writeCompleteArtifacts(base);
writeLock(base, "execute-task", "M001/S01/T01");
const assessment = await assessInterruptedSession(base);
assert.equal(assessment.classification, "stale");
assert.equal(assessment.recoveryPrompt, null);
} finally {
cleanup(base);
}
});
test("guided-flow paused-session scenario classifies as recoverable so resume remains available", async () => {
const base = makeTmpBase();
try {
writeRoadmap(base, false);
writePausedSession(base);
writeLock(base, "execute-task", "M001/S01/T01");
const assessment = await assessInterruptedSession(base);
assert.equal(assessment.classification, "recoverable");
assert.equal(assessment.pausedSession?.milestoneId, "M001");
} finally {
cleanup(base);
}
});
test("guided-flow stale paused-session scenario is suppressed when no resumable work remains", async () => {
const base = makeTmpBase();
try {
writeRoadmap(base, true);
writeCompleteArtifacts(base);
writePausedSession(base, "M999", true);
const assessment = await assessInterruptedSession(base);
assert.equal(assessment.classification, "stale");
assert.equal(assessment.hasResumableDiskState, false);
} finally {
cleanup(base);
}
});
test("guided-flow source uses step-aware resume and clears stale paused metadata without changing discuss handoff semantics", () => {
const source = readFileSync(join(import.meta.dirname, "..", "guided-flow.ts"), "utf-8");
assert.ok(source.includes('const interrupted = await assessInterruptedSession(basePath);'));
assert.ok(source.includes('resumeLabel = interrupted.pausedSession?.stepMode'));
assert.ok(source.includes('step: interrupted.pausedSession?.stepMode ?? false'));
assert.ok(source.includes('unlinkSync(join(gsdRoot(basePath), "runtime", "paused-session.json"))'));
assert.ok(source.includes('pendingAutoStartMap.set(basePath,'));
});