From ecf6af92e82a251dd3884a3ee8f3d72e6087b6f1 Mon Sep 17 00:00:00 2001 From: Mikael Hugo Date: Fri, 15 May 2026 20:56:15 +0200 Subject: [PATCH] fix(auto): avoid resuming blocked no-unit sessions --- src/resources/extensions/sf/auto.js | 52 ++++++++++--------- .../extensions/sf/interrupted-session.js | 4 +- .../sf/tests/interrupted-session.test.mjs | 27 ++++++++++ 3 files changed, 58 insertions(+), 25 deletions(-) create mode 100644 src/resources/extensions/sf/tests/interrupted-session.test.mjs diff --git a/src/resources/extensions/sf/auto.js b/src/resources/extensions/sf/auto.js index 5362b4280..a329738e1 100644 --- a/src/resources/extensions/sf/auto.js +++ b/src/resources/extensions/sf/auto.js @@ -1208,30 +1208,34 @@ export async function pauseAuto(ctx, _pi, _errorContext) { // Persist paused-session metadata so resume survives /exit (#1383). // The fresh-start bootstrap checks for this file and restores worktree context. try { - const pausedMeta = { - milestoneId: s.currentMilestoneId, - worktreePath: isInAutoWorktree(s.basePath) ? s.basePath : null, - originalBasePath: s.originalBasePath, - stepMode: s.stepMode, - pausedAt: new Date().toISOString(), - sessionFile: s.pausedSessionFile, - unitType: s.currentUnit?.type ?? undefined, - unitId: s.currentUnit?.id ?? undefined, - activeEngineId: s.activeEngineId, - activeRunDir: s.activeRunDir, - autoStartTime: s.autoStartTime, - milestoneLock: s.sessionMilestoneLock ?? undefined, - }; - const runtimeDir = join( - sfRoot(s.originalBasePath || s.basePath), - "runtime", - ); - mkdirSync(runtimeDir, { recursive: true }); - writeFileSync( - join(runtimeDir, "paused-session.json"), - JSON.stringify(pausedMeta, null, 2), - "utf-8", - ); + const hasResumableUnit = !!(s.currentUnit?.type && s.currentUnit?.id); + const hasResumableCustomEngine = !!s.activeEngineId && s.activeEngineId !== "dev"; + if (hasResumableUnit || hasResumableCustomEngine) { + const pausedMeta = { + milestoneId: s.currentMilestoneId, + worktreePath: isInAutoWorktree(s.basePath) ? s.basePath : null, + originalBasePath: s.originalBasePath, + stepMode: s.stepMode, + pausedAt: new Date().toISOString(), + sessionFile: s.pausedSessionFile, + unitType: s.currentUnit?.type ?? undefined, + unitId: s.currentUnit?.id ?? undefined, + activeEngineId: s.activeEngineId, + activeRunDir: s.activeRunDir, + autoStartTime: s.autoStartTime, + milestoneLock: s.sessionMilestoneLock ?? undefined, + }; + const runtimeDir = join( + sfRoot(s.originalBasePath || s.basePath), + "runtime", + ); + mkdirSync(runtimeDir, { recursive: true }); + writeFileSync( + join(runtimeDir, "paused-session.json"), + JSON.stringify(pausedMeta, null, 2), + "utf-8", + ); + } } catch (err) { // Non-fatal — resume will still work via full bootstrap, just without worktree context logWarning( diff --git a/src/resources/extensions/sf/interrupted-session.js b/src/resources/extensions/sf/interrupted-session.js index 8d2a9fc74..27f33d438 100644 --- a/src/resources/extensions/sf/interrupted-session.js +++ b/src/resources/extensions/sf/interrupted-session.js @@ -26,7 +26,9 @@ export function isBootstrapCrashLock(lock) { ); } export function hasResumableDerivedState(state) { - return !!(state?.activeMilestone && state.phase !== "complete"); + if (!state?.activeMilestone) return false; + if (state.phase === "complete" || state.phase === "blocked") return false; + return true; } export async function assessInterruptedSession(basePath) { const pausedSession = readPausedSessionMetadata(basePath); diff --git a/src/resources/extensions/sf/tests/interrupted-session.test.mjs b/src/resources/extensions/sf/tests/interrupted-session.test.mjs new file mode 100644 index 000000000..6b44c7907 --- /dev/null +++ b/src/resources/extensions/sf/tests/interrupted-session.test.mjs @@ -0,0 +1,27 @@ +import assert from "node:assert/strict"; +import { test } from "vitest"; +import { hasResumableDerivedState } from "../interrupted-session.js"; + +test("hasResumableDerivedState_rejects_blocked_state_without_active_work", () => { + assert.equal( + hasResumableDerivedState({ + activeMilestone: { id: "M005", title: "Blocked milestone" }, + activeSlice: null, + activeTask: null, + phase: "blocked", + }), + false, + ); +}); + +test("hasResumableDerivedState_accepts_active_non_blocked_state", () => { + assert.equal( + hasResumableDerivedState({ + activeMilestone: { id: "M005", title: "Active milestone" }, + activeSlice: { id: "S01", title: "Active slice" }, + activeTask: null, + phase: "planning", + }), + true, + ); +});