From d82c323be24e1b4d9713f98a9f4a7d971fe7ba61 Mon Sep 17 00:00:00 2001 From: Derek Pearson Date: Fri, 20 Mar 2026 22:34:30 -0400 Subject: [PATCH 01/15] 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 });')); +}); From 33564894cf762cdaf0932b9eeaa52621d0c15f3d Mon Sep 17 00:00:00 2001 From: Derek Pearson Date: Sat, 21 Mar 2026 13:20:21 -0400 Subject: [PATCH 02/15] fix: preserve step-mode and suppress stale paused resumes Remove the duplicated auto bootstrap block, keep paused step-mode sessions on /gsd next, and treat stale paused-session metadata as non-resumable when disk state shows no unfinished work. --- src/resources/extensions/gsd/auto.ts | 372 ------------------ src/resources/extensions/gsd/guided-flow.ts | 5 +- .../extensions/gsd/interrupted-session.ts | 21 +- .../gsd/tests/crash-recovery.test.ts | 28 +- .../tests/interrupted-session-auto.test.ts | 31 +- .../gsd/tests/interrupted-session-ui.test.ts | 34 +- 6 files changed, 102 insertions(+), 389 deletions(-) diff --git a/src/resources/extensions/gsd/auto.ts b/src/resources/extensions/gsd/auto.ts index 31a205f03..c55ccc46e 100644 --- a/src/resources/extensions/gsd/auto.ts +++ b/src/resources/extensions/gsd/auto.ts @@ -1220,378 +1220,6 @@ function ensurePreconditions( } } - 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, - ); - 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 }); - } - } - } -} - -// ─── 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, - hookName: string, - triggerUnitType: string, - triggerUnitId: string, - hookPrompt: string, - hookModel: string | undefined, - targetBasePath: string, -): Promise { - if (!s.active) { - s.active = true; - s.stepMode = true; - s.cmdCtx = ctx as ExtensionCommandContext; - s.basePath = targetBasePath; - s.autoStartTime = Date.now(); - s.currentUnit = null; - s.completedUnits = []; - s.pendingQuickTasks = []; - } - - const hookUnitType = `hook/${hookName}`; - const hookStartedAt = Date.now(); - - s.currentUnit = { - type: triggerUnitType, - id: triggerUnitId, - startedAt: hookStartedAt, - }; - - const result = await s.cmdCtx!.newSession(); - if (result.cancelled) { - await stopAuto(ctx, pi); - return false; - } - - s.currentUnit = { - type: hookUnitType, - id: triggerUnitId, - startedAt: hookStartedAt, - }; - - writeUnitRuntimeRecord( - s.basePath, - hookUnitType, - triggerUnitId, - hookStartedAt, - { - phase: "dispatched", - wrapupWarningSent: false, - timeoutAt: null, - lastProgressAt: hookStartedAt, - progressCount: 0, - lastProgressKind: "dispatch", - }, - ); - - if (hookModel) { - const availableModels = ctx.modelRegistry.getAvailable(); - const match = availableModels.find( - (m) => m.id === hookModel || `${m.provider}/${m.id}` === hookModel, - ); - if (match) { - try { - await pi.setModel(match); - } catch { - /* non-fatal */ - } - } - } - - const sessionFile = ctx.sessionManager.getSessionFile(); - writeLock( - lockBase(), - hookUnitType, - triggerUnitId, - s.completedUnits.length, - sessionFile, - ); - - clearUnitTimeout(); - const supervisor = resolveAutoSupervisorConfig(); - const hookHardTimeoutMs = (supervisor.hard_timeout_minutes ?? 30) * 60 * 1000; - s.unitTimeoutHandle = setTimeout(async () => { - s.unitTimeoutHandle = null; - if (!s.active) return; - if (s.currentUnit) { - writeUnitRuntimeRecord( - s.basePath, - hookUnitType, - triggerUnitId, - hookStartedAt, - { - phase: "timeout", - timeoutAt: Date.now(), - }, - ); - } - ctx.ui.notify( - `Hook ${hookName} exceeded ${supervisor.hard_timeout_minutes ?? 30}min timeout. Pausing auto-mode.`, - "warning", - ); - resetHookState(); - await pauseAuto(ctx, pi); - }, hookHardTimeoutMs); - - ctx.ui.setStatus("gsd-auto", s.stepMode ? "next" : "auto"); - ctx.ui.notify(`Running post-unit hook: ${hookName}`, "info"); - - // Ensure cwd matches basePath before hook dispatch (#1389) - try { if (process.cwd() !== s.basePath) process.chdir(s.basePath); } catch {} - - debugLog("dispatchHookUnit", { - phase: "send-message", - promptLength: hookPrompt.length, - }); - pi.sendMessage( - { customType: "gsd-auto", content: hookPrompt, display: true }, - { triggerTurn: true }, - ); - - return true; -} - -// Direct phase dispatch → auto-direct-dispatch.ts -export { dispatchDirectPhase } from "./auto-direct-dispatch.js"; - // Re-export recovery functions for external consumers export { resolveExpectedArtifactPath, diff --git a/src/resources/extensions/gsd/guided-flow.ts b/src/resources/extensions/gsd/guided-flow.ts index d6e284561..4e1ccb4ec 100644 --- a/src/resources/extensions/gsd/guided-flow.ts +++ b/src/resources/extensions/gsd/guided-flow.ts @@ -882,11 +882,14 @@ export async function showSmartEntry( clearLock(basePath); } 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: "Resume with /gsd auto", description: "Pick up where it left off", recommended: true }, + { id: "resume", label: resumeLabel, description: "Pick up where it left off", recommended: true }, { id: "continue", label: "Continue manually", description: "Open the wizard as normal" }, ], }); diff --git a/src/resources/extensions/gsd/interrupted-session.ts b/src/resources/extensions/gsd/interrupted-session.ts index 93f4525e5..dca5f9392 100644 --- a/src/resources/extensions/gsd/interrupted-session.ts +++ b/src/resources/extensions/gsd/interrupted-session.ts @@ -145,7 +145,22 @@ export async function assessInterruptedSession( }; } - if (lock && artifactSatisfied && !pausedSession) { + 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, @@ -161,7 +176,9 @@ export async function assessInterruptedSession( } const hasStrongRecoverySignal = - !!pausedSession || recoveryToolCallCount > 0 || hasResumableDiskState; + (pausedSession && hasResumableDiskState) || + recoveryToolCallCount > 0 || + hasResumableDiskState; return { classification: hasStrongRecoverySignal ? "recoverable" : "stale", diff --git a/src/resources/extensions/gsd/tests/crash-recovery.test.ts b/src/resources/extensions/gsd/tests/crash-recovery.test.ts index 39af30e1e..1ae8e2fb3 100644 --- a/src/resources/extensions/gsd/tests/crash-recovery.test.ts +++ b/src/resources/extensions/gsd/tests/crash-recovery.test.ts @@ -19,8 +19,8 @@ import { isBootstrapCrashLock, readPausedSessionMetadata, } from "../interrupted-session.ts"; -import type { GSDState } from "../types.ts"; import { gsdRoot } from "../paths.ts"; +import type { GSDState } from "../types.ts"; function makeTmpBase(): string { const base = join(tmpdir(), `gsd-test-${randomUUID()}`); @@ -98,12 +98,12 @@ function writeCompleteMilestoneSummary(base: string): void { writeFileSync(join(milestoneDir, "M001-SUMMARY.md"), "# Milestone Summary\nDone.\n", "utf-8"); } -function writePausedSession(base: string, milestoneId = "M001"): void { +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: false }, null, 2), + JSON.stringify({ milestoneId, originalBasePath: base, stepMode }, null, 2), "utf-8", ); } @@ -180,11 +180,12 @@ test("assessInterruptedSession classifies stale complete repo as stale and suppr } }); -test("assessInterruptedSession suppresses prompt when expected artifact already exists", async () => { +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", 1); const assessment = await assessInterruptedSession(base); @@ -195,9 +196,10 @@ test("assessInterruptedSession suppresses prompt when expected artifact already } }); -test("assessInterruptedSession keeps paused-session resume recoverable", async () => { +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", 1); @@ -209,6 +211,22 @@ test("assessInterruptedSession keeps paused-session resume recoverable", async ( } }); +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 keeps unfinished derived state recoverable without trace", async () => { const base = makeTmpBase(); try { diff --git a/src/resources/extensions/gsd/tests/interrupted-session-auto.test.ts b/src/resources/extensions/gsd/tests/interrupted-session-auto.test.ts index e56d07968..dd0c54f69 100644 --- a/src/resources/extensions/gsd/tests/interrupted-session-auto.test.ts +++ b/src/resources/extensions/gsd/tests/interrupted-session-auto.test.ts @@ -72,17 +72,17 @@ function writeLock(base: string, unitType: string, unitId: string, completedUnit ); } -function writePausedSession(base: string): void { +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: "M001", originalBasePath: base, stepMode: false }, null, 2), + JSON.stringify({ milestoneId, originalBasePath: base, stepMode }, null, 2), "utf-8", ); } -test("direct /gsd auto stale complete repo yields stale classification with no recovery messaging payload", async () => { +test("direct /gsd auto stale complete repo yields stale classification with no recovery payload", async () => { const base = makeTmpBase(); try { writeRoadmap(base, true); @@ -98,11 +98,11 @@ test("direct /gsd auto stale complete repo yields stale classification with no r } }); -test("direct /gsd auto paused-session metadata remains recoverable", async () => { +test("direct /gsd auto paused-session metadata remains recoverable when work is unfinished", async () => { const base = makeTmpBase(); try { writeRoadmap(base, false); - writePausedSession(base); + writePausedSession(base, "M001", false); writeLock(base, "execute-task", "M001/S01/T01", 1); const assessment = await assessInterruptedSession(base); @@ -112,3 +112,24 @@ test("direct /gsd auto paused-session metadata remains recoverable", async () => 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("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 index 3e7ffcabe..df0f820e4 100644 --- a/src/resources/extensions/gsd/tests/interrupted-session-ui.test.ts +++ b/src/resources/extensions/gsd/tests/interrupted-session-ui.test.ts @@ -57,12 +57,22 @@ function writeCompleteArtifacts(base: string): void { writeFileSync(join(milestoneDir, "M001-SUMMARY.md"), "# Milestone Summary\nDone.\n", "utf-8"); } -function writePausedSession(base: string): void { +function writePausedSession(base: string, stepMode = false): 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), + JSON.stringify({ milestoneId: "M001", originalBasePath: base, stepMode }, null, 2), + "utf-8", + ); +} + +function writeStalePausedSession(base: string, stepMode = false): void { + const runtimeDir = join(base, ".gsd", "runtime"); + mkdirSync(runtimeDir, { recursive: true }); + writeFileSync( + join(runtimeDir, "paused-session.json"), + JSON.stringify({ milestoneId: "M999", originalBasePath: base, stepMode }, null, 2), "utf-8", ); } @@ -112,11 +122,27 @@ test("guided-flow paused-session scenario classifies as recoverable so resume re } }); -test("guided-flow source gates interrupted-session UI on assessment classification", () => { +test("guided-flow stale paused-session scenario is suppressed when no resumable work remains", async () => { + const base = makeTmpBase(); + try { + writeRoadmap(base, true); + writeCompleteArtifacts(base); + writeStalePausedSession(base, 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 label and shared assessment", () => { 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('resumeLabel = interrupted.pausedSession?.stepMode')); + assert.ok(source.includes('"Resume with /gsd next"')); assert.ok(source.includes('await startAuto(ctx, pi, basePath, false, { interrupted });')); }); From 2b4e78d9ab92020aa2ccfb5ec7adcef652a3996a Mon Sep 17 00:00:00 2001 From: Derek Pearson Date: Sat, 21 Mar 2026 13:41:22 -0400 Subject: [PATCH 03/15] fix: preserve explicit interrupted-session resume mode Pass paused step-mode metadata through guided resume actions and tighten source coverage around stale paused-session handling so the interrupted-session flow matches the selected resume mode. --- src/resources/extensions/gsd/guided-flow.ts | 5 ++++- .../gsd/tests/interrupted-session-auto.test.ts | 11 +++++++++++ .../gsd/tests/interrupted-session-ui.test.ts | 5 +++-- 3 files changed, 18 insertions(+), 3 deletions(-) diff --git a/src/resources/extensions/gsd/guided-flow.ts b/src/resources/extensions/gsd/guided-flow.ts index 4e1ccb4ec..f8019ec30 100644 --- a/src/resources/extensions/gsd/guided-flow.ts +++ b/src/resources/extensions/gsd/guided-flow.ts @@ -894,7 +894,10 @@ export async function showSmartEntry( ], }); if (resume === "resume") { - await startAuto(ctx, pi, basePath, false, { interrupted }); + await startAuto(ctx, pi, basePath, false, { + interrupted, + step: interrupted.pausedSession?.stepMode ?? false, + }); return; } } diff --git a/src/resources/extensions/gsd/tests/interrupted-session-auto.test.ts b/src/resources/extensions/gsd/tests/interrupted-session-auto.test.ts index dd0c54f69..f81cb6266 100644 --- a/src/resources/extensions/gsd/tests/interrupted-session-auto.test.ts +++ b/src/resources/extensions/gsd/tests/interrupted-session-auto.test.ts @@ -128,6 +128,17 @@ test("direct /gsd auto stale paused-session metadata is treated as stale when no } }); +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('freshStartAssessment.classification === "recoverable"')); + assert.ok(source.includes('freshStartAssessment.hasResumableDiskState')); + assert.ok(source.includes('|| !!freshStartAssessment.recoveryPrompt')); + assert.ok(source.includes('|| !!freshStartAssessment.lock')); + assert.ok(!source.includes('freshStartAssessment.classification === "recoverable"\n || freshStartAssessment.hasResumableDiskState')); +}); + 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"); diff --git a/src/resources/extensions/gsd/tests/interrupted-session-ui.test.ts b/src/resources/extensions/gsd/tests/interrupted-session-ui.test.ts index df0f820e4..9ff1e8c1d 100644 --- a/src/resources/extensions/gsd/tests/interrupted-session-ui.test.ts +++ b/src/resources/extensions/gsd/tests/interrupted-session-ui.test.ts @@ -137,12 +137,13 @@ test("guided-flow stale paused-session scenario is suppressed when no resumable } }); -test("guided-flow source uses step-aware resume label and shared assessment", () => { +test("guided-flow source uses step-aware resume label and passes step mode into startAuto", () => { 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('resumeLabel = interrupted.pausedSession?.stepMode')); assert.ok(source.includes('"Resume with /gsd next"')); - assert.ok(source.includes('await startAuto(ctx, pi, basePath, false, { interrupted });')); + assert.ok(source.includes('step: interrupted.pausedSession?.stepMode ?? false')); + assert.ok(!source.includes('await startAuto(ctx, pi, basePath, false, { interrupted });')); }); From 9992d24649e29d5a02b46eccc60344902ffa7ce8 Mon Sep 17 00:00:00 2001 From: Derek Pearson Date: Sat, 21 Mar 2026 14:03:18 -0400 Subject: [PATCH 04/15] fix(gsd): preserve interrupted-session resume mode Discard stale paused-session metadata on direct auto starts and pass paused step-mode through guided resume so the selected interrupted-session action matches the actual resumed mode. --- src/resources/extensions/gsd/auto.ts | 40 +++++++++++++------ .../tests/interrupted-session-auto.test.ts | 3 +- 2 files changed, 30 insertions(+), 13 deletions(-) diff --git a/src/resources/extensions/gsd/auto.ts b/src/resources/extensions/gsd/auto.ts index c55ccc46e..34c4b55dd 100644 --- a/src/resources/extensions/gsd/auto.ts +++ b/src/resources/extensions/gsd/auto.ts @@ -954,24 +954,40 @@ export async function startAuto( if (!s.paused) { try { const meta = freshStartAssessment.pausedSession ?? readPausedSessionMetadata(base); + const pausedPath = join(gsdRoot(base), "runtime", "paused-session.json"); 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", - ); + const shouldResumePausedSession = + freshStartAssessment.classification === "recoverable" + && ( + freshStartAssessment.hasResumableDiskState + || !!freshStartAssessment.recoveryPrompt + || !!freshStartAssessment.lock + ); + if (shouldResumePausedSession) { + s.currentMilestoneId = meta.milestoneId; + s.originalBasePath = meta.originalBasePath || base; + s.stepMode = meta.stepMode ?? requestedStepMode; + s.pausedSessionFile = meta.sessionFile ?? null; + s.paused = true; + try { unlinkSync(pausedPath); } catch { /* non-fatal */ } + ctx.ui.notify( + `Resuming paused session for ${meta.milestoneId}${meta.worktreePath ? ` (worktree)` : ""}.`, + "info", + ); + } else if (existsSync(pausedPath)) { + try { unlinkSync(pausedPath); } catch { /* non-fatal */ } + } } } catch { // Malformed or missing — proceed with fresh bootstrap } } + if (!s.paused) { + s.stepMode = requestedStepMode; + } + + if (freshStartAssessment.classification !== "running" && freshStartAssessment.lock) { clearLock(base); } @@ -1005,7 +1021,7 @@ export async function startAuto( s.paused = false; s.active = true; s.verbose = verboseMode; - s.stepMode = requestedStepMode; + s.stepMode = s.stepMode || requestedStepMode; s.cmdCtx = ctx; s.basePath = base; s.unitDispatchCount.clear(); diff --git a/src/resources/extensions/gsd/tests/interrupted-session-auto.test.ts b/src/resources/extensions/gsd/tests/interrupted-session-auto.test.ts index f81cb6266..fefa690a5 100644 --- a/src/resources/extensions/gsd/tests/interrupted-session-auto.test.ts +++ b/src/resources/extensions/gsd/tests/interrupted-session-auto.test.ts @@ -132,11 +132,12 @@ test("direct /gsd auto source only resumes paused-session metadata for recoverab 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')); - assert.ok(!source.includes('freshStartAssessment.classification === "recoverable"\n || freshStartAssessment.hasResumableDiskState')); }); test("auto module imports successfully after interrupted-session changes", async () => { From 7a384a45d1c3622fc9a38bddb773616b3b32b1e0 Mon Sep 17 00:00:00 2001 From: Derek Pearson Date: Sat, 21 Mar 2026 19:32:59 -0400 Subject: [PATCH 05/15] fix(gsd): clear stale paused metadata in guided flow Remove stale paused-session state when guided entry suppresses interrupted resume prompts and keep discuss-triggered auto resumes in step mode. --- src/resources/extensions/gsd/guided-flow.ts | 14 ++++++++--- .../gsd/tests/interrupted-session-ui.test.ts | 25 ++++++------------- 2 files changed, 18 insertions(+), 21 deletions(-) diff --git a/src/resources/extensions/gsd/guided-flow.ts b/src/resources/extensions/gsd/guided-flow.ts index f8019ec30..599aa777a 100644 --- a/src/resources/extensions/gsd/guided-flow.ts +++ b/src/resources/extensions/gsd/guided-flow.ts @@ -15,6 +15,7 @@ import { deriveState } from "./state.js"; import { invalidateAllCaches } from "./cache.js"; import { startAuto } from "./auto.js"; import { clearLock } from "./crash-recovery.js"; +import { gsdRoot } from "./paths.js"; import { assessInterruptedSession, formatInterruptedSessionRunningMessage, @@ -544,12 +545,12 @@ export async function showDiscuss( const seed = draftContent ? `${basePrompt}\n\n## Prior Discussion (Draft Seed)\n\n${draftContent}` : basePrompt; - pendingAutoStart = { ctx, pi, basePath, milestoneId: mid, step: false }; + pendingAutoStart = { ctx, pi, basePath, milestoneId: mid, step: true }; await dispatchWorkflow(pi, seed, "gsd-discuss", ctx, "plan-milestone"); } else if (choice === "discuss_fresh") { const discussMilestoneTemplates = inlineTemplate("context", "Context"); const structuredQuestionsAvailable = pi.getActiveTools().includes("ask_user_questions") ? "true" : "false"; - pendingAutoStart = { ctx, pi, basePath, milestoneId: mid, step: false }; + pendingAutoStart = { ctx, pi, basePath, milestoneId: mid, step: true }; await dispatchWorkflow(pi, loadPrompt("guided-discuss-milestone", { milestoneId: mid, milestoneTitle, inlinedTemplates: discussMilestoneTemplates, structuredQuestionsAvailable, commitInstruction: buildDocsCommitInstruction(`docs(${mid}): milestone context from discuss`), @@ -558,7 +559,7 @@ export async function showDiscuss( const milestoneIds = findMilestoneIds(basePath); const uniqueMilestoneIds = !!loadEffectiveGSDPreferences()?.preferences?.unique_milestone_ids; const nextId = nextMilestoneId(milestoneIds, uniqueMilestoneIds); - pendingAutoStart = { ctx, pi, basePath, milestoneId: nextId, step: false }; + pendingAutoStart = { ctx, pi, basePath, milestoneId: nextId, step: true }; await dispatchWorkflow(pi, buildDiscussPrompt(nextId, `New milestone ${nextId}.`, basePath), "gsd-run", ctx, "plan-milestone"); } return; @@ -880,6 +881,13 @@ export async function showSmartEntry( if (interrupted.classification === "stale") { clearLock(basePath); + if (interrupted.pausedSession) { + try { + unlinkSync(join(gsdRoot(basePath), "runtime", "paused-session.json")); + } catch { + // Non-fatal stale metadata cleanup. + } + } } else if (interrupted.classification === "recoverable") { if (interrupted.lock) clearLock(basePath); const resumeLabel = interrupted.pausedSession?.stepMode diff --git a/src/resources/extensions/gsd/tests/interrupted-session-ui.test.ts b/src/resources/extensions/gsd/tests/interrupted-session-ui.test.ts index 9ff1e8c1d..67617b721 100644 --- a/src/resources/extensions/gsd/tests/interrupted-session-ui.test.ts +++ b/src/resources/extensions/gsd/tests/interrupted-session-ui.test.ts @@ -57,22 +57,12 @@ function writeCompleteArtifacts(base: string): void { writeFileSync(join(milestoneDir, "M001-SUMMARY.md"), "# Milestone Summary\nDone.\n", "utf-8"); } -function writePausedSession(base: string, stepMode = false): void { +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: "M001", originalBasePath: base, stepMode }, null, 2), - "utf-8", - ); -} - -function writeStalePausedSession(base: string, stepMode = false): void { - const runtimeDir = join(base, ".gsd", "runtime"); - mkdirSync(runtimeDir, { recursive: true }); - writeFileSync( - join(runtimeDir, "paused-session.json"), - JSON.stringify({ milestoneId: "M999", originalBasePath: base, stepMode }, null, 2), + JSON.stringify({ milestoneId, originalBasePath: base, stepMode }, null, 2), "utf-8", ); } @@ -127,7 +117,7 @@ test("guided-flow stale paused-session scenario is suppressed when no resumable try { writeRoadmap(base, true); writeCompleteArtifacts(base); - writeStalePausedSession(base, true); + writePausedSession(base, "M999", true); const assessment = await assessInterruptedSession(base); assert.equal(assessment.classification, "stale"); @@ -137,13 +127,12 @@ test("guided-flow stale paused-session scenario is suppressed when no resumable } }); -test("guided-flow source uses step-aware resume label and passes step mode into startAuto", () => { +test("guided-flow source uses step-aware resume label, step-aware discuss handoff, and stale paused cleanup", () => { 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('resumeLabel = interrupted.pausedSession?.stepMode')); - assert.ok(source.includes('"Resume with /gsd next"')); assert.ok(source.includes('step: interrupted.pausedSession?.stepMode ?? false')); - assert.ok(!source.includes('await startAuto(ctx, pi, basePath, false, { interrupted });')); + assert.ok(source.includes('unlinkSync(join(gsdRoot(basePath), "runtime", "paused-session.json"))')); + assert.ok(source.includes('pendingAutoStart = { ctx, pi, basePath, milestoneId: mid, step: true };')); + assert.ok(source.includes('pendingAutoStart = { ctx, pi, basePath, milestoneId: nextId, step: true };')); }); From 11e126a1a63a7128a9cbc5f3397fcac5d1825d29 Mon Sep 17 00:00:00 2001 From: Derek Pearson Date: Sat, 21 Mar 2026 19:52:22 -0400 Subject: [PATCH 06/15] fix(gsd): restore hook dispatch export and guided flow imports Remove a duplicate guided-flow import that broke module loading and restore the public dispatchHookUnit export so /gsd run-hook continues to work. --- src/resources/extensions/gsd/auto.ts | 127 ++++++++++++++++++++ src/resources/extensions/gsd/guided-flow.ts | 1 - 2 files changed, 127 insertions(+), 1 deletion(-) diff --git a/src/resources/extensions/gsd/auto.ts b/src/resources/extensions/gsd/auto.ts index 34c4b55dd..c82ca185f 100644 --- a/src/resources/extensions/gsd/auto.ts +++ b/src/resources/extensions/gsd/auto.ts @@ -1236,6 +1236,133 @@ function ensurePreconditions( } } +export async function dispatchHookUnit( + ctx: ExtensionContext, + pi: ExtensionAPI, + hookName: string, + triggerUnitType: string, + triggerUnitId: string, + hookPrompt: string, + hookModel: string | undefined, + targetBasePath: string, +): Promise { + if (!s.active) { + s.active = true; + s.stepMode = true; + s.cmdCtx = ctx as ExtensionCommandContext; + s.basePath = targetBasePath; + s.autoStartTime = Date.now(); + s.currentUnit = null; + s.completedUnits = []; + s.pendingQuickTasks = []; + } + + const hookUnitType = `hook/${hookName}`; + const hookStartedAt = Date.now(); + + s.currentUnit = { + type: triggerUnitType, + id: triggerUnitId, + startedAt: hookStartedAt, + }; + + const result = await s.cmdCtx!.newSession(); + if (result.cancelled) { + await stopAuto(ctx, pi); + return false; + } + + s.currentUnit = { + type: hookUnitType, + id: triggerUnitId, + startedAt: hookStartedAt, + }; + + writeUnitRuntimeRecord( + s.basePath, + hookUnitType, + triggerUnitId, + hookStartedAt, + { + phase: "dispatched", + wrapupWarningSent: false, + timeoutAt: null, + lastProgressAt: hookStartedAt, + progressCount: 0, + lastProgressKind: "dispatch", + }, + ); + + if (hookModel) { + const availableModels = ctx.modelRegistry.getAvailable(); + const match = availableModels.find( + (m) => m.id === hookModel || `${m.provider}/${m.id}` === hookModel, + ); + if (match) { + try { + await pi.setModel(match); + } catch { + /* non-fatal */ + } + } + } + + const sessionFile = ctx.sessionManager.getSessionFile(); + writeLock( + lockBase(), + hookUnitType, + triggerUnitId, + s.completedUnits.length, + sessionFile, + ); + + clearUnitTimeout(); + const supervisor = resolveAutoSupervisorConfig(); + const hookHardTimeoutMs = (supervisor.hard_timeout_minutes ?? 30) * 60 * 1000; + s.unitTimeoutHandle = setTimeout(async () => { + s.unitTimeoutHandle = null; + if (!s.active) return; + if (s.currentUnit) { + writeUnitRuntimeRecord( + s.basePath, + hookUnitType, + triggerUnitId, + hookStartedAt, + { + phase: "timeout", + timeoutAt: Date.now(), + }, + ); + } + ctx.ui.notify( + `Hook ${hookName} exceeded ${supervisor.hard_timeout_minutes ?? 30}min timeout. Pausing auto-mode.`, + "warning", + ); + resetHookState(); + await pauseAuto(ctx, pi); + }, hookHardTimeoutMs); + + ctx.ui.setStatus("gsd-auto", s.stepMode ? "next" : "auto"); + ctx.ui.notify(`Running post-unit hook: ${hookName}`, "info"); + + // Ensure cwd matches basePath before hook dispatch (#1389) + try { if (process.cwd() !== s.basePath) process.chdir(s.basePath); } catch {} + + debugLog("dispatchHookUnit", { + phase: "send-message", + promptLength: hookPrompt.length, + }); + pi.sendMessage( + { customType: "gsd-auto", content: hookPrompt, display: true }, + { triggerTurn: true }, + ); + + return true; +} + +// Direct phase dispatch → auto-direct-dispatch.ts +export { dispatchDirectPhase } from "./auto-direct-dispatch.js"; + // Re-export recovery functions for external consumers export { resolveExpectedArtifactPath, diff --git a/src/resources/extensions/gsd/guided-flow.ts b/src/resources/extensions/gsd/guided-flow.ts index 599aa777a..c57eb4aea 100644 --- a/src/resources/extensions/gsd/guided-flow.ts +++ b/src/resources/extensions/gsd/guided-flow.ts @@ -15,7 +15,6 @@ import { deriveState } from "./state.js"; import { invalidateAllCaches } from "./cache.js"; import { startAuto } from "./auto.js"; import { clearLock } from "./crash-recovery.js"; -import { gsdRoot } from "./paths.js"; import { assessInterruptedSession, formatInterruptedSessionRunningMessage, From 3bb3ca8020ab047362419d4d02dec405e333ae09 Mon Sep 17 00:00:00 2001 From: Derek Pearson Date: Sat, 21 Mar 2026 20:08:05 -0400 Subject: [PATCH 07/15] fix(gsd): satisfy extension typecheck for interrupted recovery Remove a dead running-state comparison after the early return in startAuto so extension typechecking passes alongside the interrupted-session regressions. --- src/resources/extensions/gsd/auto.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/resources/extensions/gsd/auto.ts b/src/resources/extensions/gsd/auto.ts index c82ca185f..6b163db2d 100644 --- a/src/resources/extensions/gsd/auto.ts +++ b/src/resources/extensions/gsd/auto.ts @@ -988,7 +988,7 @@ export async function startAuto( } - if (freshStartAssessment.classification !== "running" && freshStartAssessment.lock) { + if (freshStartAssessment.lock) { clearLock(base); } From 71f14269a537e79d0627ff77c1856823ac3f7ce3 Mon Sep 17 00:00:00 2001 From: Derek Pearson Date: Sat, 21 Mar 2026 20:36:03 -0400 Subject: [PATCH 08/15] fix(gsd): assess recovery from paused worktree state Use paused-session worktree metadata when deriving interrupted-session state so worktree isolation decisions follow the authoritative disk state without changing /gsd discuss auto handoff semantics. --- src/resources/extensions/gsd/guided-flow.ts | 4 +-- .../extensions/gsd/interrupted-session.ts | 9 ++++--- .../gsd/tests/crash-recovery.test.ts | 27 +++++++++++++++++-- .../gsd/tests/interrupted-session-ui.test.ts | 4 +-- 4 files changed, 34 insertions(+), 10 deletions(-) diff --git a/src/resources/extensions/gsd/guided-flow.ts b/src/resources/extensions/gsd/guided-flow.ts index c57eb4aea..b70fd3fd9 100644 --- a/src/resources/extensions/gsd/guided-flow.ts +++ b/src/resources/extensions/gsd/guided-flow.ts @@ -544,12 +544,12 @@ export async function showDiscuss( const seed = draftContent ? `${basePrompt}\n\n## Prior Discussion (Draft Seed)\n\n${draftContent}` : basePrompt; - pendingAutoStart = { ctx, pi, basePath, milestoneId: mid, step: true }; + pendingAutoStart = { ctx, pi, basePath, milestoneId: mid, step: false }; await dispatchWorkflow(pi, seed, "gsd-discuss", ctx, "plan-milestone"); } else if (choice === "discuss_fresh") { const discussMilestoneTemplates = inlineTemplate("context", "Context"); const structuredQuestionsAvailable = pi.getActiveTools().includes("ask_user_questions") ? "true" : "false"; - pendingAutoStart = { ctx, pi, basePath, milestoneId: mid, step: true }; + pendingAutoStart = { ctx, pi, basePath, milestoneId: mid, step: false }; await dispatchWorkflow(pi, loadPrompt("guided-discuss-milestone", { milestoneId: mid, milestoneTitle, inlinedTemplates: discussMilestoneTemplates, structuredQuestionsAvailable, commitInstruction: buildDocsCommitInstruction(`docs(${mid}): milestone context from discuss`), diff --git a/src/resources/extensions/gsd/interrupted-session.ts b/src/resources/extensions/gsd/interrupted-session.ts index dca5f9392..7d391b4cd 100644 --- a/src/resources/extensions/gsd/interrupted-session.ts +++ b/src/resources/extensions/gsd/interrupted-session.ts @@ -74,6 +74,7 @@ export async function assessInterruptedSession( basePath: string, ): Promise { const pausedSession = readPausedSessionMetadata(basePath); + const assessmentBasePath = pausedSession?.worktreePath || basePath; const rawLock = readCrashLock(basePath); const lock = rawLock && rawLock.pid !== process.pid ? rawLock : null; @@ -108,22 +109,22 @@ export async function assessInterruptedSession( } const isBootstrapCrash = isBootstrapCrashLock(lock); - const state = await deriveState(basePath); + const state = await deriveState(assessmentBasePath); const hasResumableDiskState = hasResumableDerivedState(state); const artifactSatisfied = !!( lock && !isBootstrapCrash && - verifyExpectedArtifact(lock.unitType, lock.unitId, basePath) + verifyExpectedArtifact(lock.unitType, lock.unitId, assessmentBasePath) ); let recovery: RecoveryBriefing | null = null; if (lock && !isBootstrapCrash && !artifactSatisfied) { recovery = synthesizeCrashRecovery( - basePath, + assessmentBasePath, lock.unitType, lock.unitId, lock.sessionFile, - join(gsdRoot(basePath), "activity"), + join(gsdRoot(assessmentBasePath), "activity"), ); } diff --git a/src/resources/extensions/gsd/tests/crash-recovery.test.ts b/src/resources/extensions/gsd/tests/crash-recovery.test.ts index 1ae8e2fb3..132dbec9b 100644 --- a/src/resources/extensions/gsd/tests/crash-recovery.test.ts +++ b/src/resources/extensions/gsd/tests/crash-recovery.test.ts @@ -98,12 +98,17 @@ function writeCompleteMilestoneSummary(base: string): void { writeFileSync(join(milestoneDir, "M001-SUMMARY.md"), "# Milestone Summary\nDone.\n", "utf-8"); } -function writePausedSession(base: string, milestoneId = "M001", stepMode = false): void { +function writePausedSession( + base: string, + milestoneId = "M001", + stepMode = false, + worktreePath?: string, +): 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), + JSON.stringify({ milestoneId, originalBasePath: base, stepMode, worktreePath }, null, 2), "utf-8", ); } @@ -227,6 +232,24 @@ test("assessInterruptedSession marks stale paused-session metadata as stale when } }); +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 { diff --git a/src/resources/extensions/gsd/tests/interrupted-session-ui.test.ts b/src/resources/extensions/gsd/tests/interrupted-session-ui.test.ts index 67617b721..6a2d0f39d 100644 --- a/src/resources/extensions/gsd/tests/interrupted-session-ui.test.ts +++ b/src/resources/extensions/gsd/tests/interrupted-session-ui.test.ts @@ -127,12 +127,12 @@ test("guided-flow stale paused-session scenario is suppressed when no resumable } }); -test("guided-flow source uses step-aware resume label, step-aware discuss handoff, and stale paused cleanup", () => { +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('pendingAutoStart = { ctx, pi, basePath, milestoneId: mid, step: true };')); + assert.ok(source.includes('pendingAutoStart = { ctx, pi, basePath, milestoneId: mid, step: false };')); assert.ok(source.includes('pendingAutoStart = { ctx, pi, basePath, milestoneId: nextId, step: true };')); }); From d3db0cb4111da3cdf20af1fad9220fb0fef9aafb Mon Sep 17 00:00:00 2001 From: Derek Pearson Date: Sat, 21 Mar 2026 20:48:12 -0400 Subject: [PATCH 09/15] fix(gsd): address review feedback from trek-e - Remove redundant `void verboseMode` suppression (parameter is used at line 364) - Simplify hasStrongRecoverySignal: `hasResumableDiskState || recoveryToolCallCount > 0` (the previous expression had a redundant third disjunct) - Remove extra blank line in auto.ts between stepMode assignment and lock-clear block --- src/resources/extensions/gsd/auto-start.ts | 1 - src/resources/extensions/gsd/auto.ts | 1 - src/resources/extensions/gsd/interrupted-session.ts | 4 +--- 3 files changed, 1 insertion(+), 5 deletions(-) diff --git a/src/resources/extensions/gsd/auto-start.ts b/src/resources/extensions/gsd/auto-start.ts index 8b405c111..85ebdc5a3 100644 --- a/src/resources/extensions/gsd/auto-start.ts +++ b/src/resources/extensions/gsd/auto-start.ts @@ -105,7 +105,6 @@ export async function bootstrapAutoSession( deps: BootstrapDeps, interrupted: InterruptedSessionAssessment, ): Promise { - void verboseMode; const { shouldUseWorktreeIsolation, registerSigtermHandler, diff --git a/src/resources/extensions/gsd/auto.ts b/src/resources/extensions/gsd/auto.ts index 6b163db2d..9d3cda351 100644 --- a/src/resources/extensions/gsd/auto.ts +++ b/src/resources/extensions/gsd/auto.ts @@ -987,7 +987,6 @@ export async function startAuto( s.stepMode = requestedStepMode; } - if (freshStartAssessment.lock) { clearLock(base); } diff --git a/src/resources/extensions/gsd/interrupted-session.ts b/src/resources/extensions/gsd/interrupted-session.ts index 7d391b4cd..f226c9507 100644 --- a/src/resources/extensions/gsd/interrupted-session.ts +++ b/src/resources/extensions/gsd/interrupted-session.ts @@ -177,9 +177,7 @@ export async function assessInterruptedSession( } const hasStrongRecoverySignal = - (pausedSession && hasResumableDiskState) || - recoveryToolCallCount > 0 || - hasResumableDiskState; + hasResumableDiskState || recoveryToolCallCount > 0; return { classification: hasStrongRecoverySignal ? "recoverable" : "stale", From 9e357f8a857fbd2437410f90ba10110b585d1e80 Mon Sep 17 00:00:00 2001 From: Derek Pearson Date: Sat, 21 Mar 2026 20:57:21 -0400 Subject: [PATCH 10/15] fix(gsd): address QA round 1 - Re-derive state from project root after stale/recoverable cleanup in guided flow; the assessment may have derived state from a worktree path that was cleaned up, leading to stale state being used downstream. - Add test for the "none" classification path (clean startup with no lock and no paused session) to close coverage gap. --- src/resources/extensions/gsd/guided-flow.ts | 5 +++-- .../gsd/tests/crash-recovery.test.ts | 19 +++++++++++++++++++ 2 files changed, 22 insertions(+), 2 deletions(-) diff --git a/src/resources/extensions/gsd/guided-flow.ts b/src/resources/extensions/gsd/guided-flow.ts index b70fd3fd9..04073b611 100644 --- a/src/resources/extensions/gsd/guided-flow.ts +++ b/src/resources/extensions/gsd/guided-flow.ts @@ -909,8 +909,9 @@ export async function showSmartEntry( } } - const state = interrupted.state ?? await deriveState(basePath); - + // 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); 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/tests/crash-recovery.test.ts b/src/resources/extensions/gsd/tests/crash-recovery.test.ts index 132dbec9b..c396ee728 100644 --- a/src/resources/extensions/gsd/tests/crash-recovery.test.ts +++ b/src/resources/extensions/gsd/tests/crash-recovery.test.ts @@ -168,6 +168,25 @@ test("readPausedSessionMetadata reads paused-session metadata when present", () } }); +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 { From 364cc7dcbc173631d65896bec0169a07fa6b9c84 Mon Sep 17 00:00:00 2001 From: Derek Pearson Date: Sat, 21 Mar 2026 21:11:11 -0400 Subject: [PATCH 11/15] fix(gsd): address QA round 2 - Initialize autoStartTime, originalModelId, originalModelProvider, and autoModeStartModel on file-based paused-session resume path so dashboard elapsed time, model restore on stop, and error-recovery model fallback all work correctly. - Validate worktreePath existence before using it as assessment base; fall back to project root when the worktree has been torn down. - Add tests for paused-session-only (no lock) with resumable disk state and for worktreePath fallback when the directory no longer exists. --- src/resources/extensions/gsd/auto.ts | 6 ++++ .../extensions/gsd/interrupted-session.ts | 5 ++- .../gsd/tests/crash-recovery.test.ts | 33 +++++++++++++++++++ 3 files changed, 43 insertions(+), 1 deletion(-) diff --git a/src/resources/extensions/gsd/auto.ts b/src/resources/extensions/gsd/auto.ts index 9d3cda351..1b15b38dc 100644 --- a/src/resources/extensions/gsd/auto.ts +++ b/src/resources/extensions/gsd/auto.ts @@ -1023,6 +1023,12 @@ export async function startAuto( s.stepMode = s.stepMode || requestedStepMode; s.cmdCtx = ctx; s.basePath = base; + s.autoStartTime = Date.now(); + s.originalModelId = ctx.model?.id ?? null; + s.originalModelProvider = ctx.model?.provider ?? null; + if (ctx.model) { + s.autoModeStartModel = { provider: ctx.model.provider, id: ctx.model.id }; + } s.unitDispatchCount.clear(); s.unitLifetimeDispatches.clear(); if (!getLedger()) initMetrics(base); diff --git a/src/resources/extensions/gsd/interrupted-session.ts b/src/resources/extensions/gsd/interrupted-session.ts index f226c9507..e5f6dcc2e 100644 --- a/src/resources/extensions/gsd/interrupted-session.ts +++ b/src/resources/extensions/gsd/interrupted-session.ts @@ -74,7 +74,10 @@ export async function assessInterruptedSession( basePath: string, ): Promise { const pausedSession = readPausedSessionMetadata(basePath); - const assessmentBasePath = pausedSession?.worktreePath || 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; diff --git a/src/resources/extensions/gsd/tests/crash-recovery.test.ts b/src/resources/extensions/gsd/tests/crash-recovery.test.ts index c396ee728..4055a371b 100644 --- a/src/resources/extensions/gsd/tests/crash-recovery.test.ts +++ b/src/resources/extensions/gsd/tests/crash-recovery.test.ts @@ -251,6 +251,39 @@ test("assessInterruptedSession marks stale paused-session metadata as stale when } }); +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"); From 690bcbd79c16579067bc7ac3b13153b7bde17e01 Mon Sep 17 00:00:00 2001 From: Derek Pearson Date: Sat, 21 Mar 2026 21:27:44 -0400 Subject: [PATCH 12/15] fix(gsd): address QA round 3 - Set resourceVersionOnStart on paused-session resume so resource staleness detection works for resumed sessions. - Re-register setLevelChangeCallback on resume so health-level transition notifications fire after process restart. - Persist unitType/unitId in paused-session metadata and restore them on resume so recovery synthesis framing text shows the actual unit instead of "unknown"/"unknown". - Check worktreePath existence before showing "(worktree)" in resume notification to avoid misleading the user when the worktree was already torn down. - Fix showDiscuss skip_milestone to use step:false (matching discuss_draft and discuss_fresh) instead of hardcoded step:true. --- src/resources/extensions/gsd/auto.ts | 17 ++++++++++++++--- src/resources/extensions/gsd/auto/session.ts | 4 ++++ src/resources/extensions/gsd/guided-flow.ts | 2 +- .../extensions/gsd/interrupted-session.ts | 2 ++ .../gsd/tests/interrupted-session-ui.test.ts | 2 +- 5 files changed, 22 insertions(+), 5 deletions(-) diff --git a/src/resources/extensions/gsd/auto.ts b/src/resources/extensions/gsd/auto.ts index 1b15b38dc..fa05b6cb9 100644 --- a/src/resources/extensions/gsd/auto.ts +++ b/src/resources/extensions/gsd/auto.ts @@ -731,6 +731,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, }; const runtimeDir = join(gsdRoot(s.originalBasePath || s.basePath), "runtime"); mkdirSync(runtimeDir, { recursive: true }); @@ -968,10 +970,12 @@ export async function startAuto( 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.paused = true; try { unlinkSync(pausedPath); } catch { /* non-fatal */ } 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)) { @@ -1024,6 +1028,7 @@ export async function startAuto( s.cmdCtx = ctx; s.basePath = base; s.autoStartTime = Date.now(); + s.resourceVersionOnStart = readResourceVersion(); s.originalModelId = ctx.model?.id ?? null; s.originalModelProvider = ctx.model?.provider ?? null; if (ctx.model) { @@ -1034,6 +1039,12 @@ export async function startAuto( 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 && @@ -1084,8 +1095,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, ); diff --git a/src/resources/extensions/gsd/auto/session.ts b/src/resources/extensions/gsd/auto/session.ts index 016a7fdf6..8ffffdd2f 100644 --- a/src/resources/extensions/gsd/auto/session.ts +++ b/src/resources/extensions/gsd/auto/session.ts @@ -118,6 +118,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; @@ -203,6 +205,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 04073b611..6f1b378f5 100644 --- a/src/resources/extensions/gsd/guided-flow.ts +++ b/src/resources/extensions/gsd/guided-flow.ts @@ -558,7 +558,7 @@ export async function showDiscuss( const milestoneIds = findMilestoneIds(basePath); const uniqueMilestoneIds = !!loadEffectiveGSDPreferences()?.preferences?.unique_milestone_ids; const nextId = nextMilestoneId(milestoneIds, uniqueMilestoneIds); - pendingAutoStart = { ctx, pi, basePath, milestoneId: nextId, step: true }; + pendingAutoStart = { ctx, pi, basePath, milestoneId: nextId, step: false }; await dispatchWorkflow(pi, buildDiscussPrompt(nextId, `New milestone ${nextId}.`, basePath), "gsd-run", ctx, "plan-milestone"); } return; diff --git a/src/resources/extensions/gsd/interrupted-session.ts b/src/resources/extensions/gsd/interrupted-session.ts index e5f6dcc2e..21d347133 100644 --- a/src/resources/extensions/gsd/interrupted-session.ts +++ b/src/resources/extensions/gsd/interrupted-session.ts @@ -29,6 +29,8 @@ export interface PausedSessionMetadata { stepMode?: boolean; pausedAt?: string; sessionFile?: string | null; + unitType?: string; + unitId?: string; } export interface InterruptedSessionAssessment { diff --git a/src/resources/extensions/gsd/tests/interrupted-session-ui.test.ts b/src/resources/extensions/gsd/tests/interrupted-session-ui.test.ts index 6a2d0f39d..a97f54e06 100644 --- a/src/resources/extensions/gsd/tests/interrupted-session-ui.test.ts +++ b/src/resources/extensions/gsd/tests/interrupted-session-ui.test.ts @@ -134,5 +134,5 @@ test("guided-flow source uses step-aware resume and clears stale paused metadata 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('pendingAutoStart = { ctx, pi, basePath, milestoneId: mid, step: false };')); - assert.ok(source.includes('pendingAutoStart = { ctx, pi, basePath, milestoneId: nextId, step: true };')); + assert.ok(source.includes('pendingAutoStart = { ctx, pi, basePath, milestoneId: nextId, step: false };')); }); From 3b9b109a4eacfbafaaf430d355c96d484c0700f2 Mon Sep 17 00:00:00 2001 From: Derek Pearson Date: Sat, 21 Mar 2026 21:45:44 -0400 Subject: [PATCH 13/15] fix(gsd): address QA round 4 - Add unitType/unitId round-trip test for paused-session metadata. - Add backward-compatibility test for legacy metadata without unitType/unitId fields. --- .../gsd/tests/crash-recovery.test.ts | 36 ++++++++++++++++++- 1 file changed, 35 insertions(+), 1 deletion(-) diff --git a/src/resources/extensions/gsd/tests/crash-recovery.test.ts b/src/resources/extensions/gsd/tests/crash-recovery.test.ts index 4055a371b..7a8b858d0 100644 --- a/src/resources/extensions/gsd/tests/crash-recovery.test.ts +++ b/src/resources/extensions/gsd/tests/crash-recovery.test.ts @@ -103,12 +103,14 @@ function writePausedSession( 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 }, null, 2), + JSON.stringify({ milestoneId, originalBasePath: base, stepMode, worktreePath, unitType, unitId }, null, 2), "utf-8", ); } @@ -168,6 +170,38 @@ test("readPausedSessionMetadata reads paused-session metadata when present", () } }); +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 { From 80a09a3503b1ffae6f3f36f2c780da452d44b6c8 Mon Sep 17 00:00:00 2001 From: Derek Pearson Date: Sun, 22 Mar 2026 12:27:13 -0400 Subject: [PATCH 14/15] fix: add activeEngineId and activeRunDir to PausedSessionMetadata interface --- src/resources/extensions/gsd/interrupted-session.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/resources/extensions/gsd/interrupted-session.ts b/src/resources/extensions/gsd/interrupted-session.ts index 21d347133..f4f65a10f 100644 --- a/src/resources/extensions/gsd/interrupted-session.ts +++ b/src/resources/extensions/gsd/interrupted-session.ts @@ -31,6 +31,8 @@ export interface PausedSessionMetadata { sessionFile?: string | null; unitType?: string; unitId?: string; + activeEngineId?: string; + activeRunDir?: string | null; } export interface InterruptedSessionAssessment { From 228dccf3f47d8e75b44136c143baaa9c11c820c5 Mon Sep 17 00:00:00 2001 From: Derek Pearson Date: Thu, 9 Apr 2026 15:31:11 -0400 Subject: [PATCH 15/15] fix: restore autoStartTime on resume + replace empty catch blocks (#3585) - auto.ts: restore s.autoStartTime from meta.autoStartTime in both custom workflow and milestone resume paths, with Date.now() fallback and zero-guard - auto.ts: replace 3 empty catch blocks with logWarning calls - guided-flow.ts: replace empty catch with logWarning call - interrupted-session.ts: add autoStartTime to PausedSessionMetadata --- src/resources/extensions/gsd/auto.ts | 10 +++++++--- src/resources/extensions/gsd/guided-flow.ts | 4 ++-- src/resources/extensions/gsd/interrupted-session.ts | 1 + 3 files changed, 10 insertions(+), 5 deletions(-) diff --git a/src/resources/extensions/gsd/auto.ts b/src/resources/extensions/gsd/auto.ts index fe21ed438..f39edba93 100644 --- a/src/resources/extensions/gsd/auto.ts +++ b/src/resources/extensions/gsd/auto.ts @@ -1164,8 +1164,9 @@ export async function startAuto( 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 { /* non-fatal */ } + 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", @@ -1197,21 +1198,24 @@ export async function startAuto( s.pausedSessionFile = meta.sessionFile ?? null; s.pausedUnitType = meta.unitType ?? null; s.pausedUnitId = meta.unitId ?? null; + s.autoStartTime = meta.autoStartTime || Date.now(); s.paused = true; - try { unlinkSync(pausedPath); } catch { /* non-fatal */ } + 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 && existsSync(meta.worktreePath) ? ` (worktree)` : ""}.`, "info", ); } } else if (existsSync(pausedPath)) { - try { unlinkSync(pausedPath); } catch { /* non-fatal */ } + 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) { diff --git a/src/resources/extensions/gsd/guided-flow.ts b/src/resources/extensions/gsd/guided-flow.ts index 451a9011c..7ba292d38 100644 --- a/src/resources/extensions/gsd/guided-flow.ts +++ b/src/resources/extensions/gsd/guided-flow.ts @@ -1314,8 +1314,8 @@ export async function showSmartEntry( if (interrupted.pausedSession) { try { unlinkSync(join(gsdRoot(basePath), "runtime", "paused-session.json")); - } catch { - // Non-fatal stale metadata cleanup. + } catch (e) { + logWarning("guided", `stale pause file cleanup failed: ${(e as Error).message}`, { file: "guided-flow.ts" }); } } } else if (interrupted.classification === "recoverable") { diff --git a/src/resources/extensions/gsd/interrupted-session.ts b/src/resources/extensions/gsd/interrupted-session.ts index 5dae6f52c..8c6274a05 100644 --- a/src/resources/extensions/gsd/interrupted-session.ts +++ b/src/resources/extensions/gsd/interrupted-session.ts @@ -33,6 +33,7 @@ export interface PausedSessionMetadata { unitId?: string; activeEngineId?: string; activeRunDir?: string | null; + autoStartTime?: number; } export interface InterruptedSessionAssessment {