fix(coding-agent): persist the cwd chosen for a moved session

This commit is contained in:
hh 2026-11-05 20:54:31 +08:00
parent 842c73170a
commit fb31edcf6a
4 changed files with 193 additions and 5 deletions

View file

@ -1,4 +1,7 @@
import { existsSync } from "node:fs";
import { closeSync, existsSync, fsyncSync, openSync, readSync, renameSync, rmSync, statSync, writeSync } from "node:fs";
import { APP_NAME } from "../config.ts";
import { resolvePath } from "../utils/paths.ts";
import type { SessionHeader } from "./session-manager.ts";
export interface SessionCwdIssue {
sessionFile?: string;
@ -57,3 +60,105 @@ export function assertSessionCwdExists(sessionManager: SessionCwdSource, fallbac
throw new MissingSessionCwdError(issue);
}
}
const SESSION_CWD_COPY_BUFFER_SIZE = 1024 * 1024;
interface LocatedSessionHeader {
header: SessionHeader;
/** Byte offset where the header line starts. */
start: number;
/** Byte offset just past the header line, including its newline. */
end: number;
}
function parseSessionHeaderCandidate(line: string): SessionHeader | null | undefined {
if (!line.trim()) return undefined;
try {
const entry = JSON.parse(line) as SessionHeader;
if (entry.type !== "session" || typeof entry.id !== "string") return null;
return entry;
} catch {
return undefined;
}
}
/**
* Locate the session header line, matching loadEntriesFromFile(): blank and malformed
* leading lines are skipped and the first parseable entry must be the session header.
*/
function locateSessionHeader(fd: number): LocatedSessionHeader | null {
const buffer = Buffer.allocUnsafe(SESSION_CWD_COPY_BUFFER_SIZE);
const pending: Buffer[] = [];
let lineStart = 0;
let offset = 0;
while (true) {
const bytesRead = readSync(fd, buffer, 0, buffer.length, offset);
if (bytesRead === 0) {
if (pending.length === 0) return null;
const header = parseSessionHeaderCandidate(Buffer.concat(pending).toString("utf8"));
return header ? { header, start: lineStart, end: offset } : null;
}
let chunkLineStart = 0;
for (let i = 0; i < bytesRead; i++) {
if (buffer[i] !== 0x0a) continue;
pending.push(Buffer.from(buffer.subarray(chunkLineStart, i)));
const header = parseSessionHeaderCandidate(Buffer.concat(pending).toString("utf8"));
pending.length = 0;
if (header === null) return null;
if (header) return { header, start: lineStart, end: offset + i + 1 };
lineStart = offset + i + 1;
chunkLineStart = i + 1;
}
if (chunkLineStart < bytesRead) {
pending.push(Buffer.from(buffer.subarray(chunkLineStart, bytesRead)));
}
offset += bytesRead;
}
}
function copyFileRange(srcFd: number, dstFd: number, start: number, end?: number): void {
const buffer = Buffer.allocUnsafe(SESSION_CWD_COPY_BUFFER_SIZE);
let position = start;
while (end === undefined || position < end) {
const length = end === undefined ? buffer.length : Math.min(buffer.length, end - position);
const bytesRead = readSync(srcFd, buffer, 0, length, position);
if (bytesRead === 0) break;
writeSync(dstFd, buffer, 0, bytesRead);
position += bytesRead;
}
}
/**
* Store a new working directory in a session file's header. Every other byte is kept
* unchanged: the header line is replaced in a temporary copy that is then renamed over
* the original, so a crash cannot leave a truncated session behind.
*/
export function updateSessionFileCwd(sessionFile: string, cwd: string): void {
const resolvedFile = resolvePath(sessionFile);
const tempFile = `${resolvedFile}.${process.pid}.tmp`;
const srcFd = openSync(resolvedFile, "r");
let renamed = false;
try {
const located = locateSessionHeader(srcFd);
if (!located) {
throw new Error(`Session file is not a valid ${APP_NAME} session: ${resolvedFile}`);
}
const headerLine = Buffer.from(`${JSON.stringify({ ...located.header, cwd })}\n`, "utf8");
const tmpFd = openSync(tempFile, "w", statSync(resolvedFile).mode);
try {
copyFileRange(srcFd, tmpFd, 0, located.start);
writeSync(tmpFd, headerLine, 0, headerLine.length);
copyFileRange(srcFd, tmpFd, located.end);
fsyncSync(tmpFd);
} finally {
closeSync(tmpFd);
}
renameSync(tempFile, resolvedFile);
renamed = true;
} finally {
closeSync(srcFd);
if (!renamed) rmSync(tempFile, { force: true });
}
}

View file

