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 });')); });