diff --git a/src/resources/extensions/gsd/auto-start.ts b/src/resources/extensions/gsd/auto-start.ts index 20aa9eed0..3f737c638 100644 --- a/src/resources/extensions/gsd/auto-start.ts +++ b/src/resources/extensions/gsd/auto-start.ts @@ -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 { 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(); diff --git a/src/resources/extensions/gsd/auto.ts b/src/resources/extensions/gsd/auto.ts index 5dfc5fc13..79b7fdc37 100644 --- a/src/resources/extensions/gsd/auto.ts +++ b/src/resources/extensions/gsd/auto.ts @@ -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 { 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, diff --git a/src/resources/extensions/gsd/auto/session.ts b/src/resources/extensions/gsd/auto/session.ts index dbf8cd0b9..4f8fc82e0 100644 --- a/src/resources/extensions/gsd/auto/session.ts +++ b/src/resources/extensions/gsd/auto/session.ts @@ -119,6 +119,8 @@ export class AutoSession { pendingVerificationRetry: PendingVerificationRetry | null = null; readonly verificationRetryCount = new Map(); 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; diff --git a/src/resources/extensions/gsd/guided-flow.ts b/src/resources/extensions/gsd/guided-flow.ts index 1ade896da..b73ad122d 100644 --- a/src/resources/extensions/gsd/guided-flow.ts +++ b/src/resources/extensions/gsd/guided-flow.ts @@ -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). diff --git a/src/resources/extensions/gsd/interrupted-session.ts b/src/resources/extensions/gsd/interrupted-session.ts new file mode 100644 index 000000000..8c6274a05 --- /dev/null +++ b/src/resources/extensions/gsd/interrupted-session.ts @@ -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 { + 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."; +} diff --git a/src/resources/extensions/gsd/tests/auto-recovery.test.ts b/src/resources/extensions/gsd/tests/auto-recovery.test.ts index 2f24f2232..37092d3df 100644 --- a/src/resources/extensions/gsd/tests/auto-recovery.test.ts +++ b/src/resources/extensions/gsd/tests/auto-recovery.test.ts @@ -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(); diff --git a/src/resources/extensions/gsd/tests/crash-recovery.test.ts b/src/resources/extensions/gsd/tests/crash-recovery.test.ts index 39323681f..a2949b58e 100644 --- a/src/resources/extensions/gsd/tests/crash-recovery.test.ts +++ b/src/resources/extensions/gsd/tests/crash-recovery.test.ts @@ -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[]): 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", diff --git a/src/resources/extensions/gsd/tests/interrupted-session-auto.test.ts b/src/resources/extensions/gsd/tests/interrupted-session-auto.test.ts new file mode 100644 index 000000000..38f6a4c81 --- /dev/null +++ b/src/resources/extensions/gsd/tests/interrupted-session-auto.test.ts @@ -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"); +}); diff --git a/src/resources/extensions/gsd/tests/interrupted-session-ui.test.ts b/src/resources/extensions/gsd/tests/interrupted-session-ui.test.ts new file mode 100644 index 000000000..21a6ca2ce --- /dev/null +++ b/src/resources/extensions/gsd/tests/interrupted-session-ui.test.ts @@ -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,')); +});