@ -55,6 +55,7 @@ import {
getMissingSessionCwdIssue,
MissingSessionCwdError,
type SessionCwdIssue,
updateSessionFileCwd,
} from "./core/session-cwd.ts";
import { assertValidSessionId, SessionManager } from "./core/session-manager.ts";
import { collectSettingsDiagnostics, deduplicateDiagnostics } from "./core/settings-diagnostics.ts";
@ -701,6 +702,12 @@ export async function main(args: string[], options?: MainOptions) {
if (!selectedCwd) {
process.exit(0);
}
try {
updateSessionFileCwd(missingSessionCwdIssue.sessionFile!, selectedCwd);
} catch (error: unknown) {
const message = error instanceof Error ? error.message : String(error);
console.error(chalk.yellow(`Warning: could not save the session cwd: ${message}`));
}
sessionManager = SessionManager.open(missingSessionCwdIssue.sessionFile!, sessionDir, selectedCwd);
} else {
console.error(chalk.red(new MissingSessionCwdError(missingSessionCwdIssue).message));

View file

@ -106,7 +106,7 @@ import { CredentialSynchronizationError } from "../../core/model-runtime.ts";
import { DefaultPackageManager } from "../../core/package-manager.ts";
import { RADIUS_MCP_URL, RADIUS_PROVIDER_ID } from "../../core/radius.ts";
import type { ResourceDiagnostic } from "../../core/resource-loader.ts";
import { formatMissingSessionCwdPrompt, MissingSessionCwdError } from "../../core/session-cwd.ts";
import { formatMissingSessionCwdPrompt, MissingSessionCwdError, updateSessionFileCwd } from "../../core/session-cwd.ts";
import {
type SessionEntry,
SessionManager,
@ -5771,6 +5771,11 @@ export class InteractiveMode {
this.showStatus("Resume cancelled");
return { cancelled: true };
}
try {
updateSessionFileCwd(error.issue.sessionFile ?? sessionPath, selectedCwd);
} catch {
this.showWarning("Could not save the session working directory; pi will ask again on the next resume.");
}
const result = await this.runtimeHost.switchSession(sessionPath, {
cwdOverride: selectedCwd,
withSession: options?.withSession,
@ -6598,6 +6603,16 @@ export class InteractiveMode {
this.showStatus("Import cancelled");
return;
}
const importedFile = this.sessionManager.getSessionFile();
if (importedFile) {
try {
updateSessionFileCwd(importedFile, selectedCwd);
} catch {
this.showWarning(
"Could not save the session working directory; pi will ask again on the next resume.",
);
}
}
this.showStatus(`Session imported from: ${inputPath}`);
return;
}

View file

@ -1,10 +1,10 @@
import { mkdirSync, rmSync, writeFileSync } from "node:fs";
import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { afterEach, describe, expect, it } from "vitest";
import { type CreateAgentSessionRuntimeFactory, createAgentSessionRuntime } from "../src/core/agent-session-runtime.ts";
import { getMissingSessionCwdIssue, MissingSessionCwdError } from "../src/core/session-cwd.ts";
import { SessionManager } from "../src/core/session-manager.ts";
import { getMissingSessionCwdIssue, MissingSessionCwdError, updateSessionFileCwd } from "../src/core/session-cwd.ts";
import { loadEntriesFromFile, SessionManager } from "../src/core/session-manager.ts";
function createTempDir(name: string): string {
const dir = join(tmpdir(), `${name}-${Date.now()}-${Math.random().toString(36).slice(2)}`);
@ -88,4 +88,65 @@ describe("session cwd handling", () => {
).rejects.toBeInstanceOf(MissingSessionCwdError);
expect(createRuntimeCalled).toBe(false);
});
// #11060: once the user picks a folder for a moved session, later resumes must not ask again
it("persists the chosen cwd in the session file header", () => {
const fallbackCwd = createTempDir("pi-session-cwd-save");
const missingCwd = join(fallbackCwd, "does-not-exist");
const sessionDir = createTempDir("pi-session-cwd-save-session-dir");
const sessionFile = join(sessionDir, "session.jsonl");
cleanupPaths.push(fallbackCwd, sessionDir);
writeSessionFile(sessionFile, missingCwd);
updateSessionFileCwd(sessionFile, fallbackCwd);
const sessionManager = SessionManager.open(sessionFile);
expect(sessionManager.getCwd()).toBe(fallbackCwd);
expect(getMissingSessionCwdIssue(sessionManager, fallbackCwd)).toBeUndefined();
});
it("keeps leading non-header lines and later entries byte-identical", () => {
const fallbackCwd = createTempDir("pi-session-cwd-entries");
const missingCwd = join(fallbackCwd, "does-not-exist");
const sessionDir = createTempDir("pi-session-cwd-entries-session-dir");
const sessionFile = join(sessionDir, "session.jsonl");
cleanupPaths.push(fallbackCwd, sessionDir);
const messageLine =
'{"type":"message","id":"1","parentId":null,"timestamp":"2025-01-01T00:00:01Z","message":{"role":"user","content":"hi","timestamp":1}}';
writeFileSync(
sessionFile,
`junk line\n\n${JSON.stringify({
type: "session",
version: 3,
id: "session-id",
timestamp: "2025-01-01T00:00:00Z",
cwd: missingCwd,
})}\n${messageLine}\n`,
);
updateSessionFileCwd(sessionFile, fallbackCwd);
expect(readFileSync(sessionFile, "utf8")).toBe(
`junk line\n\n${JSON.stringify({
type: "session",
version: 3,
id: "session-id",
timestamp: "2025-01-01T00:00:00Z",
cwd: fallbackCwd,
})}\n${messageLine}\n`,
);
expect(loadEntriesFromFile(sessionFile)).toHaveLength(2);
});
it("rejects files whose first entry is not a session header", () => {
const sessionDir = createTempDir("pi-session-cwd-invalid");
const sessionFile = join(sessionDir, "not-a-session.jsonl");
cleanupPaths.push(sessionDir);
writeFileSync(sessionFile, '{"type":"message","id":"1"}\n');
expect(() => updateSessionFileCwd(sessionFile, sessionDir)).toThrow("not a valid");
expect(readFileSync(sessionFile, "utf8")).toBe('{"type":"message","id":"1"}\n');
expect(existsSync(`${sessionFile}.${process.pid}.tmp`)).toBe(false);
});
});