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.
This commit is contained in:
Derek Pearson 2026-03-21 20:36:03 -04:00
parent 3bb3ca8020
commit 71f14269a5
4 changed files with 34 additions and 10 deletions

View file

@ -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`),

View file

@ -74,6 +74,7 @@ export async function assessInterruptedSession(
basePath: string,
): Promise<InterruptedSessionAssessment> {
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"),
);
}

View file

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

View file

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