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:
parent
93402643f4
commit
7fd4672e55
3 changed files with 79 additions and 13 deletions
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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 ───────────────────────────────────────
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue