From d82c323be24e1b4d9713f98a9f4a7d971fe7ba61 Mon Sep 17 00:00:00 2001 From: Derek Pearson Date: Fri, 20 Mar 2026 22:34:30 -0400 Subject: [PATCH] fix: suppress stale interrupted-session resume prompts Treat interrupted sessions as resumable only when paused metadata, crash trace, or unfinished derived state indicate real work remains. This clears dead locks for completed/no-op repos and keeps guided flow plus /gsd auto consistent. --- src/resources/extensions/gsd/auto-start.ts | 62 +--- src/resources/extensions/gsd/auto.ts | 302 +++++++++++++++++- src/resources/extensions/gsd/guided-flow.ts | 57 ++-- .../extensions/gsd/interrupted-session.ts | 201 ++++++++++++ .../gsd/tests/auto-recovery.test.ts | 65 ++++ .../gsd/tests/crash-recovery.test.ts | 261 ++++++++++++++- .../tests/interrupted-session-auto.test.ts | 114 +++++++ .../gsd/tests/interrupted-session-ui.test.ts | 122 +++++++ 8 files changed, 1082 insertions(+), 102 deletions(-) create mode 100644 src/resources/extensions/gsd/interrupted-session.ts create mode 100644 src/resources/extensions/gsd/tests/interrupted-session-auto.test.ts create mode 100644 src/resources/extensions/gsd/tests/interrupted-session-ui.test.ts diff --git a/src/resources/extensions/gsd/auto-start.ts b/src/resources/extensions/gsd/auto-start.ts index 8f59bbe1c..8b405c111 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, 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, @@ -109,7 +103,9 @@ export async function bootstrapAutoSession( verboseMode: boolean, requestedStepMode: boolean, deps: BootstrapDeps, + interrupted: InterruptedSessionAssessment, ): Promise { + void verboseMode; const { shouldUseWorktreeIsolation, registerSigtermHandler, @@ -187,50 +183,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 = crashLock.unitId.split("/")[0]; - 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") { @@ -251,6 +203,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 c419933df..31a205f03 100644 --- a/src/resources/extensions/gsd/auto.ts +++ b/src/resources/extensions/gsd/auto.ts @@ -18,6 +18,11 @@ import type { import { deriveState } from "./state.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"; @@ -44,6 +49,7 @@ import { clearLock, readCrashLock, isLockProcessAlive, + formatCrashInfo, } from "./crash-recovery.js"; import { acquireSessionLock, @@ -918,38 +924,302 @@ export async function startAuto( pi: ExtensionAPI, base: string, verboseMode: boolean, - options?: { step?: boolean }, + options?: { + step?: boolean; + interrupted?: InterruptedSessionAssessment; + }, ): Promise { 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 pausedPath = join(gsdRoot(base), "runtime", "paused-session.json"); - if (existsSync(pausedPath)) { - const meta = JSON.parse(readFileSync(pausedPath, "utf-8")); - if (meta.milestoneId) { - s.currentMilestoneId = meta.milestoneId; - s.originalBasePath = meta.originalBasePath || base; - s.stepMode = meta.stepMode ?? requestedStepMode; - s.paused = true; - // Clean up the persisted file — we're consuming it - try { unlinkSync(pausedPath); } catch { /* non-fatal */ } - ctx.ui.notify( - `Resuming paused session for ${meta.milestoneId}${meta.worktreePath ? ` (worktree)` : ""}.`, - "info", - ); - } + const meta = freshStartAssessment.pausedSession ?? readPausedSessionMetadata(base); + if (meta?.milestoneId) { + s.currentMilestoneId = meta.milestoneId; + s.originalBasePath = meta.originalBasePath || base; + s.stepMode = meta.stepMode ?? requestedStepMode; + s.pausedSessionFile = meta.sessionFile ?? null; + s.paused = true; + const pausedPath = join(gsdRoot(base), "runtime", "paused-session.json"); + try { unlinkSync(pausedPath); } catch { /* non-fatal */ } + ctx.ui.notify( + `Resuming paused session for ${meta.milestoneId}${meta.worktreePath ? ` (worktree)` : ""}.`, + "info", + ); } } catch { // Malformed or missing — proceed with fresh bootstrap } } + if (freshStartAssessment.classification !== "running" && 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) { + const resumeLock = acquireSessionLock(base); + if (!resumeLock.acquired) { + ctx.ui.notify(`Cannot resume: ${resumeLock.reason}`, "error"); + return; + } + + s.paused = false; + s.active = true; + s.verbose = verboseMode; + s.stepMode = requestedStepMode; + s.cmdCtx = ctx; + s.basePath = base; + s.unitDispatchCount.clear(); + s.unitLifetimeDispatches.clear(); + if (!getLedger()) initMetrics(base); + if (s.currentMilestoneId) setActiveMilestoneId(base, s.currentMilestoneId); + + // ── Auto-worktree: re-enter worktree on resume ── + if ( + s.currentMilestoneId && + shouldUseWorktreeIsolation() && + s.originalBasePath && + !isInAutoWorktree(s.basePath) && + !detectWorktreeName(s.basePath) && + !detectWorktreeName(s.originalBasePath) + ) { + buildResolver().enterMilestone(s.currentMilestoneId, { + notify: ctx.ui.notify.bind(ctx.ui), + }); + } + + registerSigtermHandler(lockBase()); + + ctx.ui.setStatus("gsd-auto", s.stepMode ? "next" : "auto"); + ctx.ui.setFooter(hideFooter); + ctx.ui.notify( + s.stepMode ? "Step-mode resumed." : "Auto-mode resumed.", + "info", + ); + restoreHookState(s.basePath); + try { + await rebuildState(s.basePath); + syncCmuxSidebar(loadEffectiveGSDPreferences()?.preferences, await deriveState(s.basePath)); + } catch (e) { + debugLog("resume-rebuild-state-failed", { + error: e instanceof Error ? e.message : String(e), + }); + } + try { + const report = await runGSDDoctor(s.basePath, { fix: true }); + if (report.fixesApplied.length > 0) { + ctx.ui.notify( + `Resume: applied ${report.fixesApplied.length} fix(es) to state.`, + "info", + ); + } + } catch (e) { + debugLog("resume-doctor-failed", { + error: e instanceof Error ? e.message : String(e), + }); + } + invalidateAllCaches(); + + if (s.pausedSessionFile) { + const activityDir = join(gsdRoot(s.basePath), "activity"); + const recovery = synthesizeCrashRecovery( + s.basePath, + s.currentUnit?.type ?? "unknown", + s.currentUnit?.id ?? "unknown", + s.pausedSessionFile ?? undefined, + activityDir, + ); + if (recovery && recovery.trace.toolCallCount > 0) { + s.pendingCrashRecovery = recovery.prompt; + ctx.ui.notify( + `Recovered ${recovery.trace.toolCallCount} tool calls from paused session. Resuming with context.`, + "info", + ); + } + s.pausedSessionFile = null; + } + + updateSessionLock( + lockBase(), + "resuming", + s.currentMilestoneId ?? "unknown", + s.completedUnits.length, + ); + writeLock( + lockBase(), + "resuming", + s.currentMilestoneId ?? "unknown", + s.completedUnits.length, + ); + logCmuxEvent(loadEffectiveGSDPreferences()?.preferences, s.stepMode ? "Step-mode resumed." : "Auto-mode resumed.", "progress"); + + await autoLoop(ctx, pi, s, buildLoopDeps()); + return; + } + + // ── Fresh start path — delegated to auto-start.ts ── + const bootstrapDeps: BootstrapDeps = { + shouldUseWorktreeIsolation, + registerSigtermHandler, + lockBase, + buildResolver, + }; + + const ready = await bootstrapAutoSession( + s, + ctx, + pi, + base, + verboseMode, + requestedStepMode, + bootstrapDeps, + freshStartAssessment, + ); + if (!ready) return; + + try { + syncCmuxSidebar(loadEffectiveGSDPreferences()?.preferences, await deriveState(s.basePath)); + } catch { + // Best-effort only — sidebar sync must never block auto-mode startup + } + logCmuxEvent(loadEffectiveGSDPreferences()?.preferences, requestedStepMode ? "Step-mode started." : "Auto-mode started.", "progress"); + + // Dispatch the first unit + await autoLoop(ctx, pi, s, buildLoopDeps()); +} + +// ─── Agent End Handler ──────────────────────────────────────────────────────── + +/** + * Deprecated thin wrapper — kept as export for backward compatibility. + * The actual agent_end processing now happens via resolveAgentEnd() in auto-loop.ts, + * which is called directly from index.ts. The autoLoop() while loop handles all + * post-unit processing (verification, hooks, dispatch) that this function used to do. + * + * If called by straggler code, it simply resolves the pending promise so the loop + * can continue. + */ +export async function handleAgentEnd( + ctx: ExtensionContext, + pi: ExtensionAPI, +): Promise { + if (!s.active || !s.cmdCtx) return; + clearUnitTimeout(); + resolveAgentEnd({ messages: [] }); +} +// describeNextUnit is imported from auto-dashboard.ts and re-exported +export { describeNextUnit } from "./auto-dashboard.js"; + +/** Thin wrapper: delegates to auto-dashboard.ts, passing state accessors. */ +function updateProgressWidget( + ctx: ExtensionContext, + unitType: string, + unitId: string, + state: GSDState, +): void { + const badge = s.currentUnitRouting?.tier + ? ({ light: "L", standard: "S", heavy: "H" }[s.currentUnitRouting.tier] ?? + undefined) + : undefined; + _updateProgressWidget( + ctx, + unitType, + unitId, + state, + widgetStateAccessors, + badge, + ); +} + +/** State accessors for the widget — closures over module globals. */ +const widgetStateAccessors: WidgetStateAccessors = { + getAutoStartTime: () => s.autoStartTime, + isStepMode: () => s.stepMode, + getCmdCtx: () => s.cmdCtx, + getBasePath: () => s.basePath, + isVerbose: () => s.verbose, + isSessionSwitching: isSessionSwitchInFlight, +}; + +// ─── Preconditions ──────────────────────────────────────────────────────────── + +/** + * Ensure directories, branches, and other prerequisites exist before + * dispatching a unit. The LLM should never need to mkdir or git checkout. + */ +function ensurePreconditions( + unitType: string, + unitId: string, + base: string, + state: GSDState, +): void { + const parts = unitId.split("/"); + const mid = parts[0]!; + + const mDir = resolveMilestonePath(base, mid); + if (!mDir) { + const newDir = join(milestonesDir(base), mid); + mkdirSync(join(newDir, "slices"), { recursive: true }); + } + + if (parts.length >= 2) { + const sid = parts[1]!; + + const mDirResolved = resolveMilestonePath(base, mid); + if (mDirResolved) { + const slicesDir = join(mDirResolved, "slices"); + const sDir = resolveDir(slicesDir, sid); + if (!sDir) { + mkdirSync(join(slicesDir, sid, "tasks"), { recursive: true }); + } + const resolvedSliceDir = resolveDir(slicesDir, sid) ?? sid; + const tasksDir = join(slicesDir, resolvedSliceDir, "tasks"); + if (!existsSync(tasksDir)) { + mkdirSync(tasksDir, { recursive: true }); + } + } + } +} + if (s.paused) { const resumeLock = acquireSessionLock(base); if (!resumeLock.acquired) { diff --git a/src/resources/extensions/gsd/guided-flow.ts b/src/resources/extensions/gsd/guided-flow.ts index 983e42b4d..d6e284561 100644 --- a/src/resources/extensions/gsd/guided-flow.ts +++ b/src/resources/extensions/gsd/guided-flow.ts @@ -14,7 +14,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 { @@ -867,38 +872,32 @@ 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" - && crashLock.completedUnits === 0; - - 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; - } + } else if (interrupted.classification === "recoverable") { + if (interrupted.lock) clearLock(basePath); + const resume = await showNextAction(ctx, { + title: "GSD — Interrupted Session Detected", + summary: formatInterruptedSessionSummary(interrupted), + 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, { interrupted }); + return; } } - const state = await deriveState(basePath); + const state = interrupted.state ?? await deriveState(basePath); + if (!state.activeMilestone) { // Guard: if a discuss session is already in flight, don't re-inject the prompt. diff --git a/src/resources/extensions/gsd/interrupted-session.ts b/src/resources/extensions/gsd/interrupted-session.ts new file mode 100644 index 000000000..93f4525e5 --- /dev/null +++ b/src/resources/extensions/gsd/interrupted-session.ts @@ -0,0 +1,201 @@ +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; +} + +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" && + lock.completedUnits === 0 + ); +} + +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 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(basePath); + const hasResumableDiskState = hasResumableDerivedState(state); + const artifactSatisfied = !!( + lock && + !isBootstrapCrash && + verifyExpectedArtifact(lock.unitType, lock.unitId, basePath) + ); + + let recovery: RecoveryBriefing | null = null; + if (lock && !isBootstrapCrash && !artifactSatisfied) { + recovery = synthesizeCrashRecovery( + basePath, + lock.unitType, + lock.unitId, + lock.sessionFile, + join(gsdRoot(basePath), "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 (lock && artifactSatisfied && !pausedSession) { + return { + classification: "stale", + lock, + pausedSession, + state, + recovery, + recoveryPrompt, + recoveryToolCallCount, + artifactSatisfied, + hasResumableDiskState, + isBootstrapCrash: false, + }; + } + + const hasStrongRecoverySignal = + !!pausedSession || recoveryToolCallCount > 0 || hasResumableDiskState; + + 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 2bd57caef..19993441c 100644 --- a/src/resources/extensions/gsd/tests/auto-recovery.test.ts +++ b/src/resources/extensions/gsd/tests/auto-recovery.test.ts @@ -315,6 +315,71 @@ test("verifyExpectedArtifact accepts plan-slice with completed tasks", () => { } }); +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", () => { diff --git a/src/resources/extensions/gsd/tests/crash-recovery.test.ts b/src/resources/extensions/gsd/tests/crash-recovery.test.ts index bce69cc7a..39af30e1e 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 type { GSDState } from "../types.ts"; +import { gsdRoot } from "../paths.ts"; function makeTmpBase(): string { const base = join(tmpdir(), `gsd-test-${randomUUID()}`); @@ -24,6 +32,252 @@ function cleanup(base: string): void { try { rmSync(base, { recursive: true, force: true }); } catch { /* */ } } +function writeTestLock( + base: string, + unitType: string, + unitId: string, + completedUnits: number, + sessionFile?: string, +): void { + writeFileSync( + join(gsdRoot(base), "auto.lock"), + JSON.stringify({ + pid: 999999999, + startedAt: new Date().toISOString(), + unitType, + unitId, + unitStartedAt: new Date().toISOString(), + completedUnits, + 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"): void { + const runtimeDir = join(base, ".gsd", "runtime"); + mkdirSync(runtimeDir, { recursive: true }); + writeFileSync( + join(runtimeDir, "paused-session.json"), + JSON.stringify({ milestoneId, originalBasePath: base, stepMode: false }, 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 zero-completed special case", () => { + const bootstrap: LockData = { + pid: 999999999, + startedAt: new Date().toISOString(), + unitType: "starting", + unitId: "bootstrap", + unitStartedAt: new Date().toISOString(), + completedUnits: 0, + }; + assert.equal(isBootstrapCrashLock(bootstrap), true); + assert.equal(isBootstrapCrashLock({ ...bootstrap, completedUnits: 1 }), 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("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", 1); + + 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", async () => { + const base = makeTmpBase(); + try { + writeRoadmap(base, true); + writeCompleteSliceArtifacts(base); + writeTestLock(base, "complete-slice", "M001/S01", 1); + + 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", async () => { + const base = makeTmpBase(); + try { + writePausedSession(base); + writeTestLock(base, "execute-task", "M001/S01/T01", 1); + + const assessment = await assessInterruptedSession(base); + assert.equal(assessment.classification, "recoverable"); + assert.equal(assessment.pausedSession?.milestoneId, "M001"); + } 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", 1); + + 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", 1); + 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", 0); + + 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", () => { @@ -77,8 +331,7 @@ test("clearLock is safe when no lock exists", () => { // ─── isLockProcessAlive ────────────────────────────────────────────────── -test("isLockProcessAlive returns true for current process (different pid)", () => { - // Our own PID is explicitly excluded (recycled PID guard) +test("isLockProcessAlive returns false for own PID", () => { const lock: LockData = { pid: process.pid, startedAt: new Date().toISOString(), @@ -92,7 +345,7 @@ test("isLockProcessAlive returns true for current process (different pid)", () = 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..e56d07968 --- /dev/null +++ b/src/resources/extensions/gsd/tests/interrupted-session-auto.test.ts @@ -0,0 +1,114 @@ +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, completedUnits = 1): void { + writeFileSync( + join(base, ".gsd", "auto.lock"), + JSON.stringify({ + pid: 999999999, + startedAt: new Date().toISOString(), + unitType, + unitId, + unitStartedAt: new Date().toISOString(), + completedUnits, + }, null, 2), + "utf-8", + ); +} + +function writePausedSession(base: string): void { + const runtimeDir = join(base, ".gsd", "runtime"); + mkdirSync(runtimeDir, { recursive: true }); + writeFileSync( + join(runtimeDir, "paused-session.json"), + JSON.stringify({ milestoneId: "M001", originalBasePath: base, stepMode: false }, null, 2), + "utf-8", + ); +} + +test("direct /gsd auto stale complete repo yields stale classification with no recovery messaging payload", async () => { + const base = makeTmpBase(); + try { + writeRoadmap(base, true); + writeCompleteArtifacts(base); + writeLock(base, "execute-task", "M001/S01/T01", 1); + + 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", async () => { + const base = makeTmpBase(); + try { + writeRoadmap(base, false); + writePausedSession(base); + writeLock(base, "execute-task", "M001/S01/T01", 1); + + const assessment = await assessInterruptedSession(base); + assert.equal(assessment.classification, "recoverable"); + assert.equal(assessment.pausedSession?.milestoneId, "M001"); + } finally { + cleanup(base); + } +}); 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..3e7ffcabe --- /dev/null +++ b/src/resources/extensions/gsd/tests/interrupted-session-ui.test.ts @@ -0,0 +1,122 @@ +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): void { + const runtimeDir = join(base, ".gsd", "runtime"); + mkdirSync(runtimeDir, { recursive: true }); + writeFileSync( + join(runtimeDir, "paused-session.json"), + JSON.stringify({ milestoneId: "M001", originalBasePath: base, stepMode: false }, null, 2), + "utf-8", + ); +} + +function writeLock(base: string, unitType: string, unitId: string, completedUnits = 1): void { + writeFileSync( + join(base, ".gsd", "auto.lock"), + JSON.stringify({ + pid: 999999999, + startedAt: new Date().toISOString(), + unitType, + unitId, + unitStartedAt: new Date().toISOString(), + completedUnits, + }, 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", 1); + + 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", 1); + + const assessment = await assessInterruptedSession(base); + assert.equal(assessment.classification, "recoverable"); + assert.equal(assessment.pausedSession?.milestoneId, "M001"); + } finally { + cleanup(base); + } +}); + +test("guided-flow source gates interrupted-session UI on assessment classification", () => { + 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('if (interrupted.classification === "running")')); + assert.ok(source.includes('if (interrupted.classification === "stale")')); + assert.ok(source.includes('} else if (interrupted.classification === "recoverable")')); + assert.ok(source.includes('await startAuto(ctx, pi, basePath, false, { interrupted });')); +});