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.
This commit is contained in:
Derek Pearson 2026-03-20 22:34:30 -04:00
parent c4286f4c57
commit d82c323be2
8 changed files with 1082 additions and 102 deletions

View file

@ -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<boolean> {
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();

View file

@ -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<void> {
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<void> {
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) {

View file

@ -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.

View file

@ -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<InterruptedSessionAssessment> {
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.";
}

View file

@ -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", () => {

View file

@ -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<string, unknown>[]): 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",

View file

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

View file

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