cherry-pick(auto): handle worktree context fallback + sanitize paused session paths

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 <noreply@anthropic.com>
This commit is contained in:
Mikael Hugo 2026-04-28 05:25:40 +02:00
parent 93402643f4
commit 7fd4672e55
3 changed files with 79 additions and 13 deletions

View file

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

View file

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

View file

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