mirror of
https://github.com/badlogic/pi-mono.git
synced 2026-11-05 23:37:25 +00:00
fix(coding-agent): persist the cwd chosen for a moved session
This commit is contained in:
parent
842c73170a
commit
fb31edcf6a
4 changed files with 193 additions and 5 deletions
|
|
@ -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 });
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue