From ba4abb7e2f8863e2aef1a8ab25d9d032c1fe2f1c Mon Sep 17 00:00:00 2001 From: can1357 Date: Mon, 8 Jun 2026 00:47:39 +0200 Subject: [PATCH] fix(coding-agent): excluded cwd-less legacy sessions from relocation guard - Guard now skips sessions with empty cwd, not just undefined. - Prevents path.resolve("") collision from hijacking a moved session. --- .../src/session/session-manager.ts | 4 +- .../continue-relocation.test.ts | 54 +++++++++++++++++++ 2 files changed, 56 insertions(+), 2 deletions(-) diff --git a/packages/coding-agent/src/session/session-manager.ts b/packages/coding-agent/src/session/session-manager.ts index 58f5a71bb..8ad4a4d95 100644 --- a/packages/coding-agent/src/session/session-manager.ts +++ b/packages/coding-agent/src/session/session-manager.ts @@ -3518,7 +3518,7 @@ export class SessionManager { // current-directory session. If that shared dir also contains an older session // that already belongs to the current cwd, prefer that local session instead // of re-rooting the stale breadcrumb over it. - const resolvedBreadcrumbCwd = breadcrumbCwd ?? path.resolve(breadcrumb.cwd); + const resolvedBreadcrumbCwd = path.resolve(breadcrumb.cwd); mostRecent = await findMostRecentSession(dir, storage); const sourceCwdGone = !fs.existsSync(resolvedBreadcrumbCwd); const breadcrumbSessionFile = path.resolve(breadcrumb.sessionFile); @@ -3529,7 +3529,7 @@ export class SessionManager { const currentCwdSession = (await SessionManager.list(cwd, dir, storage)).find( session => path.resolve(session.path) !== breadcrumbSessionFile && - session.cwd !== undefined && + session.cwd && path.resolve(session.cwd) === resolvedCwd, ); if (currentCwdSession) { diff --git a/packages/coding-agent/test/session-manager/continue-relocation.test.ts b/packages/coding-agent/test/session-manager/continue-relocation.test.ts index 681802f78..751c16bd3 100644 --- a/packages/coding-agent/test/session-manager/continue-relocation.test.ts +++ b/packages/coding-agent/test/session-manager/continue-relocation.test.ts @@ -30,6 +30,17 @@ function writeBreadcrumb(cwd: string, sessionFile: string): string { return file; } +function stripHeaderCwd(file: string): void { + const lines = fs.readFileSync(file, "utf8").split("\n"); + const rewritten = lines.map(line => { + if (!line.trim()) return line; + const obj = JSON.parse(line) as { type?: string; cwd?: unknown }; + if (obj.type === "session") delete obj.cwd; + return JSON.stringify(obj); + }); + fs.writeFileSync(file, rewritten.join("\n")); +} + describe("SessionManager.continueRecent relocation", () => { let testAgentDir: string; let cwdA: string; @@ -230,4 +241,47 @@ describe("SessionManager.continueRecent relocation", () => { await resumed.close(); } }); + + it("re-roots past a cwd-less legacy session in a shared explicit sessionDir", async () => { + // Regression: SessionInfo.cwd is "" for sessions whose header has no cwd, and + // path.resolve("") === process.cwd(). A guard that only excluded `undefined` + // treated such a legacy session as "belongs to the current cwd" whenever + // --continue ran from process.cwd(), hijacking the moved session. Resume must + // be invoked with process.cwd() to reproduce the path.resolve("") collision. + const explicitSessionDir = path.join(testAgentDir, "shared-legacy-sessions"); + const currentCwd = process.cwd(); + + // Older session with no recorded cwd (header cwd stripped → "" on load). + const legacy = SessionManager.create(cwdB, explicitSessionDir); + legacy.appendMessage({ role: "user", content: "legacy cwd-less", timestamp: 1 }); + legacy.appendMessage(makeAssistantMessage()); + await legacy.flush(); + const legacyFile = legacy.getSessionFile(); + if (!legacyFile) throw new Error("Expected persisted legacy session file"); + await legacy.close(); + stripHeaderCwd(legacyFile); + + // Newer moved session, recorded under the now-missing worktree cwd. + await new Promise(resolve => setTimeout(resolve, 20)); + const moved = SessionManager.create(cwdA, explicitSessionDir); + moved.appendMessage({ role: "user", content: "newer moved cwd", timestamp: 2 }); + moved.appendMessage(makeAssistantMessage()); + await moved.flush(); + const movedFile = moved.getSessionFile(); + if (!movedFile) throw new Error("Expected persisted moved session file"); + await moved.close(); + + writeBreadcrumb(cwdA, movedFile); + await fsp.rm(cwdA, { recursive: true, force: true }); + + const resumed = await SessionManager.continueRecent(currentCwd, explicitSessionDir); + try { + // The moved session is re-rooted; the cwd-less legacy session is not hijacked. + expect(resumed.getSessionFile()).toBe(movedFile); + expect(resumed.getCwd()).toBe(path.resolve(currentCwd)); + expect(fs.existsSync(legacyFile)).toBe(true); + } finally { + await resumed.close(); + } + }); });