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.
This commit is contained in:
parent
d82c323be2
commit
33564894cf
6 changed files with 102 additions and 389 deletions
|
|
@ -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<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 });
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ─── 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<boolean> {
|
||||
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,
|
||||
|
|
|
|||
|
|
@ -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" },
|
||||
],
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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 });'));
|
||||
});
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue