From 5dfa3ed98b72c5517f81580bfee0b71b4264cd10 Mon Sep 17 00:00:00 2001 From: Thomas Kosiewski Date: Tue, 8 Sep 2026 17:29:32 +0200 Subject: [PATCH 01/10] =?UTF-8?q?=F0=9F=A4=96=20fix:=20fence=20stale=20com?= =?UTF-8?q?paction=20publications=20after=20history=20deletion?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Advance publication generation when the actual deleted occurrences change current provider context or its boundary. Preserve raw privacy evidence, duplicate-ID behavior, and ineffective deletions; retain conservative fencing after later write failures. Signed-off-by: Thomas Kosiewski --- _Generated with `xum` • Model: `unavailable` • Thinking: `unavailable` • Cost: `$unavailable`_ Change-Id: I858f4e08e1a4442c149463ed2d56d36db4665c7d --- src/node/services/historyService.test.ts | 413 ++++++++++++++++++++++- src/node/services/historyService.ts | 51 ++- 2 files changed, 444 insertions(+), 20 deletions(-) diff --git a/src/node/services/historyService.test.ts b/src/node/services/historyService.test.ts index 3b86113ba3..1ea353a993 100644 --- a/src/node/services/historyService.test.ts +++ b/src/node/services/historyService.test.ts @@ -1,6 +1,8 @@ import * as path from "path"; import { describe, it, expect, beforeEach, afterEach, spyOn } from "bun:test"; import { CONTEXT_BOUNDARY_KINDS } from "@/common/constants/contextBoundary"; +import { SESSION_HISTORY_MAX_LINE_BYTES } from "@/common/constants/contextBudget"; +import { CONTINUOUS_COMPACTION_GENERATION_FILE } from "@/constants/continuousCompaction"; import { HistoryService } from "./historyService"; import type { Config } from "@/node/config"; import { createTestHistoryService } from "./testHistoryService"; @@ -11,7 +13,9 @@ import { createMuxMessage, type MuxMessage } from "@/common/types/message"; import assert from "node:assert"; import { createHash } from "node:crypto"; import * as fs from "fs/promises"; +import * as atomicWrite from "write-file-atomic"; import * as fileLock from "@/node/utils/concurrency/fileLock"; +import { workspaceFileLocks } from "@/node/utils/concurrency/workspaceFileLocks"; import { historyWriteLockPath, workspaceRemovalTombstonePath, @@ -1075,7 +1079,337 @@ describe("HistoryService", () => { return { store, receipt }; } + async function deleteErroredPlaceholder(messageId: string) { + const history = await service.getHistoryFromLatestBoundary(ws); + assert(history.success); + const message = history.data.find((entry) => entry.id === messageId); + assert(message); + assert( + ( + await service.writePartial(ws, { + ...message, + parts: [], + metadata: { ...message.metadata, error: "stream failed" }, + }) + ).success + ); + return service.commitPartial(ws, messageId); + } + + async function seedDeletionHistory(archive: MuxMessage[], chat: MuxMessage[]) { + assert((await service.appendManyToHistory(ws, [...archive, ...chat])).success); + // Complete lazy rotation, then model legacy layouts where the archive + // still contributes provider context, or chat retains sealed duplicates. + assert((await service.getHistoryFromLatestBoundary(ws)).success); + const chatPath = path.join(config.sessionsDir, ws, "chat.jsonl"); + const archivePath = path.join(config.sessionsDir, ws, "chat-archive.jsonl"); + const bytes = (messages: MuxMessage[]) => + Buffer.from(messages.map((message) => messageLine(ws, message) + "\n").join("")); + await fs.writeFile(chatPath, bytes(chat)); + await fs.writeFile(archivePath, bytes(archive)); + return { chatPath, archivePath, bytes }; + } + + it.each( + [ + { + name: "empty placeholder", + archive: [], + chat: [createMuxMessage("target", "assistant", "")], + changed: false, + }, + { + name: "display-only row", + archive: [], + chat: [{ ...display(), id: "target" }], + changed: false, + }, + { + name: "sealed active row", + archive: [], + chat: [row("target"), boundary()], + changed: false, + }, + { + name: "sealed active boundary", + archive: [], + chat: [{ ...reset(), id: "target" }, boundary()], + changed: false, + }, + { + name: "sealed archive row", + archive: [row("target")], + chat: [boundary()], + changed: false, + }, + { + name: "sealed archive boundary", + archive: [{ ...boundary(), id: "target" }], + chat: [reset()], + changed: false, + }, + { + name: "active-first duplicate", + archive: [row("target")], + chat: [createMuxMessage("target", "assistant", ""), row("later")], + changed: false, + }, + { + name: "duplicate active occurrences", + archive: [], + chat: [row("target"), boundary(), row("target"), row("later")], + changed: true, + }, + { + name: "duplicate archive context", + archive: [row("target"), row("target")], + chat: [row("later")], + changed: true, + }, + { + name: "archive duplicate sealed content", + archive: [row("target"), boundary(), createMuxMessage("target", "assistant", "")], + chat: [row("later")], + changed: false, + }, + ...[reset(), reset(true), { ...boundary(), parts: [] }].map((message, index) => ({ + name: `archive boundary ${index}`, + archive: [row("old"), { ...message, id: "target" }], + chat: [row("later")], + changed: true, + })), + { + name: "oversized reset evidence", + archive: [], + chat: [ + row("old"), + { ...reset(), id: "target", padding: " ".repeat(SESSION_HISTORY_MAX_LINE_BYTES) }, + ], + changed: false, + refused: true, + }, + ].flatMap((testCase) => + ["single", "batch"].map((method) => ({ refused: false, ...testCase, method })) + ) + )( + "$method deletion classifies $name by removed occurrences", + async ({ archive, chat, changed, method, refused }) => { + const { chatPath, archivePath } = await seedDeletionHistory( + structuredClone(archive), + structuredClone(chat) + ); + const beforeChat = await fs.readFile(chatPath); + const beforeArchive = await fs.readFile(archivePath); + const { store, receipt } = await capturePublication(); + const inChat = chat.some((message) => message.id === "target"); + const admitted = !refused && (method === "single" || inChat); + const advance = spyOn(store, "advanceGenerationUnderHistoryLock"); + try { + const result = + method === "single" + ? await service.deleteMessage(ws, "target") + : await service.deleteMessages(ws, ["target"]); + expect(result).toMatchObject({ success: admitted }); + expect(advance).toHaveBeenCalledTimes(admitted && changed ? 1 : 0); + if (!admitted) { + expect(await fs.readFile(chatPath)).toEqual(beforeChat); + expect(await fs.readFile(archivePath)).toEqual(beforeArchive); + } else { + // Active-first deletion must retain archive duplicates verbatim. + expect(await fs.readFile(inChat ? archivePath : chatPath)).toEqual( + inChat ? beforeArchive : beforeChat + ); + const retained = await collectFullHistory(service, ws); + expect(retained.filter((message) => message.id === "target")).toHaveLength( + inChat ? archive.filter((message) => message.id === "target").length : 0 + ); + } + if (admitted && changed) { + expect(await store.captureGeneration()).not.toBe(receipt.publicationGeneration); + expect(await store.read()).toBeNull(); + } else { + expect(await store.captureGeneration()).toBe(receipt.publicationGeneration); + expect( + await store.recordFallbackPrefix( + receipt, + { modelString: "anthropic:next", prefix }, + () => true + ) + ).not.toBeNull(); + } + } finally { + advance.mockRestore(); + } + } + ); + + it.each([ + "single missing", + "batch missing", + "batch refused", + "duplicate batch IDs", + "partial placeholder", + ])("%s deletion leaves generation and unrelated history unchanged", async (method) => { + const { chatPath, archivePath } = await seedDeletionHistory( + [], + [row("target"), createMuxMessage("empty", "assistant", "")] + ); + const before = await fs.readFile(chatPath); + const { store, receipt } = await capturePublication(); + if (method === "duplicate batch IDs") { + expect( + await service.deleteMessages(ws, ["target", "target"]).catch((error: unknown) => error) + ).toBeInstanceOf(Error); + } else { + const result = + method === "partial placeholder" + ? await deleteErroredPlaceholder("empty") + : method === "single missing" + ? await service.deleteMessage(ws, "missing") + : await service.deleteMessages( + ws, + method === "batch refused" ? ["target", "missing"] : ["missing"] + ); + expect(result.success).toBe(method === "partial placeholder"); + } + if (method !== "partial placeholder") expect(await fs.readFile(chatPath)).toEqual(before); + expect(await fs.readFile(archivePath)).toEqual(Buffer.alloc(0)); + expect(await store.captureGeneration()).toBe(receipt.publicationGeneration); + expect(await store.read()).toEqual(receipt); + }); + + it.each( + ["chat", "archive"].flatMap((artifact) => + ["single", "batch"].flatMap((method) => + [false, true].map((active) => ({ artifact, method, active })) + ) + ) + )( + "$method deletion preserves raw floors in $artifact (active cut: $active)", + async ({ artifact, method, active }) => { + const old = row("target"); + const fresh = row(active ? "target" : "fresh"); + const { chatPath, archivePath, bytes } = await seedDeletionHistory( + artifact === "archive" ? [old, fresh] : [], + artifact === "chat" ? [old, fresh] : [row("tail")] + ); + const raw = Buffer.concat([ + Buffer.from(' {\n"contextBoundaryKind"\n:\n"reset"\n'), + Buffer.from([0xff]), + Buffer.from("\n}\n"), + ]); + const targetPath = artifact === "chat" ? chatPath : archivePath; + await fs.writeFile(targetPath, Buffer.concat([bytes([old]), raw, bytes([fresh])])); + const { store, receipt } = await capturePublication(); + const result = + method === "single" + ? await service.deleteMessage(ws, "target") + : await service.deleteMessages(ws, ["target"]); + const admitted = method === "single" || artifact === "chat"; + expect(result.success).toBe(admitted); + expect(await fs.readFile(targetPath)).toEqual( + Buffer.concat([ + ...(admitted ? [] : [bytes([old])]), + raw, + ...(admitted && active ? [] : [bytes([fresh])]), + ]) + ); + expect((await store.captureGeneration()) !== receipt.publicationGeneration).toBe( + admitted && active + ); + const provider = await service.getHistoryFromLatestBoundary(ws); + assert(provider.success); + expect(provider.data.map((message) => message.id)).toEqual([ + ...(admitted && active ? [] : [fresh.id]), + ...(artifact === "archive" ? ["tail"] : []), + ]); + } + ); + + it.each( + ["single", "batch", "archive", "partial"].flatMap((method) => + ["generation", "history"].map((stage) => ({ method, stage })) + ) + )( + "$method deletion handles a $stage write failure without changing history or counters", + async ({ method, stage }) => { + const target = method === "partial" ? reset() : row("target"); + const { chatPath, archivePath } = await seedDeletionHistory( + method === "archive" ? [target] : [], + method === "archive" ? [] : [target] + ); + const beforeChat = await fs.readFile(chatPath); + const beforeArchive = await fs.readFile(archivePath); + const { store, receipt } = await capturePublication(); + const counters = service as unknown as { sequenceCounters: Map }; + const counter = counters.sequenceCounters.get(ws); + const failedPath = + stage === "generation" + ? path.join(config.sessionsDir, ws, CONTINUOUS_COMPACTION_GENERATION_FILE) + : method === "archive" + ? archivePath + : chatPath; + const atomic = atomicWrite.default; + const failure = spyOn(atomicWrite, "default").mockImplementation( + new Proxy(atomic, { + apply(target, _thisArg, args: Parameters) { + if (args[0] === failedPath) return Promise.reject(new Error("disk unavailable")); + return target(...args); + }, + }) + ); + try { + const result = + method === "partial" + ? await deleteErroredPlaceholder(target.id) + : method === "batch" + ? await service.deleteMessages(ws, [target.id]) + : await service.deleteMessage(ws, target.id); + expect(result.success).toBe(false); + expect(await fs.readFile(chatPath)).toEqual(beforeChat); + expect(await fs.readFile(archivePath)).toEqual(beforeArchive); + expect(counters.sequenceCounters.get(ws)).toBe(counter); + expect((await store.captureGeneration()) !== receipt.publicationGeneration).toBe( + stage === "history" + ); + expect(await store.read()).toEqual(stage === "history" ? null : receipt); + if (method === "partial") expect(await service.readPartial(ws)).not.toBeNull(); + } finally { + failure.mockRestore(); + } + } + ); + const destructiveCases = [ + { + name: "single provider-row deletion", + rows: [row("old"), row("tail")], + mutate: () => service.deleteMessage(ws, "old"), + expected: ["tail"], + }, + { + name: "batch provider-row deletion", + rows: [row("old"), row("tail"), row("later")], + mutate: () => service.deleteMessages(ws, ["old", "tail"]), + expected: ["later"], + }, + ...[ + { name: "reset", message: reset() }, + { name: "rollover", message: reset(true) }, + { name: "empty compaction", message: { ...boundary(), parts: [] } }, + ].flatMap(({ name, message }) => + ["single", "batch", "partial"].map((method) => ({ + name: `${method} deletion of ${name} boundary`, + rows: [row("old"), message], + mutate: () => + method === "partial" + ? deleteErroredPlaceholder(message.id) + : method === "batch" + ? service.deleteMessages(ws, [message.id]) + : service.deleteMessage(ws, message.id), + expected: ["old"], + })) + ), ...[false, true].flatMap((rollover) => [false, true].map((batch) => ({ name: `${rollover ? "rollover" : "reset"} ${batch ? "batch" : "single"}`, @@ -1257,14 +1591,23 @@ describe("HistoryService", () => { } ); - it("holds the history file lock from generation advancement through deletion", async () => { - assert((await service.appendToHistory(ws, row("old"))).success); + it.each( + ["clear", "single", "batch", "partial", "archive"].flatMap((method) => + ["initial", "fallback", "boundary"].map((publication) => ({ method, publication })) + ) + )("$method deletion fences foreign $publication", async (testCase) => { + const { method, publication } = testCase; + const target = method === "partial" ? reset() : row("old"); + const { chatPath, archivePath } = await seedDeletionHistory( + method === "archive" ? [target] : [], + method === "archive" ? [] : [target] + ); const store = service.getContinuousCompactionJournal(ws); // Exercise an existing durable generation too, rather than only legacy absence. await store.advanceGeneration(); const { receipt } = await capturePublication(); - await store.clear(receipt); - const historyPath = path.join(config.sessionsDir, ws, "chat.jsonl"); + if (publication === "initial") await store.clear(receipt); + const historyPath = method === "archive" ? archivePath : chatPath; const before = await fs.readFile(historyPath, "utf8"); const entered = Promise.withResolvers(); const release = Promise.withResolvers(); @@ -1277,36 +1620,82 @@ describe("HistoryService", () => { await release.promise; } ); - const clearing = service.clearHistory(ws); - const foreign = new HistoryService(config).getContinuousCompactionJournal(ws); + const deleting = + method === "clear" + ? service.clearHistory(ws) + : method === "partial" + ? deleteErroredPlaceholder(target.id) + : method === "batch" + ? service.deleteMessages(ws, [target.id]) + : service.deleteMessage(ws, target.id); + const foreignHistory = new HistoryService(config); + const foreign = foreignHistory.getContinuousCompactionJournal(ws); + const publish = async (candidate: ContinuousCompactionJournal) => { + if (publication === "boundary") + return ( + await foreignHistory.persistBoundaryWithTailCopies( + ws, + structuredClone(candidate.boundary), + [], + false, + () => true, + { + publication: { generation: candidate.publicationGeneration, journal: candidate }, + onCommitted: () => undefined, + } + ) + ).success; + return ( + (publication === "fallback" + ? await foreign.recordFallbackPrefix( + candidate, + { modelString: "anthropic:next", prefix }, + () => true + ) + : await foreign.write(candidate, prefix, () => true)) !== null + ); + }; const capture = spyOn(foreign, "captureGenerationUnderHistoryLock"); const acquire = fileLock.acquireProcessFileLock; const attempted = Promise.withResolvers(); let acquiring: | ReturnType> | undefined; - let writing: ReturnType | undefined; + let writing: Promise | undefined; + const queued = spyOn(workspaceFileLocks, "withLock"); try { await entered.promise; acquiring = spyOn(fileLock, "acquireProcessFileLock").mockImplementation((options) => { attempted.resolve(); return acquire(options); }); - writing = foreign.write(receipt, prefix, () => true); - await attempted.promise; + writing = publish(receipt); + // Boundary writes first queue on the shared in-process mutex; journal + // publications go straight to the same cross-process history lock. + if (publication === "boundary") expect(queued).toHaveBeenCalled(); + else await attempted.promise; expect(capture).not.toHaveBeenCalled(); expect(await fs.readFile(historyPath, "utf8")).toBe(before); release.resolve(); - expect((await clearing).success).toBe(true); - expect(await writing).toBeNull(); + expect((await deleting).success).toBe(true); + expect(await writing).toBe(false); expect(await foreign.captureGeneration()).not.toBe(receipt.publicationGeneration); expect(await collectFullHistory(new HistoryService(config), ws)).toEqual([]); + expect(await foreign.read()).toBeNull(); + const fresh = await foreign.write( + { ...receipt, publicationGeneration: await foreign.captureGeneration() }, + prefix, + () => true + ); + assert(fresh); + if (publication !== "initial") expect(await publish(fresh)).toBe(true); } finally { release.resolve(); - await Promise.all([clearing, writing]); + await Promise.all([deleting, writing]); acquiring?.mockRestore(); capture.mockRestore(); advancing.mockRestore(); + queued.mockRestore(); } }); diff --git a/src/node/services/historyService.ts b/src/node/services/historyService.ts index 679c60d126..ce747eb34f 100644 --- a/src/node/services/historyService.ts +++ b/src/node/services/historyService.ts @@ -3381,10 +3381,11 @@ export class HistoryService { } const filteredMessages = messages.filter((message) => !ids.has(message.id)); - await writeFileAtomic( - this.getChatHistoryPath(workspaceId), - this.serializeHistoryRewrite(rows, workspaceId, (row) => (ids.has(row.id) ? null : row)) + const historyEntries = this.serializeHistoryRewrite(rows, workspaceId, (row) => + ids.has(row.id) ? null : row ); + await this.fenceDeletedMessagesUnderHistoryLock(workspaceId, messages, ids); + await writeFileAtomic(this.getChatHistoryPath(workspaceId), historyEntries); const maxSeq = filteredMessages.reduce((max, message) => { const sequence = message.metadata?.historySequence; @@ -3433,6 +3434,35 @@ export class HistoryService { ); } + private async fenceDeletedMessagesUnderHistoryLock( + workspaceId: string, + messages: MuxMessage[], + deletedIds: ReadonlySet, + newerMessageCount = 0 + ): Promise { + // Cleanup must retire foreign compactors only when the actual removed + // occurrences affect today's provider view, including an empty boundary. + // Use the raw-aware suffix so retained unreadable resets still seal old rows. + const providerMessages = await readProviderHistoryFromLatestBoundary( + { + chat: this.getChatHistoryPath(workspaceId), + archive: this.getChatArchivePath(workspaceId), + }, + 0 + ); + // Archive fallback excludes all newer chat rows. Matching IDs across files + // would conflate retained duplicates with occurrences this write removes. + const activeCount = Math.max(0, providerMessages.length - newerMessageCount); + const removed = messages + .slice(Math.max(0, messages.length - activeCount)) + .filter((message) => deletedIds.has(message.id)); + if (tailCutChangesProviderContext(removed)) { + // Call only after serialization admits the rewrite, immediately before + // its write. A later disk failure must not restore the old generation. + await this.getContinuousCompactionJournal(workspaceId).advanceGenerationUnderHistoryLock(); + } + } + private async deleteMessageUnderWriteLock( workspaceId: string, messageId: string @@ -3458,12 +3488,16 @@ export class HistoryService { // Archived rows are strictly older than active rows, so deleting one // can never affect the sequence counter. - await writeFileAtomic( - this.getChatArchivePath(workspaceId), - this.serializeHistoryRewrite(archiveRows, workspaceId, (row) => - row.id === messageId ? null : row - ) + const archiveEntries = this.serializeHistoryRewrite(archiveRows, workspaceId, (row) => + row.id === messageId ? null : row + ); + await this.fenceDeletedMessagesUnderHistoryLock( + workspaceId, + archiveMessages, + new Set([messageId]), + messages.length ); + await writeFileAtomic(this.getChatArchivePath(workspaceId), archiveEntries); return Ok(undefined); } @@ -3472,6 +3506,7 @@ export class HistoryService { row.id === messageId ? null : row ); + await this.fenceDeletedMessagesUnderHistoryLock(workspaceId, messages, new Set([messageId])); // Atomic write prevents corruption if app crashes mid-write await writeFileAtomic(historyPath, historyEntries); From 72639030a380ac12c7ec618cd425ef93bcdfb011 Mon Sep 17 00:00:00 2001 From: Thomas Kosiewski Date: Tue, 8 Sep 2026 18:29:42 +0200 Subject: [PATCH 02/10] =?UTF-8?q?=F0=9F=A4=96=20fix:=20fence=20reset=20evi?= =?UTF-8?q?dence=20exposed=20by=20history=20deletion?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Deleting an empty readable separator can join malformed JSON fragments into reset evidence. Reuse the provider scanner's incremental reset probe to fence only newly exposed evidence while preserving raw bytes and generations for ineffective cuts. Nine new regressions cover single/batch deletion and partial cleanup, actual provider context, and stale publication rejection. All 302 affected tests and full static checks pass; independent review approved. Signed-off-by: Thomas Kosiewski --- _Generated with `xum` • Model: `unavailable` • Thinking: `unavailable` • Cost: `$unavailable`_ Change-Id: I98498e788a9a88eafc60022bd042eb43ea895cfe --- src/node/services/historyScanner.ts | 13 +++ src/node/services/historyService.test.ts | 116 +++++++++++++++++++++++ src/node/services/historyService.ts | 50 +++++++++- 3 files changed, 174 insertions(+), 5 deletions(-) diff --git a/src/node/services/historyScanner.ts b/src/node/services/historyScanner.ts index fe3a0c4311..044115d1cf 100644 --- a/src/node/services/historyScanner.ts +++ b/src/node/services/historyScanner.ts @@ -178,6 +178,19 @@ function classifyHistoryScanRow(text: string, probe: HistoryResetProbe): MuxMess } } +/** Use the provider reader's probe when rewrites join previously separated unreadable rows. */ +export function hasUnreadableHistoryResetEvidence(rows: readonly Buffer[]): boolean { + const probe: HistoryResetProbe = { resetProbe: "", resetStage: 0, possibleReset: false }; + for (let i = rows.length - 1; i >= 0; i--) { + const raw = rows[i].at(-1) === 10 ? rows[i].subarray(0, -1) : rows[i]; + addHistoryResetProbe(probe, raw, true); + if (raw.length <= SESSION_HISTORY_MAX_LINE_BYTES) + classifyHistoryScanRow(raw.toString("utf8"), probe); + if (probe.possibleReset) return true; + } + return false; +} + function historyFileStamp( stat: { dev: number; ino: number; size: number; mtimeMs: number; ctimeMs: number } | undefined ): string { diff --git a/src/node/services/historyService.test.ts b/src/node/services/historyService.test.ts index 1ea353a993..c7f0b714fe 100644 --- a/src/node/services/historyService.test.ts +++ b/src/node/services/historyService.test.ts @@ -1326,6 +1326,122 @@ describe("HistoryService", () => { } ); + it.each([ + ...["single", "batch", "partial", "archive"].map((method) => ({ method, variant: "new" })), + ...[ + "retained boundary", + "existing reset", + "retained separator", + "missing token", + "escaped junk", + ].map((variant) => ({ method: "single", variant })), + ])( + "$method deletion classifies joining malformed fragments ($variant)", + async ({ method, variant }) => { + const old = row("old"); + const separator = createMuxMessage("separator", "assistant", ""); + const fresh = row("fresh"); + const retained = + variant === "retained separator" ? [createMuxMessage("retained", "assistant", "")] : []; + const suffix = variant === "retained boundary" ? [boundary()] : []; + const source = [old, separator, ...retained, fresh, ...suffix]; + const { chatPath, archivePath, bytes } = await seedDeletionHistory( + method === "archive" ? source : [], + method === "archive" ? [] : source + ); + const targetPath = method === "archive" ? archivePath : chatPath; + const left = Buffer.from( + variant === "existing reset" + ? '{"metadata":{"contextBoundaryKind":"reset"},broken\n' + : '{"metadata":{"contextBoundaryKind"\n' + ); + const right = + variant === "escaped junk" + ? Buffer.concat([ + Buffer.from("?junk"), + Buffer.from([0xff]), + Buffer.from(':"res\\u0065t"}}\n'), + ]) + : Buffer.from(variant === "missing token" ? ':"other"}}\n' : ':"reset"}}\n'); + const tail = Buffer.concat([bytes(retained), right, bytes([fresh, ...suffix])]); + await fs.writeFile( + targetPath, + Buffer.concat([bytes([old]), left, bytes([separator]), tail]) + ); + const before = await service.getHistoryFromLatestBoundary(ws); + assert(before.success); + expect(before.data.map((message) => message.id)).toEqual( + variant === "retained boundary" + ? ["sealed"] + : [ + ...(variant === "existing reset" ? [] : ["old"]), + "separator", + ...retained.map((message) => message.id), + "fresh", + ] + ); + const { store, receipt } = await capturePublication(); + const result = + method === "partial" + ? await deleteErroredPlaceholder("separator") + : method === "batch" + ? await service.deleteMessages(ws, ["separator"]) + : await service.deleteMessage(ws, "separator"); + expect(result.success).toBe(true); + expect(await fs.readFile(targetPath)).toEqual(Buffer.concat([bytes([old]), left, tail])); + const after = await service.getHistoryFromLatestBoundary(ws); + assert(after.success); + const fenced = variant === "new" || variant === "escaped junk"; + expect(after.data.map((message) => message.id)).toEqual( + variant === "retained boundary" + ? ["sealed"] + : [ + ...(fenced || variant === "existing reset" ? [] : ["old"]), + ...retained.map((message) => message.id), + "fresh", + ] + ); + expect((await store.captureGeneration()) !== receipt.publicationGeneration).toBe(fenced); + if (!fenced) { + expect(await store.read()).toEqual(receipt); + return; + } + const foreignHistory = new HistoryService(config); + const foreign = foreignHistory.getContinuousCompactionJournal(ws); + expect( + await foreign.recordFallbackPrefix( + receipt, + { modelString: "anthropic:next", prefix }, + () => true + ) + ).toBeNull(); + expect( + ( + await foreignHistory.persistBoundaryWithTailCopies( + ws, + structuredClone(receipt.boundary), + [], + false, + () => true, + { + publication: { generation: receipt.publicationGeneration, journal: receipt }, + onCommitted: () => undefined, + } + ) + ).success + ).toBe(false); + expect(await foreign.read()).toBeNull(); + expect(await foreign.write(receipt, prefix, () => true)).toBeNull(); + expect( + await foreign.write( + { ...receipt, publicationGeneration: await foreign.captureGeneration() }, + prefix, + () => true + ) + ).not.toBeNull(); + } + ); + it.each( ["single", "batch", "archive", "partial"].flatMap((method) => ["generation", "history"].map((stage) => ({ method, stage })) diff --git a/src/node/services/historyService.ts b/src/node/services/historyService.ts index ce747eb34f..b173a1cab0 100644 --- a/src/node/services/historyService.ts +++ b/src/node/services/historyService.ts @@ -7,6 +7,7 @@ import { SESSION_HISTORY_MAX_SCAN_BYTES } from "@/common/constants/contextBudget import { hasRawResetMarker, hasAmbiguousResetKeys, + hasUnreadableHistoryResetEvidence, isReadableHistoryMessage, scanHistoryFilesBounded, readProviderHistoryFromLatestBoundary, @@ -133,6 +134,41 @@ function tailCutChangesProviderContext(removedMessages: MuxMessage[]): boolean { ); } +function deletionCreatesRawReset( + rows: readonly HistoryRewriteRow[], + deletedIds: ReadonlySet, + activeRemoved: ReadonlySet +): boolean { + let originalRun: Buffer[] = []; + let joinedRun: Buffer[] = []; + let existingReset = false; + let removedActive = false; + const createdReset = () => + removedActive && + !existingReset && + !hasUnreadableHistoryResetEvidence(originalRun) && + hasUnreadableHistoryResetEvidence(joinedRun); + // Readable rows break the raw reset probe, even when provider-ineligible. + // Removing such a separator can seal captured context without removing it. + for (const row of rows) { + if (!row.message) { + originalRun.push(row.raw); + joinedRun.push(row.raw); + } else if (deletedIds.has(row.message.id)) { + existingReset ||= hasUnreadableHistoryResetEvidence(originalRun); + originalRun = []; + removedActive ||= activeRemoved.has(row.message); + } else { + if (createdReset()) return true; + originalRun = []; + joinedRun = []; + existingReset = false; + removedActive = false; + } + } + return createdReset(); +} + function stripContextUsage(message: MuxMessage): MuxMessage { if (!message.metadata) { return message; @@ -3384,7 +3420,7 @@ export class HistoryService { const historyEntries = this.serializeHistoryRewrite(rows, workspaceId, (row) => ids.has(row.id) ? null : row ); - await this.fenceDeletedMessagesUnderHistoryLock(workspaceId, messages, ids); + await this.fenceDeletedMessagesUnderHistoryLock(workspaceId, rows, ids); await writeFileAtomic(this.getChatHistoryPath(workspaceId), historyEntries); const maxSeq = filteredMessages.reduce((max, message) => { @@ -3436,7 +3472,7 @@ export class HistoryService { private async fenceDeletedMessagesUnderHistoryLock( workspaceId: string, - messages: MuxMessage[], + rows: HistoryRewriteRow[], deletedIds: ReadonlySet, newerMessageCount = 0 ): Promise { @@ -3453,10 +3489,14 @@ export class HistoryService { // Archive fallback excludes all newer chat rows. Matching IDs across files // would conflate retained duplicates with occurrences this write removes. const activeCount = Math.max(0, providerMessages.length - newerMessageCount); + const messages = rows.flatMap((row) => (row.message ? [row.message] : [])); const removed = messages .slice(Math.max(0, messages.length - activeCount)) .filter((message) => deletedIds.has(message.id)); - if (tailCutChangesProviderContext(removed)) { + if ( + tailCutChangesProviderContext(removed) || + deletionCreatesRawReset(rows, deletedIds, new Set(removed)) + ) { // Call only after serialization admits the rewrite, immediately before // its write. A later disk failure must not restore the old generation. await this.getContinuousCompactionJournal(workspaceId).advanceGenerationUnderHistoryLock(); @@ -3493,7 +3533,7 @@ export class HistoryService { ); await this.fenceDeletedMessagesUnderHistoryLock( workspaceId, - archiveMessages, + archiveRows, new Set([messageId]), messages.length ); @@ -3506,7 +3546,7 @@ export class HistoryService { row.id === messageId ? null : row ); - await this.fenceDeletedMessagesUnderHistoryLock(workspaceId, messages, new Set([messageId])); + await this.fenceDeletedMessagesUnderHistoryLock(workspaceId, rows, new Set([messageId])); // Atomic write prevents corruption if app crashes mid-write await writeFileAtomic(historyPath, historyEntries); From f4204a6f7e840e1fdf17a0a08a818dd973a76dde Mon Sep 17 00:00:00 2001 From: Thomas Kosiewski Date: Tue, 8 Sep 2026 19:21:10 +0200 Subject: [PATCH 03/10] =?UTF-8?q?=F0=9F=A4=96=20fix:=20preserve=20oversize?= =?UTF-8?q?d=20reset=20evidence=20during=20history=20deletion?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Align rewrite classification with the provider scanner: oversized raw reset evidence remains preserved and cannot be deleted as an ordinary parsed message. Exclude the trailing newline from size and ambiguity checks so an exactly-at-limit valid row remains addressable and fenced. Single, batch and archive regressions reproduce the mismatch. Reasoning-only deletion regressions inherit the lower-layer conservative classification fix after integration. Fourteen boundary cases pass; final broad validation follows parent integration. Signed-off-by: Thomas Kosiewski --- _Generated with `xum` • Model: `unavailable` • Thinking: `unavailable` • Cost: `$unavailable`_ Change-Id: I065fb5ad8f37cfc36226218a053e144dbc2dd1f7 --- .../utils/messages/compactionBoundary.test.ts | 20 ++ src/node/services/historyService.test.ts | 179 +++++++++++++++++- src/node/services/historyService.ts | 17 +- 3 files changed, 211 insertions(+), 5 deletions(-) diff --git a/src/common/utils/messages/compactionBoundary.test.ts b/src/common/utils/messages/compactionBoundary.test.ts index dc0230a882..b125ed1a32 100644 --- a/src/common/utils/messages/compactionBoundary.test.ts +++ b/src/common/utils/messages/compactionBoundary.test.ts @@ -146,6 +146,26 @@ describe("findLatestCompactionBoundaryIndex", () => { }); describe("context boundary helpers", () => { + it("opts into reasoning eligibility without admitting rejected or reset rows", () => { + const reasoning = createMuxMessage("reasoning", "assistant", ""); + reasoning.parts = [{ type: "reasoning", text: "Provider reasoning" }]; + expect(hasProviderEligibleMessages([reasoning])).toBe(false); + expect(hasProviderEligibleMessages([reasoning], { preserveReasoningOnly: true })).toBe(true); + for (const metadata of [ + { contextBudgetRejected: true }, + { contextBoundaryKind: "reset" as const }, + ]) { + expect( + hasProviderEligibleMessages([{ ...reasoning, metadata }], { preserveReasoningOnly: true }) + ).toBe(false); + } + expect( + hasProviderEligibleMessages([createMuxMessage("empty", "assistant", "")], { + preserveReasoningOnly: true, + }) + ).toBe(false); + }); + it("recognizes context reset boundaries as latest context boundary", () => { const messages = [ createMuxMessage("u0", "user", "before"), diff --git a/src/node/services/historyService.test.ts b/src/node/services/historyService.test.ts index c7f0b714fe..d5eb6ca680 100644 --- a/src/node/services/historyService.test.ts +++ b/src/node/services/historyService.test.ts @@ -6,6 +6,7 @@ import { CONTINUOUS_COMPACTION_GENERATION_FILE } from "@/constants/continuousCom import { HistoryService } from "./historyService"; import type { Config } from "@/node/config"; import { createTestHistoryService } from "./testHistoryService"; +import { prepareProviderRequestMessages } from "./turnContextAssembler"; import type { ContinuousCompactionJournal } from "@/common/orpc/schemas/continuousCompaction"; import { createContextBudgetRejectedMessage } from "@/common/utils/messages/contextBudgetRejection"; import { updateSubagentTranscriptArtifactsFile } from "./subagentTranscriptArtifacts"; @@ -1037,7 +1038,7 @@ describe("HistoryService", () => { return service.rejectContextBudgetRequest(ws, latest.data[0]); } - async function capturePublication() { + async function capturePublication(effectiveThinkingLevel: "off" | "high" = "off") { const store = service.getContinuousCompactionJournal(ws); const journal: ContinuousCompactionJournal = { version: 1, @@ -1058,7 +1059,7 @@ describe("HistoryService", () => { preparation: { modelString: "anthropic:claude-sonnet-4-5", providerForMessages: "anthropic", - effectiveThinkingLevel: "off", + effectiveThinkingLevel, effectiveAgentId: "exec", toolNamesForSentinel: [], }, @@ -1110,6 +1111,153 @@ describe("HistoryService", () => { return { chatPath, archivePath, bytes }; } + it.each( + ["single", "batch", "archive"].flatMap((method) => [0, 1].map((extra) => ({ method, extra }))) + )( + "$method deletion counts JSON bytes without the LF at the reset limit (+$extra)", + async ({ method, extra }) => { + const old = row("old"); + const floor = { ...reset(), padding: "" }; + const fresh = row("fresh"); + const source = [old, floor, fresh]; + const { chatPath, archivePath, bytes } = await seedDeletionHistory( + method === "archive" ? source : [], + method === "archive" ? [] : source + ); + floor.padding = "x".repeat( + SESSION_HISTORY_MAX_LINE_BYTES + extra - Buffer.byteLength(messageLine(ws, floor)) + ); + expect(Buffer.byteLength(messageLine(ws, floor))).toBe( + SESSION_HISTORY_MAX_LINE_BYTES + extra + ); + const targetPath = method === "archive" ? archivePath : chatPath; + const beforeBytes = bytes(source); + await fs.writeFile(targetPath, beforeBytes); + const before = await service.getHistoryFromLatestBoundary(ws); + assert(before.success); + expect(before.data.map((message) => message.id)).toEqual( + extra === 0 ? [floor.id, fresh.id] : [fresh.id] + ); + const { store, receipt } = await capturePublication(); + const result = + method === "batch" + ? await service.deleteMessages(ws, [floor.id]) + : await service.deleteMessage(ws, floor.id); + expect(result.success).toBe(extra === 0); + expect(await fs.readFile(targetPath)).toEqual( + extra === 0 ? bytes([old, fresh]) : beforeBytes + ); + const after = await service.getHistoryFromLatestBoundary(ws); + assert(after.success); + expect(after.data.map((message) => message.id)).toEqual( + extra === 0 ? [old.id, fresh.id] : [fresh.id] + ); + expect((await store.captureGeneration()) !== receipt.publicationGeneration).toBe( + extra === 0 + ); + } + ); + + it.each(["single", "batch", "archive", "partial"])( + "%s deletion preserves oversized token-separated raw reset evidence", + async (method) => { + const floor = { + ...createMuxMessage("floor", "assistant", ""), + contextBoundaryKind: 0, + padding: "x".repeat(SESSION_HISTORY_MAX_LINE_BYTES), + candidate: "reset", + }; + const placeholder = + method === "partial" ? [createMuxMessage("floor", "assistant", "")] : []; + const source = [row("old"), floor, ...placeholder, row("fresh")]; + const { chatPath, archivePath } = await seedDeletionHistory( + method === "archive" ? source : [], + method === "archive" ? [] : source + ); + const targetPath = method === "archive" ? archivePath : chatPath; + const beforeBytes = await fs.readFile(targetPath); + const before = await service.getHistoryFromLatestBoundary(ws); + assert(before.success); + expect(before.data.map((message) => message.id)).toEqual([ + ...placeholder.map((message) => message.id), + "fresh", + ]); + const { store, receipt } = await capturePublication(); + const result = + method === "partial" + ? await deleteErroredPlaceholder("floor") + : method === "batch" + ? await service.deleteMessages(ws, ["floor"]) + : await service.deleteMessage(ws, "floor"); + const after = await service.getHistoryFromLatestBoundary(ws); + assert(after.success); + expect(after.data.map((message) => message.id)).toEqual(["fresh"]); + expect(result.success).toBe(method === "partial"); + expect(await fs.readFile(targetPath)).toEqual( + method === "partial" + ? Buffer.from( + beforeBytes.toString("utf8").replace(messageLine(ws, placeholder[0]) + "\n", "") + ) + : beforeBytes + ); + expect(await store.read()).toEqual(receipt); + expect(await store.captureGeneration()).toBe(receipt.publicationGeneration); + } + ); + + it.each(["single", "batch", "archive", "partial"])( + "%s deletion fences provider-preserved reasoning-only context", + async (method) => { + const reasoning: MuxMessage = { + ...createMuxMessage("reasoning", "assistant", ""), + parts: [{ type: "reasoning", text: "Preserved provider reasoning" }], + }; + const placeholder = + method === "partial" ? [createMuxMessage("reasoning", "assistant", "")] : []; + const source = [row("old"), reasoning, ...placeholder, row("fresh")]; + await seedDeletionHistory( + method === "archive" ? source : [], + method === "archive" ? [] : source + ); + const before = await service.getHistoryFromLatestBoundary(ws); + assert(before.success); + expect( + prepareProviderRequestMessages( + before.data, + "anthropic", + "high" + ).providerRequestMessages.map((message) => message.id) + ).toEqual(["old", "reasoning", "fresh"]); + const { receipt } = await capturePublication("high"); + if (method === "partial") { + assert( + ( + await service.writePartial(ws, { + ...placeholder[0], + metadata: { ...placeholder[0].metadata, error: "stream failed" }, + }) + ).success + ); + } + const result = + method === "partial" + ? await service.commitPartial(ws, "reasoning") + : method === "batch" + ? await service.deleteMessages(ws, ["reasoning"]) + : await service.deleteMessage(ws, "reasoning"); + expect(result.success).toBe(true); + const foreign = new HistoryService(config).getContinuousCompactionJournal(ws); + expect( + await foreign.recordFallbackPrefix( + receipt, + { modelString: "anthropic:next", prefix }, + () => true + ) + ).toBeNull(); + expect(await foreign.captureGeneration()).not.toBe(receipt.publicationGeneration); + } + ); + it.each( [ { @@ -1148,6 +1296,33 @@ describe("HistoryService", () => { chat: [reset()], changed: false, }, + ...[false, true].map((archived) => { + const reasoning: MuxMessage = { + ...createMuxMessage("target", "assistant", ""), + parts: [{ type: "reasoning", text: "Sealed reasoning" }], + }; + return { + name: `${archived ? "archive" : "active"} sealed reasoning`, + archive: archived ? [reasoning] : [], + chat: [...(archived ? [] : [reasoning]), boundary()], + changed: false, + }; + }), + ...[ + { + name: "ordinary oversized row", + padding: "x".repeat(SESSION_HISTORY_MAX_LINE_BYTES), + candidate: "ordinary", + }, + { name: "readable reset-token payload", padding: "small", candidate: "reset" }, + ].map(({ name, ...payload }) => ({ + name, + archive: [], + chat: [ + { ...createMuxMessage("target", "assistant", ""), contextBoundaryKind: 0, ...payload }, + ], + changed: false, + })), { name: "active-first duplicate", archive: [row("target")], diff --git a/src/node/services/historyService.ts b/src/node/services/historyService.ts index b173a1cab0..b09f5a16b1 100644 --- a/src/node/services/historyService.ts +++ b/src/node/services/historyService.ts @@ -3,7 +3,10 @@ import { HISTORY_PROVENANCE_MAX_RECEIPT_BYTES, invalidateHistoryAppendProvenance, } from "./historyAppendProvenance"; -import { SESSION_HISTORY_MAX_SCAN_BYTES } from "@/common/constants/contextBudget"; +import { + SESSION_HISTORY_MAX_SCAN_BYTES, + SESSION_HISTORY_MAX_LINE_BYTES, +} from "@/common/constants/contextBudget"; import { hasRawResetMarker, hasAmbiguousResetKeys, @@ -2657,12 +2660,20 @@ export class HistoryService { }> { const raw = (await this.readExistingFileBytes(filePath)) ?? Buffer.alloc(0); const rows = splitHistoryLines(raw).map((line) => { - const text = line.toString("utf8"); + // Match the provider scanner's row budget without the JSONL delimiter. + const content = line.at(-1) === 10 ? line.subarray(0, -1) : line; + const text = content.toString("utf8"); return { raw: line, message: this.parseMessages(text, filePath, (value) => isReadableHistoryMessage(value) && - !(hasRawResetMarker(text) && hasAmbiguousResetKeys(text)) + !(hasRawResetMarker(text) && hasAmbiguousResetKeys(text)) && + // Oversized rows use the provider scanner's token probe, even when + // intervening bytes prevent a contiguous raw reset marker match. + !( + content.length > SESSION_HISTORY_MAX_LINE_BYTES && + hasUnreadableHistoryResetEvidence([line]) + ) ? normalizeLegacyMuxMetadata(value) : null )[0], From b46048dafb8a4a5eb72bbf3740ffef2c01acbbf6 Mon Sep 17 00:00:00 2001 From: Thomas Kosiewski Date: Tue, 8 Sep 2026 19:31:33 +0200 Subject: [PATCH 04/10] =?UTF-8?q?=F0=9F=A4=96=20tests:=20keep=20shared=20r?= =?UTF-8?q?easoning=20coverage=20in=20its=20owning=20layer?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Remove the duplicated helper test now supplied by F1; retain F2's deletion-specific reasoning and oversized-reset regressions. The final three-history-file candidate passes 395 affected tests and full static checks. Signed-off-by: Thomas Kosiewski --- _Generated with `xum` • Model: `unavailable` • Thinking: `unavailable` • Cost: `$unavailable`_ Change-Id: I1258af7cb17d7024973bc372a57c2f9d5f79f414 --- .../utils/messages/compactionBoundary.test.ts | 20 ------------------- 1 file changed, 20 deletions(-) diff --git a/src/common/utils/messages/compactionBoundary.test.ts b/src/common/utils/messages/compactionBoundary.test.ts index b125ed1a32..dc0230a882 100644 --- a/src/common/utils/messages/compactionBoundary.test.ts +++ b/src/common/utils/messages/compactionBoundary.test.ts @@ -146,26 +146,6 @@ describe("findLatestCompactionBoundaryIndex", () => { }); describe("context boundary helpers", () => { - it("opts into reasoning eligibility without admitting rejected or reset rows", () => { - const reasoning = createMuxMessage("reasoning", "assistant", ""); - reasoning.parts = [{ type: "reasoning", text: "Provider reasoning" }]; - expect(hasProviderEligibleMessages([reasoning])).toBe(false); - expect(hasProviderEligibleMessages([reasoning], { preserveReasoningOnly: true })).toBe(true); - for (const metadata of [ - { contextBudgetRejected: true }, - { contextBoundaryKind: "reset" as const }, - ]) { - expect( - hasProviderEligibleMessages([{ ...reasoning, metadata }], { preserveReasoningOnly: true }) - ).toBe(false); - } - expect( - hasProviderEligibleMessages([createMuxMessage("empty", "assistant", "")], { - preserveReasoningOnly: true, - }) - ).toBe(false); - }); - it("recognizes context reset boundaries as latest context boundary", () => { const messages = [ createMuxMessage("u0", "user", "before"), From 85b85887a18bca829d7d46899ca263a3ac426de8 Mon Sep 17 00:00:00 2001 From: Thomas Kosiewski Date: Tue, 8 Sep 2026 19:59:52 +0200 Subject: [PATCH 05/10] =?UTF-8?q?=F0=9F=A4=96=20tests:=20preserve=20exact?= =?UTF-8?q?=20rollback=20fixture=20under=20deletion=20fencing?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Restore captured history bytes to model completed exact rollback in the inherited pending protocol test. Generic deletion now advances the generation; retain the same-generation boundary assertions instead of weakening them. Validation: 454 combined tests, full static checks, and independent review pass. Signed-off-by: Thomas Kosiewski --- _Generated with `xum` • Model: `unavailable` • Thinking: `unavailable` • Cost: `$unavailable`_ Change-Id: Ic1dcca041b9c92576c7f36170433a9689bf6b532 --- src/node/services/compactionPendingState.test.ts | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/src/node/services/compactionPendingState.test.ts b/src/node/services/compactionPendingState.test.ts index eba2f7660f..cb49a0fa00 100644 --- a/src/node/services/compactionPendingState.test.ts +++ b/src/node/services/compactionPendingState.test.ts @@ -434,6 +434,8 @@ describe("unactivated compaction pending-file protocol", () => { .getContinuousCompactionJournal(workspaceId) .captureGeneration(); const b = await prepare("b"); + const historyPath = path.join(h.config.sessionsDir, workspaceId, CHAT_FILE_NAME); + const beforeHeartbeat = await fs.readFile(historyPath); expect( ( await h.historyService.appendToHistory( @@ -446,10 +448,10 @@ describe("unactivated compaction pending-file protocol", () => { ) ).success ).toBe(true); - // The ordered fixture supplies an already-completed deletion; the real adapter's - // exact-delete proof is tested separately. A later boundary cannot revoke that fact. - const deleted = await h.historyService.deleteMessage(workspaceId, "b"); - expect(deleted.success).toBe(true); + // Model an already-completed exact rollback with real history bytes. Generic deletion + // advances the generation and would hide the same-generation boundary regression here; + // production activation owns the exact rollback transaction and its proof. + await fs.writeFile(historyPath, beforeHeartbeat); const foreign = new HistoryService(h.config); if (replacement === "unreadable-reset") boundaryOverride = { kind: "unreadable-reset" }; if (replacement === "identified") { @@ -471,7 +473,7 @@ describe("unactivated compaction pending-file protocol", () => { ); const expected = replacement === "none" ? ["/legacy.ts"] : undefined; expect((await restart().load(() => true))?.attachments.readFiles).toEqual(expected); - expect(await store.rollback(b, () => deleted.success)).toBe(true); + expect(await store.rollback(b, () => true)).toBe(true); expect((await restart().load(() => true))?.attachments.readFiles).toEqual(expected); } ); From 526669eaedbf6c8e7223a24ae67bf12822565107 Mon Sep 17 00:00:00 2001 From: Thomas Kosiewski Date: Tue, 8 Sep 2026 20:34:11 +0200 Subject: [PATCH 06/10] =?UTF-8?q?=F0=9F=A4=96=20fix:=20fence=20readable=20?= =?UTF-8?q?reset=20floors=20and=20protect=20active=20deletion=20identity?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Include readable malformed-role reset floors only in deletion classification. Preserve protected active IDs so an undeletable active target cannot fall through to an archived duplicate. Validation: 482 affected tests, full static checks, and independent review pass. Signed-off-by: Thomas Kosiewski --- _Generated with `xum` • Model: `unavailable` • Thinking: `unavailable` • Cost: `$unavailable`_ Change-Id: Ib47b810dcdcfc9403e4abdcab0616ce182ba8c97 --- src/node/services/historyScanner.ts | 24 ++++-- src/node/services/historyService.test.ts | 100 +++++++++++++++++++++++ src/node/services/historyService.ts | 34 +++++--- 3 files changed, 138 insertions(+), 20 deletions(-) diff --git a/src/node/services/historyScanner.ts b/src/node/services/historyScanner.ts index 044115d1cf..8e7ba2714e 100644 --- a/src/node/services/historyScanner.ts +++ b/src/node/services/historyScanner.ts @@ -205,7 +205,8 @@ type ProviderHistoryStart = async function findProviderHistoryStart( handle: fs.FileHandle, fileSize: number, - skip: number + skip: number, + includeReadableResetFloor: boolean ): Promise { const probe: HistoryResetProbe = { resetProbe: "", resetStage: 0, possibleReset: false }; let parts: Buffer[] = []; @@ -234,7 +235,8 @@ async function findProviderHistoryStart( const durableBoundary = message !== null && isDurableContextBoundaryMarker(message); if (isManualHistoryReset(message, probe.possibleReset)) { // Retain readable reset markers, but never count them as skippable boundaries. - if (durableBoundary) return start; + // Deletion also needs readable malformed-role floors that provider requests exclude. + if (durableBoundary || (includeReadableResetFloor && message)) return start; // A fragmented marker may end several rows to the right of the key that // completed recognition. Never return any of that unreadable evidence. return unreadableRunEnd ?? rowEnd; @@ -279,7 +281,8 @@ async function findProviderHistoryStart( async function readHistoryProjectionFromLatestBoundary( paths: Record, skip: number, - project: (value: unknown) => Row | null + project: (value: unknown) => Row | null, + includeReadableResetFloor = false ): Promise { assert(Number.isSafeInteger(skip) && skip >= 0, "provider boundary skip must be non-negative"); const files = new Map(); @@ -303,7 +306,7 @@ async function readHistoryProjectionFromLatestBoundary( ): Promise => { const file = files.get(artifact); return file - ? findProviderHistoryStart(file.handle, file.size, skipCount) + ? findProviderHistoryStart(file.handle, file.size, skipCount, includeReadableResetFloor) : Promise.resolve({ kind: "exhausted", oldestBoundary: null, boundaryCount: 0 }); }; const readTail = async (artifact: HistoryArtifact, offset: number): Promise => { @@ -361,10 +364,17 @@ async function readHistoryProjectionFromLatestBoundary( export function readProviderHistoryFromLatestBoundary( paths: Record, - skip: number + skip: number, + options?: { + /** Mutation classification needs the excluded floor itself; provider requests leave this off. */ + includeReadableResetFloor?: boolean; + } ): Promise { - return readHistoryProjectionFromLatestBoundary(paths, skip, (value) => - isReadableHistoryMessage(value) ? normalizeLegacyMuxMetadata(value) : null + return readHistoryProjectionFromLatestBoundary( + paths, + skip, + (value) => (isReadableHistoryMessage(value) ? normalizeLegacyMuxMetadata(value) : null), + options?.includeReadableResetFloor ); } diff --git a/src/node/services/historyService.test.ts b/src/node/services/historyService.test.ts index d5eb6ca680..19e05b89bd 100644 --- a/src/node/services/historyService.test.ts +++ b/src/node/services/historyService.test.ts @@ -1111,6 +1111,106 @@ describe("HistoryService", () => { return { chatPath, archivePath, bytes }; } + it.each( + ["user", "system"].flatMap((role) => + ["single", "batch", "archive"].flatMap((method) => + [ + { newerFloor: "none", tail: false }, + { newerFloor: "none", tail: true }, + { newerFloor: "boundary", tail: true }, + { newerFloor: "raw reset", tail: true }, + ].map((scenario) => ({ role, method, ...scenario })) + ) + ) + )( + "$method deletion fences a readable $role reset floor (newer: $newerFloor, tail: $tail)", + async ({ role, method, newerFloor, tail }) => { + assert(role === "user" || role === "system"); + const floor: MuxMessage = { ...reset(), role }; + const source = [row("old"), floor]; + const suffix = [ + ...(newerFloor === "boundary" ? [boundary()] : []), + ...(tail ? [row("fresh")] : []), + ]; + const { chatPath, archivePath, bytes } = await seedDeletionHistory( + method === "archive" ? source : [], + [...(method === "archive" ? [] : source), ...suffix] + ); + if (newerFloor === "raw reset") { + await fs.writeFile( + chatPath, + Buffer.concat([ + bytes(method === "archive" ? [] : source), + Buffer.from('{"metadata":{"contextBoundaryKind":"reset"}\n'), + bytes(suffix), + ]) + ); + } + const before = await service.getHistoryFromLatestBoundary(ws); + assert(before.success); + expect(before.data.map((message) => message.id)).toEqual( + suffix.map((message) => message.id) + ); + const { store, receipt } = await capturePublication(); + const untouchedPath = method === "archive" ? chatPath : archivePath; + const untouched = await fs.readFile(untouchedPath); + const result = + method === "batch" + ? await service.deleteMessages(ws, [floor.id]) + : await service.deleteMessage(ws, floor.id); + expect(result.success).toBe(true); + expect(await fs.readFile(untouchedPath)).toEqual(untouched); + const after = await service.getHistoryFromLatestBoundary(ws); + assert(after.success); + expect(after.data.map((message) => message.id)).toEqual([ + ...(newerFloor === "none" ? ["old"] : []), + ...suffix.map((message) => message.id), + ]); + const changed = newerFloor === "none"; + expect((await store.captureGeneration()) !== receipt.publicationGeneration).toBe(changed); + const foreign = new HistoryService(config).getContinuousCompactionJournal(ws); + expect( + (await foreign.recordFallbackPrefix( + receipt, + { modelString: "anthropic:next", prefix }, + () => true + )) !== null + ).toBe(!changed); + } + ); + + it.each( + ["contiguous", "token-separated"].flatMap((variant) => + [false, true].map((readableDuplicate) => ({ variant, readableDuplicate })) + ) + )( + "single deletion preserves an active protected $variant reset ID shared with archive (readable duplicate: $readableDuplicate)", + async ({ variant, readableDuplicate }) => { + const floor = { + ...(variant === "contiguous" ? reset() : createMuxMessage("reset", "assistant", "")), + contextBoundaryKind: 0, + padding: "x".repeat(SESSION_HISTORY_MAX_LINE_BYTES), + candidate: "reset", + }; + const placeholder = createMuxMessage(floor.id, "assistant", ""); + const fresh = row("fresh"); + const { chatPath, archivePath, bytes } = await seedDeletionHistory( + [row(floor.id)], + [floor, ...(readableDuplicate ? [placeholder] : []), fresh] + ); + const beforeChat = await fs.readFile(chatPath); + const beforeArchive = await fs.readFile(archivePath); + const { store, receipt } = await capturePublication(); + expect((await service.deleteMessage(ws, floor.id)).success).toBe(readableDuplicate); + expect(await fs.readFile(chatPath)).toEqual( + readableDuplicate ? bytes([floor, fresh]) : beforeChat + ); + expect(await fs.readFile(archivePath)).toEqual(beforeArchive); + expect(await store.captureGeneration()).toBe(receipt.publicationGeneration); + expect(await store.read()).toEqual(receipt); + } + ); + it.each( ["single", "batch", "archive"].flatMap((method) => [0, 1].map((extra) => ({ method, extra }))) )( diff --git a/src/node/services/historyService.ts b/src/node/services/historyService.ts index b09f5a16b1..b31901e5f6 100644 --- a/src/node/services/historyService.ts +++ b/src/node/services/historyService.ts @@ -19,6 +19,7 @@ import { type BoundedHistoryScanOptions, } from "./historyScanner"; import { getRequestPreludeMessageIds } from "@/common/utils/messages/requestPrelude"; +import { isManualHistoryReset } from "@/common/utils/messages/contextWindows"; import { createContextBudgetRejectedMessage } from "@/common/utils/messages/contextBudgetRejection"; import * as path from "path"; import { createHash, randomUUID } from "node:crypto"; @@ -99,6 +100,7 @@ interface HistoryTruncateTransaction extends HistoryTruncateHashes { interface HistoryRewriteRow { raw: Buffer; message: MuxMessage | undefined; + protectedMessageId?: string; } function splitHistoryLines(raw: Buffer): Buffer[] { @@ -2663,20 +2665,21 @@ export class HistoryService { // Match the provider scanner's row budget without the JSONL delimiter. const content = line.at(-1) === 10 ? line.subarray(0, -1) : line; const text = content.toString("utf8"); - return { - raw: line, - message: this.parseMessages(text, filePath, (value) => - isReadableHistoryMessage(value) && - !(hasRawResetMarker(text) && hasAmbiguousResetKeys(text)) && + const parsed = this.parseMessages(text, filePath, (value) => + isReadableHistoryMessage(value) ? normalizeLegacyMuxMetadata(value) : null + )[0]; + const protectedReset = + parsed !== undefined && + ((hasRawResetMarker(text) && hasAmbiguousResetKeys(text)) || // Oversized rows use the provider scanner's token probe, even when // intervening bytes prevent a contiguous raw reset marker match. - !( - content.length > SESSION_HISTORY_MAX_LINE_BYTES && - hasUnreadableHistoryResetEvidence([line]) - ) - ? normalizeLegacyMuxMetadata(value) - : null - )[0], + (content.length > SESSION_HISTORY_MAX_LINE_BYTES && + hasUnreadableHistoryResetEvidence([line]))); + return { + raw: line, + message: protectedReset ? undefined : parsed, + // Preserve identity for active-first lookup even when the raw floor cannot be rewritten. + protectedMessageId: protectedReset ? parsed?.id : undefined, }; }); return { rows, messages: rows.flatMap((row) => (row.message ? [row.message] : [])) }; @@ -3495,7 +3498,8 @@ export class HistoryService { chat: this.getChatHistoryPath(workspaceId), archive: this.getChatArchivePath(workspaceId), }, - 0 + 0, + { includeReadableResetFloor: true } ); // Archive fallback excludes all newer chat rows. Matching IDs across files // would conflate retained duplicates with occurrences this write removes. @@ -3506,6 +3510,7 @@ export class HistoryService { .filter((message) => deletedIds.has(message.id)); if ( tailCutChangesProviderContext(removed) || + removed.some((message) => isManualHistoryReset(message)) || deletionCreatesRawReset(rows, deletedIds, new Set(removed)) ) { // Call only after serialization admits the rewrite, immediately before @@ -3527,6 +3532,9 @@ export class HistoryService { const filteredMessages = messages.filter((msg) => msg.id !== messageId); if (filteredMessages.length === messages.length) { + if (rows.some((row) => row.protectedMessageId === messageId)) { + return Err(`Message with ID ${messageId} is protected reset evidence in active history`); + } // Not in the active epoch — the row may live in the sealed archive // (rare: cleanup paths almost always target recent rows). const { rows: archiveRows, messages: archiveMessages } = await this.readHistoryForRewrite( From e0760c8e731498221f0bd1fbc51564c7d21a48ed Mon Sep 17 00:00:00 2001 From: Thomas Kosiewski Date: Tue, 8 Sep 2026 21:11:30 +0200 Subject: [PATCH 07/10] =?UTF-8?q?=F0=9F=A4=96=20fix:=20retain=20protected?= =?UTF-8?q?=20history=20rows=20in=20sequence=20accounting?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Keep parsed identity and sequence metadata separate from transformable messages. Preserve occupied sequences across retained raw rows during deletion, truncation, and workspace rename. Seven counter regressions reproduced; ordinary appends already refreshed from disk, so actual sequence reuse was not reproduced. 397 history tests and independent review pass; final combined validation follows parent integration. Signed-off-by: Thomas Kosiewski --- _Generated with `xum` • Model: `unavailable` • Thinking: `unavailable` • Cost: `$unavailable`_ Change-Id: I671021c59473e8f7012a776866510aa2f0fd97a4 --- src/node/services/historyService.test.ts | 90 ++++++++++++++++++++++++ src/node/services/historyService.ts | 42 ++++++++--- 2 files changed, 121 insertions(+), 11 deletions(-) diff --git a/src/node/services/historyService.test.ts b/src/node/services/historyService.test.ts index 19e05b89bd..e03e5f1317 100644 --- a/src/node/services/historyService.test.ts +++ b/src/node/services/historyService.test.ts @@ -1258,6 +1258,96 @@ describe("HistoryService", () => { } ); + it.each( + [ + "single delete", + "batch delete", + "archive delete", + "active truncation", + "archive truncation", + "prefix truncation", + "rename", + "protected-only rename", + "clear", + ].flatMap((method) => ["chat", "archive"].map((artifact) => ({ method, artifact }))) + )( + "$method accounts for a retained protected sequence in $artifact after restart", + async ({ method, artifact }) => { + const target = row("target"); + const fresh = row("fresh"); + const floor = { + ...createMuxMessage("floor", "assistant", ""), + contextBoundaryKind: 0, + padding: "x".repeat(SESSION_HISTORY_MAX_LINE_BYTES), + candidate: "reset", + }; + const archivedTarget = method.startsWith("archive"); + const protectedOnly = method === "protected-only rename"; + const archive = [ + ...(archivedTarget ? [target] : []), + ...(artifact === "archive" ? [floor] : []), + ]; + const chat = [ + ...(!protectedOnly && !archivedTarget ? [target] : []), + ...(artifact === "chat" ? [floor] : []), + ...(protectedOnly ? [] : [fresh]), + ]; + const { chatPath, archivePath, bytes } = await seedDeletionHistory(archive, chat); + floor.metadata = { ...floor.metadata, historySequence: 100 }; + await fs.writeFile( + artifact === "chat" ? chatPath : archivePath, + bytes(artifact === "chat" ? chat : archive) + ); + const floorBytes = bytes([floor]); + const restarted = new HistoryService(config); + let nextWorkspace = ws; + const result = + method === "single delete" || method === "archive delete" + ? await restarted.deleteMessage(ws, target.id) + : method === "batch delete" + ? await restarted.deleteMessages(ws, [target.id]) + : method === "active truncation" || method === "archive truncation" + ? await restarted.truncateAfterMessage(ws, target.id, { keepTargetMessage: true }) + : method === "prefix truncation" + ? await restarted.truncateHistory(ws, 0.1) + : method === "clear" + ? await restarted.clearHistory(ws) + : await (async () => { + nextWorkspace = `${ws}-renamed`; + await fs.rename( + path.dirname(chatPath), + path.join(config.sessionsDir, nextWorkspace) + ); + return restarted.migrateWorkspaceId(ws, nextWorkspace); + })(); + expect(result.success).toBe(true); + const retainedBytes = Buffer.concat( + await Promise.all( + ["chat.jsonl", "chat-archive.jsonl"].map((file) => + fs + .readFile(path.join(config.sessionsDir, nextWorkspace, file)) + .catch(() => Buffer.alloc(0)) + ) + ) + ); + expect(retainedBytes.includes(floorBytes)).toBe(method !== "clear"); + // The rewrite must publish a counter consistent with retained bytes immediately; + // the append path's disk refresh must not be needed to repair its bookkeeping. + const counters = restarted as unknown as { sequenceCounters: Map }; + const cachedNext = counters.sequenceCounters.get(nextWorkspace); + const next = row("next"); + expect((await restarted.appendToHistory(nextWorkspace, next)).success).toBe(true); + expect(next.metadata?.historySequence).toBe(method === "clear" ? 0 : 101); + const reloaded = new HistoryService(config); + const later = row("later"); + expect((await reloaded.appendToHistory(nextWorkspace, later)).success).toBe(true); + expect(later.metadata?.historySequence).toBe(method === "clear" ? 1 : 102); + if (method !== "archive delete") { + expect(cachedNext).toBe(method === "clear" ? 0 : 101); + } + } + ); + it.each(["single", "batch", "archive", "partial"])( "%s deletion preserves oversized token-separated raw reset evidence", async (method) => { diff --git a/src/node/services/historyService.ts b/src/node/services/historyService.ts index b31901e5f6..2bfc8c460f 100644 --- a/src/node/services/historyService.ts +++ b/src/node/services/historyService.ts @@ -100,7 +100,7 @@ interface HistoryTruncateTransaction extends HistoryTruncateHashes { interface HistoryRewriteRow { raw: Buffer; message: MuxMessage | undefined; - protectedMessageId?: string; + protectedMessage?: MuxMessage; } function splitHistoryLines(raw: Buffer): Buffer[] { @@ -2678,13 +2678,23 @@ export class HistoryService { return { raw: line, message: protectedReset ? undefined : parsed, - // Preserve identity for active-first lookup even when the raw floor cannot be rewritten. - protectedMessageId: protectedReset ? parsed?.id : undefined, + // Keep identity and sequence accounting even when the raw floor cannot be rewritten. + protectedMessage: protectedReset ? parsed : undefined, }; }); return { rows, messages: rows.flatMap((row) => (row.message ? [row.message] : [])) }; } + private getProtectedRewriteMaxSequence(rows: readonly HistoryRewriteRow[]): number { + // These parsed rows survive every partial rewrite as raw bytes, even beyond a cut. + // Their sequences remain occupied regardless of whether they are transformable. + return ( + this.getNewestHistorySequence( + rows.flatMap((row) => (row.protectedMessage ? [row.protectedMessage] : [])) + ) ?? -1 + ); + } + private serializeHistoryRewrite( rows: readonly HistoryRewriteRow[], workspaceId: string, @@ -3452,7 +3462,7 @@ export class HistoryService { return max; } return sequence > max ? sequence : max; - }, -1); + }, this.getProtectedRewriteMaxSequence(rows)); const archiveMaxSeq = await this.getArchiveTailMaxSequence(workspaceId); const nextSeq = Math.max(maxSeq, archiveMaxSeq) + 1; assert( @@ -3532,7 +3542,7 @@ export class HistoryService { const filteredMessages = messages.filter((msg) => msg.id !== messageId); if (filteredMessages.length === messages.length) { - if (rows.some((row) => row.protectedMessageId === messageId)) { + if (rows.some((row) => row.protectedMessage?.id === messageId)) { return Err(`Message with ID ${messageId} is protected reset evidence in active history`); } // Not in the active epoch — the row may live in the sealed archive @@ -3590,7 +3600,7 @@ export class HistoryService { } return seq > max ? seq : max; - }, -1); + }, this.getProtectedRewriteMaxSequence(rows)); // Sealed archive rows keep their sequences across active-file deletes. // Without this floor, deleting the last sequenced active row in a fresh // process would cache a counter below archived rows and reuse their @@ -3703,7 +3713,7 @@ export class HistoryService { } return seq > max ? seq : max; - }, -1); + }, this.getProtectedRewriteMaxSequence(rows)); // Sealed archive rows keep their sequences across an active-epoch // truncation. When the truncation empties the active file, floor the // counter with the archive max so new appends can never reuse archived @@ -3776,6 +3786,10 @@ export class HistoryService { // Update sequence counter to continue from where we truncated. // Self-healing read path: skip malformed persisted historySequence values. + const protectedMaxSeq = this.getProtectedRewriteMaxSequence([ + ...archiveRows, + ...activeEpochRows, + ]); const maxTruncatedSeq = truncatedMessages.reduce((max, msg) => { const seq = msg.metadata?.historySequence; if (seq === undefined) { @@ -3795,7 +3809,7 @@ export class HistoryService { } return seq > max ? seq : max; - }, -1); + }, protectedMaxSeq); const nextSeq = maxTruncatedSeq + 1; assert( isNonNegativeInteger(nextSeq), @@ -4025,6 +4039,10 @@ export class HistoryService { // Update sequence counter to continue from where we are. // Self-healing read path: skip malformed persisted historySequence values. + const protectedMaxSeq = this.getProtectedRewriteMaxSequence([ + ...archiveRows, + ...chatRows, + ]); const maxRemainingSeq = remainingMessages.reduce((max, msg) => { const seq = msg.metadata?.historySequence; if (seq === undefined) { @@ -4044,7 +4062,7 @@ export class HistoryService { } return seq > max ? seq : max; - }, -1); + }, protectedMaxSeq); const nextSeq = maxRemainingSeq + 1; assert( isNonNegativeInteger(nextSeq), @@ -4112,12 +4130,15 @@ export class HistoryService { const { rows, messages } = await this.readHistoryForRewrite( this.getChatHistoryPath(newWorkspaceId) ); + const oldCounter = Math.max( + this.sequenceCounters.get(oldWorkspaceId) ?? 0, + this.getProtectedRewriteMaxSequence([...archiveRows, ...rows]) + 1 + ); if (messages.length === 0) { // No active messages to migrate, just transfer the sequence counter. // Floor it with the archive max: an archive-only session (active file // deleted/truncated) renamed in a fresh process has no cached counter, // and seeding 0 would reuse archived historySequence values. - const oldCounter = this.sequenceCounters.get(oldWorkspaceId) ?? 0; const archiveFloor = (await this.getArchiveTailMaxSequence(newWorkspaceId)) + 1; this.sequenceCounters.set(newWorkspaceId, Math.max(oldCounter, archiveFloor)); this.sequenceCounters.delete(oldWorkspaceId); @@ -4132,7 +4153,6 @@ export class HistoryService { await writeFileAtomic(newHistoryPath, historyEntries); // Transfer sequence counter to new workspace ID - const oldCounter = this.sequenceCounters.get(oldWorkspaceId) ?? 0; this.sequenceCounters.set(newWorkspaceId, oldCounter); this.sequenceCounters.delete(oldWorkspaceId); From f0117331dd98b97503ad630eb294ecc914d58199 Mon Sep 17 00:00:00 2001 From: Thomas Kosiewski Date: Tue, 8 Sep 2026 22:08:02 +0200 Subject: [PATCH 08/10] =?UTF-8?q?=F0=9F=A4=96=20tests:=20inject=20failures?= =?UTF-8?q?=20into=20staged=20generation=20writes?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Generation publication now writes a staging sibling before renaming it into place. Target that workspace's generation staging prefix while retaining exact history-path matching, and assert that each intended failure was injected. Signed-off-by: Thomas Kosiewski --- _Generated with `xum` • Model: `unavailable` • Thinking: `unavailable` • Cost: `$unavailable`_ Change-Id: Iaaee0b2384739d671476ba02898ab694d059d598 --- src/node/services/historyService.test.ts | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/src/node/services/historyService.test.ts b/src/node/services/historyService.test.ts index e03e5f1317..3ffb40cddc 100644 --- a/src/node/services/historyService.test.ts +++ b/src/node/services/historyService.test.ts @@ -1831,10 +1831,19 @@ describe("HistoryService", () => { ? archivePath : chatPath; const atomic = atomicWrite.default; + let injected = false; const failure = spyOn(atomicWrite, "default").mockImplementation( new Proxy(atomic, { apply(target, _thisArg, args: Parameters) { - if (args[0] === failedPath) return Promise.reject(new Error("disk unavailable")); + // Generation publication writes a staging sibling before renaming it into place. + const matches = + stage === "generation" + ? typeof args[0] === "string" && args[0].startsWith(`${failedPath}.continuous-`) + : args[0] === failedPath; + if (matches) { + injected = true; + return Promise.reject(new Error("disk unavailable")); + } return target(...args); }, }) @@ -1846,6 +1855,7 @@ describe("HistoryService", () => { : method === "batch" ? await service.deleteMessages(ws, [target.id]) : await service.deleteMessage(ws, target.id); + expect(injected).toBe(true); expect(result.success).toBe(false); expect(await fs.readFile(chatPath)).toEqual(beforeChat); expect(await fs.readFile(archivePath)).toEqual(beforeArchive); From b82705b1febee73d5a6c1dd0aad94686aa96da2f Mon Sep 17 00:00:00 2001 From: Thomas Kosiewski Date: Tue, 8 Sep 2026 23:15:29 +0200 Subject: [PATCH 09/10] =?UTF-8?q?=F0=9F=A4=96=20ci:=20serialize=20Linux=20?= =?UTF-8?q?Electron=20tests=20to=20preserve=20native=20focus?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Parallel Electron tests share one Xvfb display and clipboard. Run one Linux worker so another test cannot steal native focus during clipboard permission checks or self-closing authentication popup input. Keep every test and security check unchanged. Native probes reproduced three focus-related clipboard failures in four competing runs, including focus loss during awaited activation. An unfocused popup reproduced the same closed-target click error while authentication and renderer identity remained healthy. The original remote-connection file passes all three tests with one worker. The cost is a slower Linux suite; approximately fifteen minutes from summed prior CI attempt durations is an estimate, not a measured serial runtime. Signed-off-by: Thomas Kosiewski --- _Generated with `xum` • Model: `unavailable` • Thinking: `unavailable` • Cost: `$unavailable`_ Change-Id: I8703782a9f36966f8b257b29ab9c189ea1afc06e --- .github/workflows/pr.yml | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/.github/workflows/pr.yml b/.github/workflows/pr.yml index 1adbd2c2b3..3fd0926f1b 100644 --- a/.github/workflows/pr.yml +++ b/.github/workflows/pr.yml @@ -400,7 +400,8 @@ jobs: timeout-minutes: 10 - name: Run tests if: matrix.os == 'linux' - run: xvfb-run -a make test-e2e + # Native focus and clipboard are shared by every Electron app on this display. + run: xvfb-run -a make test-e2e PLAYWRIGHT_ARGS="--workers=1" env: ELECTRON_DISABLE_SANDBOX: 1 - name: Run tests From 71e8e36221d785db6aa41cc66f5ed08e5ca4d988 Mon Sep 17 00:00:00 2001 From: Thomas Kosiewski Date: Tue, 8 Sep 2026 23:45:37 +0200 Subject: [PATCH 10/10] =?UTF-8?q?=F0=9F=A4=96=20fix:=20refuse=20protected?= =?UTF-8?q?=20active=20targets=20before=20archive=20truncation?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit An oversized or ambiguous active reset row is excluded from transformable history. Truncating its ID could therefore select an older archived duplicate, remove unrelated active messages, and advance publication generation while retaining the actual protected target. Apply the existing protected-active refusal before truncation falls back to the archive. Readable active duplicates still take precedence; an unrelated protected ID still permits legitimate archive truncation. Twelve regressions cover both row forms and keep/remove target modes, preserving raw file bytes and publication state on refusal. All 518 affected tests and full static checks pass. Independent review approved the bounded guard and regression matrix. Signed-off-by: Thomas Kosiewski --- _Generated with `xum` • Model: `unavailable` • Thinking: `unavailable` • Cost: `$unavailable`_ Change-Id: I7d89262c7fe08d1d230a8c5b83370c2de8e5a273 --- src/node/services/historyService.test.ts | 72 ++++++++++++++++++++++++ src/node/services/historyService.ts | 6 ++ 2 files changed, 78 insertions(+) diff --git a/src/node/services/historyService.test.ts b/src/node/services/historyService.test.ts index 3ffb40cddc..4d4ef681bf 100644 --- a/src/node/services/historyService.test.ts +++ b/src/node/services/historyService.test.ts @@ -1211,6 +1211,78 @@ describe("HistoryService", () => { } ); + it.each( + ["oversized", "ambiguous"].flatMap((variant) => + [false, true].flatMap((keepTargetMessage) => + ["protected", "readable", "archive"].map((targetKind) => ({ + variant, + keepTargetMessage, + targetKind, + })) + ) + ) + )( + "truncation keeps protected active identity ($variant, $targetKind, keep=$keepTargetMessage)", + async ({ variant, keepTargetMessage, targetKind }) => { + const target = row("target"); + const floor = { + ...createMuxMessage( + targetKind === "archive" ? "other-protected" : target.id, + "assistant", + "", + variant === "oversized" ? { contextBoundaryKind: CONTEXT_BOUNDARY_KINDS.RESET } : {} + ), + ...(variant === "oversized" && { padding: "x".repeat(SESSION_HISTORY_MAX_LINE_BYTES) }), + }; + const placeholder = createMuxMessage(target.id, "assistant", ""); + const fresh = row("fresh"); + const archive = [row("archive-before"), target, row("archive-after")]; + const activeTail = [...(targetKind === "readable" ? [placeholder] : []), fresh]; + const { chatPath, archivePath, bytes } = await seedDeletionHistory(archive, [ + floor, + ...activeTail, + ]); + // Preserve duplicate metadata keys as raw evidence; parsing alone loses the reset. + const floorLine = messageLine(ws, floor) + "\n"; + const rawFloor = Buffer.from( + variant === "ambiguous" + ? floorLine.replace( + '"metadata":', + '"metadata":{"contextBoundaryKind":"reset"},"metadata":' + ) + : floorLine + ); + await fs.writeFile(chatPath, Buffer.concat([rawFloor, bytes(activeTail)])); + const beforeChat = await fs.readFile(chatPath); + const beforeArchive = await fs.readFile(archivePath); + const { store, receipt } = await capturePublication(); + const result = await service.truncateAfterMessage(ws, target.id, { keepTargetMessage }); + + expect(result.success).toBe(targetKind !== "protected"); + if (targetKind === "protected") { + expect(await fs.readFile(chatPath)).toEqual(beforeChat); + expect(await fs.readFile(archivePath)).toEqual(beforeArchive); + expect(await store.captureGeneration()).toBe(receipt.publicationGeneration); + expect(await store.read()).toEqual(receipt); + } else { + const retainedArchive = + targetKind === "archive" ? archive.slice(0, keepTargetMessage ? 2 : 1) : []; + const retainedActive = + targetKind === "readable" && keepTargetMessage ? [placeholder] : []; + expect(await fs.readFile(chatPath)).toEqual( + Buffer.concat([bytes(retainedArchive), rawFloor, bytes(retainedActive)]) + ); + if (targetKind === "archive") { + const archiveStat = await fs.stat(archivePath).catch((error: unknown) => error); + expect(archiveStat).toMatchObject({ code: "ENOENT" }); + } else { + expect(await fs.readFile(archivePath)).toEqual(beforeArchive); + } + expect(await store.captureGeneration()).not.toBe(receipt.publicationGeneration); + } + } + ); + it.each( ["single", "batch", "archive"].flatMap((method) => [0, 1].map((extra) => ({ method, extra }))) )( diff --git a/src/node/services/historyService.ts b/src/node/services/historyService.ts index 2bfc8c460f..6d8cc057d1 100644 --- a/src/node/services/historyService.ts +++ b/src/node/services/historyService.ts @@ -3653,6 +3653,12 @@ export class HistoryService { const keepTargetMessage = options?.keepTargetMessage === true; if (messageIndex === -1) { + // A protected active target must not redirect an edit/fork to an older duplicate. + if (rows.some((row) => row.protectedMessage?.id === messageId)) { + return Err( + `Message with ID ${messageId} is protected reset evidence in active history` + ); + } // Editing/forking from a pre-boundary message: the target lives in the // sealed archive. Everything after the cut (the archive tail AND the // entire active epoch) is discarded, so collapse the remainder back