Merge PR #4473: fix(session): guard legacy snapcompact frames on resume (@cexll)

This commit is contained in:
can1357
2026-07-05 13:10:26 +02:00
3 changed files with 169 additions and 4 deletions
+1
View File
@@ -66,6 +66,7 @@
- Fixed Windows session tail loss after atomic compaction rewrites by fencing append writers during full-file replacement and gating the atomic publish on a `commitGuard` that the storage backend checks synchronously before rename, so a concurrent `flushSync` (Ctrl+C / session-exit) is not overwritten by the stale body serialized before it ran. Covers post-compaction prompts, tool results, title changes, and exit diagnostics on the current JSONL path ([#4338](https://github.com/can1357/oh-my-pi/issues/4338)).
- Preserved isolated branch-mode task output as a patch artifact when `commitToBranch` fails before the task branch can be transferred, and surfaced the captured patch path (plus any nested patches) through the eval `agent()` failure message so callers can recover the work instead of losing it with the isolation worktree ([#4437](https://github.com/can1357/oh-my-pi/issues/4437)).
- Fixed task-class subagents dropping unresolved explicit model-role selectors before startup, preventing `modelRoles.task` from silently falling through to an unrelated available provider model ([#4421](https://github.com/can1357/oh-my-pi/issues/4421)).
- Fixed large legacy snapcompact archives being rehydrated into active resumed-session context, avoiding Bun Worker crashes on oversized archived frame payloads ([#4470](https://github.com/can1357/oh-my-pi/issues/4470)).
## [16.3.4] - 2026-07-03
@@ -4,6 +4,50 @@ import * as snapcompact from "@oh-my-pi/snapcompact";
import { createBranchSummaryMessage, createCompactionSummaryMessage, createCustomMessage } from "./messages";
import { type CompactionEntry, EPHEMERAL_MODEL_CHANGE_ROLE, type SessionEntry } from "./session-entries";
// #4470 crash artifacts had legacy frames (no shape metadata) with 17 frames,
// ~306k archive chars, and ~1.5M truncated chars. Current snapcompact frames
// carry shape metadata; only legacy archives with frame payload risk get this
// conservative LLM-payload guard, and transcript rendering remains intact.
const LEGACY_SNAPCOMPACT_FRAME_COUNT_GUARD = 16;
const LEGACY_SNAPCOMPACT_ARCHIVE_TEXT_GUARD = 250_000;
const LEGACY_SNAPCOMPACT_TRUNCATED_CHARS_GUARD = 1_000_000;
function hasLegacySnapcompactFrames(archive: snapcompact.Archive): boolean {
return archive.frames.some(frame => frame.font === undefined && frame.variant === undefined);
}
function hasCrashRiskSnapcompactFramePayload(archive: snapcompact.Archive): boolean {
return (
archive.frames.length >= LEGACY_SNAPCOMPACT_FRAME_COUNT_GUARD ||
snapcompact.frameDataBytes(archive.frames) >= snapcompact.FRAME_DATA_BYTES_BUDGET
);
}
function hasCrashRiskSnapcompactArchiveSize(archive: snapcompact.Archive): boolean {
return (
archive.frames.length >= LEGACY_SNAPCOMPACT_FRAME_COUNT_GUARD ||
archive.truncatedChars >= LEGACY_SNAPCOMPACT_TRUNCATED_CHARS_GUARD ||
(snapcompact.archiveSourceText(archive)?.length ?? 0) >= LEGACY_SNAPCOMPACT_ARCHIVE_TEXT_GUARD
);
}
function isCrashRiskLegacySnapcompactArchive(archive: snapcompact.Archive): boolean {
return (
hasLegacySnapcompactFrames(archive) &&
hasCrashRiskSnapcompactFramePayload(archive) &&
hasCrashRiskSnapcompactArchiveSize(archive)
);
}
function snapcompactHistoryBlockOptions(
archive: snapcompact.Archive,
options: BuildSessionContextOptions | undefined,
): snapcompact.HistoryBlockOptions | undefined {
if (options?.transcript) return undefined;
if (isCrashRiskLegacySnapcompactArchive(archive)) return { maxFrameDataBytes: 0 };
return { maxFrameDataBytes: snapcompact.FRAME_DATA_BYTES_BUDGET };
}
export interface SessionContext {
messages: AgentMessage[];
thinkingLevel?: string;
@@ -82,10 +126,7 @@ function snapcompactHistoryBlocksForContext(
options: BuildSessionContextOptions | undefined,
) {
if (!archive) return undefined;
return snapcompact.historyBlocks(
archive,
options?.transcript ? undefined : { maxFrameDataBytes: snapcompact.FRAME_DATA_BYTES_BUDGET },
);
return snapcompact.historyBlocks(archive, snapcompactHistoryBlockOptions(archive, options));
}
export function buildSessionContext(
@@ -303,6 +303,129 @@ describe("buildSessionContext", () => {
expect(transcriptSummary.blocks?.filter(block => block.type === "image")).toHaveLength(2);
});
it("does not rehydrate legacy oversized snapcompact frames into active LLM context (#4470)", () => {
const framePayload = "A".repeat(100_000);
const archiveText = `Issue #4470 legacy archive source\n${"archived history ".repeat(22_000)}`;
const compacted: CompactionEntry = {
...compaction("3", "2", "Legacy snapcompact summary", "1"),
preserveData: {
[snapcompact.PRESERVE_KEY]: {
frames: Array.from({ length: 17 }, (_, index) => ({
data: framePayload,
mimeType: "image/png",
cols: 64,
rows: 40,
chars: 1000 + index,
})),
totalChars: archiveText.length,
truncatedChars: 1_500_000,
text: archiveText,
textHead: "oldest retained snapcompact archive text",
textTail: "newest retained snapcompact archive text",
},
},
};
const entries: SessionEntry[] = [
msg("1", null, "user", "before compact"),
msg("2", "1", "assistant", "archived response"),
compacted,
msg("4", "3", "user", "after resume"),
];
const ctx = buildSessionContext(entries);
expect(ctx.messages.map(message => message.role)).toEqual(["compactionSummary", "user", "assistant", "user"]);
const summary = ctx.messages[0];
if (summary?.role !== "compactionSummary") throw new Error("Expected active compaction summary");
const blocks = summary.blocks ?? [];
expect(summary.summary).toContain("Legacy snapcompact summary");
expect(blocks.some(block => block.type === "text" && block.text.includes("oldest retained snapcompact"))).toBe(
true,
);
expect(blocks.some(block => block.type === "text" && block.text.includes("newest retained snapcompact"))).toBe(
true,
);
expect(blocks.filter(block => block.type === "image")).toHaveLength(0);
expect(summary.images ?? []).toHaveLength(0);
const transcript = buildSessionContext(entries, undefined, undefined, { transcript: true });
const transcriptSummary = transcript.messages[2];
if (transcriptSummary?.role !== "compactionSummary") throw new Error("Expected transcript compaction summary");
expect(transcriptSummary.blocks?.filter(block => block.type === "image")).toHaveLength(17);
});
it("keeps current oversized snapcompact frame archives in active LLM context", () => {
const framePayload = "A".repeat(100_000);
const archiveText = `Current archive source\n${"archived history ".repeat(22_000)}`;
const compacted: CompactionEntry = {
...compaction("3", "2", "Current snapcompact summary", "1"),
preserveData: {
[snapcompact.PRESERVE_KEY]: {
frames: Array.from({ length: 17 }, (_, index) => ({
data: framePayload,
mimeType: "image/png",
cols: 64,
rows: 40,
chars: 1000 + index,
font: "8x13",
variant: "bw",
lineRepeat: 1,
})),
totalChars: archiveText.length,
truncatedChars: 1_500_000,
text: archiveText,
textHead: "current oldest retained text",
textTail: "current newest retained text",
},
},
};
const entries: SessionEntry[] = [
msg("1", null, "user", "before compact"),
msg("2", "1", "assistant", "archived response"),
compacted,
msg("4", "3", "user", "after resume"),
];
const ctx = buildSessionContext(entries);
const summary = ctx.messages[0];
if (summary?.role !== "compactionSummary") throw new Error("Expected active compaction summary");
expect(summary.blocks?.filter(block => block.type === "image")).toHaveLength(17);
});
it("keeps small legacy snapcompact frame archives in active LLM context", () => {
const archiveText = `Large legacy text-only counter\n${"archived history ".repeat(22_000)}`;
const compacted: CompactionEntry = {
...compaction("3", "2", "Small legacy snapcompact summary", "1"),
preserveData: {
[snapcompact.PRESERVE_KEY]: {
frames: Array.from({ length: 2 }, (_, index) => ({
data: "A".repeat(1000),
mimeType: "image/png",
cols: 64,
rows: 40,
chars: 1000 + index,
})),
totalChars: archiveText.length,
truncatedChars: 1_500_000,
text: archiveText,
textHead: "small legacy oldest retained text",
textTail: "small legacy newest retained text",
},
},
};
const entries: SessionEntry[] = [
msg("1", null, "user", "before compact"),
msg("2", "1", "assistant", "archived response"),
compacted,
msg("4", "3", "user", "after resume"),
];
const ctx = buildSessionContext(entries);
const summary = ctx.messages[0];
if (summary?.role !== "compactionSummary") throw new Error("Expected active compaction summary");
expect(summary.blocks?.filter(block => block.type === "image")).toHaveLength(2);
});
it("multiple compactions uses latest", () => {
const entries: SessionEntry[] = [
msg("1", null, "user", "a"),