From 7fd4672e55baa4b0bd7c6e8c98e083402f0b0f5f Mon Sep 17 00:00:00 2001 From: Mikael Hugo Date: Tue, 28 Apr 2026 05:25:40 +0200 Subject: [PATCH] cherry-pick(auto): handle worktree context fallback + sanitize paused session paths MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Cherry-pick of gsd-build/gsd-2 a4f78731f — handles worktree context fallback and sanitizes paths in paused session resumption. Skips uok-plan-v2-wiring test hunk (drifted in sf). Co-Authored-By: Claude Sonnet 4.6 --- src/resources/extensions/sf/auto.ts | 51 +++++++++++++++---- .../auto-paused-session-validation.test.ts | 12 +++++ src/resources/extensions/sf/uok/plan-v2.ts | 29 +++++++++-- 3 files changed, 79 insertions(+), 13 deletions(-) diff --git a/src/resources/extensions/sf/auto.ts b/src/resources/extensions/sf/auto.ts index 53c8e4c58..26bd960f9 100644 --- a/src/resources/extensions/sf/auto.ts +++ b/src/resources/extensions/sf/auto.ts @@ -127,7 +127,7 @@ import { } from "./metrics.js"; import { setLogBasePath, logWarning, logError } from "./workflow-logger.js"; import { homedir } from "node:os"; -import { join } from "node:path"; +import { isAbsolute, join } from "node:path"; import { pathToFileURL } from "node:url"; import { readFileSync, existsSync, mkdirSync, writeFileSync, unlinkSync } from "node:fs"; import { atomicWriteSync } from "./atomic-write.js"; @@ -307,6 +307,21 @@ function restoreMilestoneLockEnv(): void { s.milestoneLockEnvCaptured = false; } +function normalizeSessionFilePath(raw: unknown): string | null { + if (typeof raw !== "string") return null; + const trimmed = raw.trim(); + if (!trimmed) return null; + const firstLine = trimmed.split(/\r?\n/, 1)[0]?.trim() ?? ""; + if (!firstLine) return null; + + // Guard against accidental message concatenation by trimming to .jsonl. + const jsonlIndex = firstLine.toLowerCase().indexOf(".jsonl"); + const candidate = jsonlIndex >= 0 ? firstLine.slice(0, jsonlIndex + ".jsonl".length) : firstLine; + if (!isAbsolute(candidate)) return null; + if (!candidate.toLowerCase().endsWith(".jsonl")) return null; + return candidate; +} + export function startAutoDetached( ctx: ExtensionCommandContext, pi: ExtensionAPI, @@ -1070,7 +1085,7 @@ export async function pauseAuto( // from provider-error pause and avoid hard-stopping (#2762). resolveAgentEndCancelled(_errorContext); - s.pausedSessionFile = ctx?.sessionManager?.getSessionFile() ?? null; + s.pausedSessionFile = normalizeSessionFilePath(ctx?.sessionManager?.getSessionFile() ?? null); // Persist paused-session metadata so resume survives /exit (#1383). // The fresh-start bootstrap checks for this file and restores worktree context. @@ -1371,7 +1386,11 @@ export async function startAuto( s.autoStartTime = meta.autoStartTime || Date.now(); s.sessionMilestoneLock = meta.milestoneLock ?? null; s.paused = true; - try { unlinkSync(pausedPath); } catch (e) { logWarning("session", `pause file cleanup failed: ${e instanceof Error ? e.message : String(e)}`, { file: "auto.ts" }); } + try { unlinkSync(pausedPath); } catch (e) { + if ((e as NodeJS.ErrnoException).code !== "ENOENT") { + logWarning("session", `pause file cleanup failed: ${e instanceof Error ? e.message : String(e)}`, { file: "auto.ts" }); + } + } ctx.ui.notify( `Resuming paused custom workflow${meta.activeRunDir ? ` (${meta.activeRunDir})` : ""}.`, "info", @@ -1390,7 +1409,9 @@ export async function startAuto( const summaryFile = resolveMilestoneFile(base, meta.milestoneId, "SUMMARY"); if (!mDir || summaryFile) { try { unlinkSync(pausedPath); } catch (err) { - logWarning("session", `pause file cleanup failed: ${err instanceof Error ? err.message : String(err)}`, { file: "auto.ts" }); + if ((err as NodeJS.ErrnoException).code !== "ENOENT") { + logWarning("session", `pause file cleanup failed: ${err instanceof Error ? err.message : String(err)}`, { file: "auto.ts" }); + } } ctx.ui.notify( `Paused milestone ${meta.milestoneId} is ${!mDir ? "missing" : "already complete"}. Starting fresh.`, @@ -1400,20 +1421,28 @@ export async function startAuto( s.currentMilestoneId = meta.milestoneId; s.originalBasePath = meta.originalBasePath || base; s.stepMode = meta.stepMode ?? requestedStepMode; - s.pausedSessionFile = meta.sessionFile ?? null; + s.pausedSessionFile = normalizeSessionFilePath(meta.sessionFile ?? null); s.pausedUnitType = meta.unitType ?? null; s.pausedUnitId = meta.unitId ?? null; s.autoStartTime = meta.autoStartTime || Date.now(); s.sessionMilestoneLock = meta.milestoneLock ?? null; s.paused = true; - try { unlinkSync(pausedPath); } catch (e) { logWarning("session", `pause file cleanup failed: ${e instanceof Error ? e.message : String(e)}`, { file: "auto.ts" }); } + try { unlinkSync(pausedPath); } catch (e) { + if ((e as NodeJS.ErrnoException).code !== "ENOENT") { + logWarning("session", `pause file cleanup failed: ${e instanceof Error ? e.message : String(e)}`, { file: "auto.ts" }); + } + } ctx.ui.notify( `Resuming paused session for ${meta.milestoneId}${meta.worktreePath && existsSync(meta.worktreePath) ? ` (worktree)` : ""}.`, "info", ); } } else if (existsSync(pausedPath)) { - try { unlinkSync(pausedPath); } catch (e) { logWarning("session", `stale pause file cleanup failed: ${e instanceof Error ? e.message : String(e)}`, { file: "auto.ts" }); } + try { unlinkSync(pausedPath); } catch (e) { + if ((e as NodeJS.ErrnoException).code !== "ENOENT") { + logWarning("session", `stale pause file cleanup failed: ${e instanceof Error ? e.message : String(e)}`, { file: "auto.ts" }); + } + } } } } catch (err) { @@ -1472,7 +1501,9 @@ export async function startAuto( // Lock acquired — now safe to delete the pause file if (s.pausedSessionFile) { try { unlinkSync(s.pausedSessionFile); } catch (err) { - logWarning("session", `pause file cleanup failed: ${err instanceof Error ? err.message : String(err)}`, { file: "auto.ts" }); + if ((err as NodeJS.ErrnoException).code !== "ENOENT") { + logWarning("session", `pause file cleanup failed: ${err instanceof Error ? err.message : String(err)}`, { file: "auto.ts" }); + } } s.pausedSessionFile = null; } @@ -1801,12 +1832,12 @@ export async function dispatchHookUnit( } } - const sessionFile = ctx.sessionManager.getSessionFile(); + const sessionFile = normalizeSessionFilePath(ctx.sessionManager.getSessionFile()); writeLock( lockBase(), hookUnitType, triggerUnitId, - sessionFile, + sessionFile ?? undefined, ); clearUnitTimeout(); diff --git a/src/resources/extensions/sf/tests/auto-paused-session-validation.test.ts b/src/resources/extensions/sf/tests/auto-paused-session-validation.test.ts index 31f420abe..43264780d 100644 --- a/src/resources/extensions/sf/tests/auto-paused-session-validation.test.ts +++ b/src/resources/extensions/sf/tests/auto-paused-session-validation.test.ts @@ -39,6 +39,18 @@ test("auto.ts validates milestone before restoring paused session (#1664)", () = source.includes('resolveMilestoneFile(base, meta.milestoneId, "SUMMARY")'), "auto.ts must check for SUMMARY file to detect completed milestones", ); + + // Resume path must sanitize paused session file metadata before unlink/recovery. + assert.ok( + source.includes("normalizeSessionFilePath(meta.sessionFile ?? null)"), + "auto.ts must sanitize paused-session metadata sessionFile before using it", + ); + + // Pause path must sanitize live session file path before persisting metadata. + assert.ok( + source.includes("normalizeSessionFilePath(ctx?.sessionManager?.getSessionFile() ?? null)"), + "auto.ts must sanitize sessionManager getSessionFile output before persisting", + ); }); // ─── Filesystem validation unit tests ─────────────────────────────────────── diff --git a/src/resources/extensions/sf/uok/plan-v2.ts b/src/resources/extensions/sf/uok/plan-v2.ts index af0046b22..aac0b7ffc 100644 --- a/src/resources/extensions/sf/uok/plan-v2.ts +++ b/src/resources/extensions/sf/uok/plan-v2.ts @@ -42,6 +42,29 @@ function hasFileContent(path: string | null): boolean { } } +function getArtifactLookupBases(basePath: string): string[] { + const bases = [basePath]; + const projectRoot = process.env.GSD_PROJECT_ROOT; + if (projectRoot && projectRoot.trim().length > 0 && projectRoot !== basePath) { + bases.push(projectRoot); + } + return bases; +} + +function hasMilestoneFileContent( + basePath: string, + milestoneId: string, + suffix: string, +): boolean { + const bases = getArtifactLookupBases(basePath); + for (const candidateBase of bases) { + if (hasFileContent(resolveMilestoneFile(candidateBase, milestoneId, suffix))) { + return true; + } + } + return false; +} + function countSliceResearchArtifacts(basePath: string, milestoneId: string, slices: SliceRow[]): number { let count = 0; for (const slice of slices) { @@ -60,9 +83,9 @@ export function compileUnitGraphFromState(basePath: string, state: SFState): Pla const slices = getMilestoneSlices(mid).sort((a, b) => Number(a.sequence ?? 0) - Number(b.sequence ?? 0)); const nodes: UokGraphNode[] = []; const clarifyRoundLimit = PLAN_V2_CLARIFY_ROUND_LIMIT; - const draftContextIncluded = hasFileContent(resolveMilestoneFile(basePath, mid, "CONTEXT-DRAFT")); - const finalizedContextIncluded = hasFileContent(resolveMilestoneFile(basePath, mid, "CONTEXT")); - const researchSynthesized = hasFileContent(resolveMilestoneFile(basePath, mid, "RESEARCH")) + const draftContextIncluded = hasMilestoneFileContent(basePath, mid, "CONTEXT-DRAFT"); + const finalizedContextIncluded = hasMilestoneFileContent(basePath, mid, "CONTEXT"); + const researchSynthesized = hasMilestoneFileContent(basePath, mid, "RESEARCH") || countSliceResearchArtifacts(basePath, mid, slices) > 0; if (isExecutionEntryPhase(state.phase) && !finalizedContextIncluded) {