Skip to content

Commit e2dd5a0

Browse files
authored
fix(sessions): avoid cross-cwd recent resumes (#97785)
1 parent 856cd18 commit e2dd5a0

2 files changed

Lines changed: 52 additions & 10 deletions

File tree

src/agents/sessions/session-manager.test.ts

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -153,6 +153,36 @@ describe("SessionManager.open", () => {
153153
expect(SessionManager.continueRecent(longCwd, dir).getSessionFile()).toBe(sessionFile);
154154
});
155155

156+
it("does not continue a different cwd from a colliding session directory", async () => {
157+
const dir = await makeTempDir();
158+
const cwdA = "/home/alice/dev/client/app";
159+
const cwdB = "/home/alice/dev/client-app";
160+
const sessionA = path.join(dir, "session-a.jsonl");
161+
const sessionB = path.join(dir, "session-b.jsonl");
162+
const headerA = buildSessionHeader(cwdA, "session-a");
163+
const headerB = buildSessionHeader(cwdB, "session-b");
164+
165+
await fs.writeFile(sessionA, `${JSON.stringify(headerA)}\n`, "utf8");
166+
await fs.writeFile(sessionB, `${JSON.stringify(headerB)}\n`, "utf8");
167+
await fs.utimes(
168+
sessionA,
169+
new Date("2026-06-18T00:00:00.000Z"),
170+
new Date("2026-06-18T00:00:00.000Z"),
171+
);
172+
await fs.utimes(
173+
sessionB,
174+
new Date("2026-06-18T00:00:01.000Z"),
175+
new Date("2026-06-18T00:00:01.000Z"),
176+
);
177+
178+
expect(findMostRecentSession(dir)).toBe(sessionB);
179+
expect(findMostRecentSession(dir, cwdA)).toBe(sessionA);
180+
expect(SessionManager.continueRecent(cwdA, dir).getSessionFile()).toBe(sessionA);
181+
await expect(SessionManager.list(cwdA, dir)).resolves.toEqual([
182+
expect.objectContaining({ path: sessionA, cwd: cwdA }),
183+
]);
184+
});
185+
156186
it("skips oversized recent session headers instead of hiding valid sessions", async () => {
157187
const dir = await makeTempDir();
158188
const validSessionFile = path.join(dir, "valid-session.jsonl");

src/agents/sessions/session-manager.ts

Lines changed: 22 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1171,27 +1171,34 @@ function readFirstSessionFileLine(filePath: string): string | undefined {
11711171
}
11721172
}
11731173

1174-
function isValidSessionFile(filePath: string): boolean {
1174+
function readSessionHeaderFromFile(filePath: string): SessionHeader | undefined {
11751175
try {
11761176
const firstLine = readFirstSessionFileLine(filePath);
11771177
if (!firstLine) {
1178-
return false;
1178+
return undefined;
11791179
}
11801180
const header = JSON.parse(firstLine);
1181-
return header.type === "session" && typeof header.id === "string";
1181+
if (header.type !== "session" || typeof header.id !== "string") {
1182+
return undefined;
1183+
}
1184+
return header as SessionHeader;
11821185
} catch {
1183-
return false;
1186+
return undefined;
11841187
}
11851188
}
11861189

11871190
/** Exported for testing */
1188-
export function findMostRecentSession(sessionDir: string): string | null {
1191+
export function findMostRecentSession(sessionDir: string, cwd?: string): string | null {
11891192
try {
11901193
const files = readdirSync(sessionDir)
11911194
.filter((f) => f.endsWith(".jsonl"))
11921195
.map((f) => join(sessionDir, f))
1193-
.filter(isValidSessionFile)
1194-
.map((path) => ({ path, mtime: statSync(path).mtime }))
1196+
.map((path) => ({ path, header: readSessionHeaderFromFile(path) }))
1197+
.filter(
1198+
(candidate): candidate is { path: string; header: SessionHeader } =>
1199+
candidate.header !== undefined && (cwd === undefined || candidate.header.cwd === cwd),
1200+
)
1201+
.map((candidate) => ({ path: candidate.path, mtime: statSync(candidate.path).mtime }))
11951202
.toSorted((a, b) => b.mtime.getTime() - a.mtime.getTime());
11961203

11971204
return files[0]?.path || null;
@@ -1348,6 +1355,10 @@ async function buildSessionInfo(filePath: string): Promise<SessionInfo | null> {
13481355
}
13491356
}
13501357

1358+
function sessionInfoMatchesCwd(info: SessionInfo, cwd: string | undefined): boolean {
1359+
return cwd === undefined || info.cwd === cwd;
1360+
}
1361+
13511362
export type SessionListProgress = (loaded: number, total: number) => void;
13521363

13531364
const MAX_CONCURRENT_SESSION_INFO_LOADS = 10;
@@ -1397,6 +1408,7 @@ async function listSessionsFromDir(
13971408
onProgress?: SessionListProgress,
13981409
progressOffset = 0,
13991410
progressTotal?: number,
1411+
cwd?: string,
14001412
): Promise<SessionInfo[]> {
14011413
const sessions: SessionInfo[] = [];
14021414
if (!existsSync(dir)) {
@@ -1414,7 +1426,7 @@ async function listSessionsFromDir(
14141426
onProgress?.(progressOffset + loaded, total);
14151427
});
14161428
for (const info of results) {
1417-
if (info) {
1429+
if (info && sessionInfoMatchesCwd(info, cwd)) {
14181430
sessions.push(info);
14191431
}
14201432
}
@@ -2921,7 +2933,7 @@ export class SessionManager {
29212933
*/
29222934
static continueRecent(cwd: string, sessionDir?: string): SessionManager {
29232935
const dir = sessionDir ?? getDefaultSessionDir(cwd);
2924-
const mostRecent = findMostRecentSession(dir);
2936+
const mostRecent = findMostRecentSession(dir, cwd);
29252937
if (mostRecent) {
29262938
return new SessionManager(cwd, dir, mostRecent, true);
29272939
}
@@ -2995,7 +3007,7 @@ export class SessionManager {
29953007
onProgress?: SessionListProgress,
29963008
): Promise<SessionInfo[]> {
29973009
const dir = sessionDir ?? getDefaultSessionDir(cwd);
2998-
const sessions = await listSessionsFromDir(dir, onProgress);
3010+
const sessions = await listSessionsFromDir(dir, onProgress, 0, undefined, cwd);
29993011
sessions.sort((a, b) => b.modified.getTime() - a.modified.getTime());
30003012
return sessions;
30013013
}

0 commit comments

Comments
 (0)