Merge PR #8598: fix(session): repair torn JSONL appends (@roboomp)
# Conflicts: # packages/coding-agent/src/session/session-loader.ts # packages/coding-agent/src/session/session-manager.ts # packages/coding-agent/test/session-loader-stream.test.ts
This commit is contained in:
@@ -61,8 +61,10 @@ import {
|
||||
import { findMostRecentSession, listAllSessions, listSessions, type SessionInfo } from "./session-listing";
|
||||
import {
|
||||
loadEntriesFromFile,
|
||||
loadSessionFile,
|
||||
readTitleSlotFromFile,
|
||||
resolveBlobRefsInEntries,
|
||||
type SessionLoadResult,
|
||||
visitEntriesFromFile,
|
||||
} from "./session-loader";
|
||||
import { generateId, migrateToCurrentVersion } from "./session-migrations";
|
||||
@@ -545,6 +547,7 @@ export class SessionManager {
|
||||
*/
|
||||
#breadcrumbFresh = false;
|
||||
#sessionNameChangedCallbacks = new Set<() => void>();
|
||||
#persistenceErrorCallbacks = new Set<(error: Error) => void>();
|
||||
|
||||
private constructor(cwd: string, sessionDir: string, persist: boolean, storage: SessionStorage) {
|
||||
this.#cwd = cwd;
|
||||
@@ -586,6 +589,15 @@ export class SessionManager {
|
||||
error: error.message,
|
||||
stack: error.stack,
|
||||
});
|
||||
for (const callback of this.#persistenceErrorCallbacks) {
|
||||
try {
|
||||
callback(error);
|
||||
} catch (callbackError) {
|
||||
logger.warn("Session persistence error observer failed", {
|
||||
error: toError(callbackError).message,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return this.#diskFailure;
|
||||
@@ -843,6 +855,7 @@ export class SessionManager {
|
||||
this.#diskTail = Promise.resolve();
|
||||
this.#closeWriterEventually();
|
||||
this.#storage.writeTextSync(targetPath, body);
|
||||
this.#clearDiskError();
|
||||
// Only mark the manager current when writing the active session path.
|
||||
// Mid-move writes update the live relocation path; `#sessionFile` is
|
||||
// still the pre-repoint source until moveTo repoints it.
|
||||
@@ -935,7 +948,13 @@ export class SessionManager {
|
||||
this.#atomicRewriteDirty = true;
|
||||
return;
|
||||
}
|
||||
if (this.#diskFailure) throw this.#diskFailure;
|
||||
if (this.#diskFailure) {
|
||||
// The failed entry and any later entries remain in memory. A full
|
||||
// replacement is the writability probe and restores all of them once
|
||||
// transient storage pressure clears.
|
||||
this.#fileIsCurrent = false;
|
||||
this.#rewriteRequired = true;
|
||||
}
|
||||
|
||||
// Lazy gate: a brand-new session is not written until it has an assistant
|
||||
// message (or someone forced creation), so sessions that never produce
|
||||
@@ -973,9 +992,9 @@ export class SessionManager {
|
||||
// chain. Prefer appendSync so write failures latch `#diskFailure` before
|
||||
// this call returns (not via a discarded rejected Promise after a later
|
||||
// microtask). Callers stay non-throwing here — the core turn loop invokes
|
||||
// appendMessage/appendCustomEntry without try/catch; flushSync/close and
|
||||
// subsequent appends still throw the latched error. File writers apply
|
||||
// each line to the OS page cache before return.
|
||||
// appendMessage/appendCustomEntry without try/catch. A later entry retries
|
||||
// all in-memory state through a full rewrite. File writers apply each line
|
||||
// to the OS page cache before return.
|
||||
// A mid-close writer leaves `#writer` undefined, so `#appendWriter` simply
|
||||
// opens a fresh append handle and the entry still lands.
|
||||
try {
|
||||
@@ -984,16 +1003,28 @@ export class SessionManager {
|
||||
if (writer.appendSync) {
|
||||
writer.appendSync(line);
|
||||
} else {
|
||||
void writer.append(line).catch(err => this.#noteDiskFailure(err));
|
||||
void writer.append(line).catch(err => {
|
||||
this.#fileIsCurrent = false;
|
||||
this.#rewriteRequired = true;
|
||||
this.#noteDiskFailure(err);
|
||||
});
|
||||
}
|
||||
} catch (err) {
|
||||
this.#fileIsCurrent = false;
|
||||
this.#rewriteRequired = true;
|
||||
this.#noteDiskFailure(err);
|
||||
}
|
||||
}
|
||||
|
||||
async #persistTitleChangeEntry(entry: TitleChangeEntry, update: SessionTitleUpdate): Promise<void> {
|
||||
if (!this.#persist || !this.#sessionFile) return;
|
||||
if (this.#diskFailure) throw this.#diskFailure;
|
||||
if (this.#diskFailure) {
|
||||
this.#fileIsCurrent = false;
|
||||
this.#rewriteRequired = true;
|
||||
this.#rewriteSynchronously();
|
||||
if (this.#diskFailure) throw this.#diskFailure;
|
||||
return;
|
||||
}
|
||||
|
||||
if (!this.#shouldHaveSessionFile()) {
|
||||
this.#fileIsCurrent = false;
|
||||
@@ -1317,7 +1348,7 @@ export class SessionManager {
|
||||
await this.#setSessionFile(sessionFile);
|
||||
}
|
||||
|
||||
async #setSessionFile(sessionFile: string, loadedEntries?: FileEntry[]): Promise<void> {
|
||||
async #setSessionFile(sessionFile: string, loadedSession?: SessionLoadResult): Promise<void> {
|
||||
await this.#drainAndCloseWriter();
|
||||
this.#clearDiskError();
|
||||
this.#draftOnlySessionCleanupArmed = false;
|
||||
@@ -1326,8 +1357,8 @@ export class SessionManager {
|
||||
this.#sessionFile = resolvedSessionFile;
|
||||
this.#rememberBreadcrumb(this.#cwd, resolvedSessionFile);
|
||||
|
||||
const titleSlot = await readTitleSlotFromFile(resolvedSessionFile, this.#storage);
|
||||
const fileEntries = loadedEntries ?? (await loadEntriesFromFile(resolvedSessionFile, this.#storage));
|
||||
const loaded = loadedSession ?? (await loadSessionFile(resolvedSessionFile, this.#storage));
|
||||
const { entries: fileEntries, titleSlot } = loaded;
|
||||
if (fileEntries.length === 0) {
|
||||
// Explicit but empty/missing path (e.g. --session flag): start fresh but
|
||||
// keep the requested path and materialize the header immediately.
|
||||
@@ -1361,7 +1392,7 @@ export class SessionManager {
|
||||
this.#titleUpdatedAt = titleSlot?.updatedAt ?? header.timestamp;
|
||||
this.#hasTitleSlot = titleSlot !== undefined;
|
||||
this.#fileIsCurrent = true;
|
||||
this.#rewriteRequired = migrated;
|
||||
this.#rewriteRequired = migrated || loaded.malformedRecords > 0;
|
||||
this.#forceFileCreation = true;
|
||||
this.#artifactManager = null;
|
||||
this.#artifactManagerSessionFile = null;
|
||||
@@ -2017,6 +2048,14 @@ export class SessionManager {
|
||||
};
|
||||
}
|
||||
|
||||
/** Subscribe to persistence failures so hosts can surface lost-durability state. */
|
||||
onPersistenceError(cb: (error: Error) => void): () => void {
|
||||
this.#persistenceErrorCallbacks.add(cb);
|
||||
return () => {
|
||||
this.#persistenceErrorCallbacks.delete(cb);
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Set the session display name.
|
||||
* @param source "user" for explicit renames; "auto" for generated titles.
|
||||
@@ -2624,8 +2663,8 @@ export class SessionManager {
|
||||
storage: SessionStorage = new FileSessionStorage(),
|
||||
options?: { initialCwd?: string; suppressBreadcrumb?: boolean },
|
||||
): Promise<SessionManager> {
|
||||
const loaded = await loadEntriesFromFile(filePath, storage);
|
||||
const header = loaded.find(entry => entry.type === "session") as SessionHeader | undefined;
|
||||
const loaded = await loadSessionFile(filePath, storage);
|
||||
const header = loaded.entries.find(entry => entry.type === "session") as SessionHeader | undefined;
|
||||
// Resume into the session's recorded cwd only when that directory still
|
||||
// exists. A deleted project dir would make the constructor's #cwd — and the
|
||||
// `setProjectDir` chdir interactive mode runs next — point at (and fail on)
|
||||
|
||||
Reference in New Issue
Block a user