From 258b38981a6b9837d1b2b1fe785a349b4ce63689 Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Fri, 7 Aug 2026 18:51:24 +0000 Subject: [PATCH 01/36] =?UTF-8?q?=F0=9F=A4=96=20fix:=20clear=20stale=20usa?= =?UTF-8?q?ge=20state=20when=20history=20is=20rewritten=20to=20a=20fresh?= =?UTF-8?q?=20boundary?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Start Here (replaceHistory), /reset (resetContext), and /clear (truncateHistory) rewrite history to a fresh context boundary but left the session's in-memory lastUsageState from the previous stream intact. The next sendMessage fed that stale high usage into the on-send compaction check and synthesized a spurious /compact request against the already-cleared context. Clear the session usage state in all three paths; the next send re-seeds accurate usage from post-boundary history. --- _Generated with `mux` • Model: `anthropic:claude-fable-5` • Thinking: `xhigh`_ --- src/node/services/agentSession.ts | 10 ++ src/node/services/workspaceService.test.ts | 175 +++++++++++++++++++++ src/node/services/workspaceService.ts | 5 + 3 files changed, 190 insertions(+) diff --git a/src/node/services/agentSession.ts b/src/node/services/agentSession.ts index 9906c1a910..0d990d7fa8 100644 --- a/src/node/services/agentSession.ts +++ b/src/node/services/agentSession.ts @@ -3418,6 +3418,16 @@ export class AgentSession { }; } + /** + * Called when history is rewritten to a fresh boundary outside the compaction + * stream path (Start Here, /reset, /clear): stale usage would make the next + * send auto-compact the already-cleared context. sendMessage re-seeds accurate + * usage from post-boundary history. + */ + clearUsageState(): void { + this.lastUsageState = undefined; + } + /** * Persist a manual user message + emit a stream-error chat event when a * pre-stream gate (e.g. the unpriced-model budget gate) rejects a send. diff --git a/src/node/services/workspaceService.test.ts b/src/node/services/workspaceService.test.ts index 0738c1fe30..19be622c03 100644 --- a/src/node/services/workspaceService.test.ts +++ b/src/node/services/workspaceService.test.ts @@ -2,6 +2,9 @@ import { describe, expect, test, mock, beforeEach, afterEach, spyOn, type Mock } import { WorkspaceService, generateForkBranchName, generateForkTitle } from "./workspaceService"; import type { IdleCompactionOutcome } from "./idleCompactionService"; import type { AgentSession } from "./agentSession"; +import { createAgentSessionHarness } from "./agentSession.testHarness"; +import type { AutoCompactionUsageState } from "@/common/utils/compaction/autoCompactionCheck"; +import { createDisplayUsage } from "@/common/utils/tokens/displayUsage"; import { askUserQuestionManager } from "./askUserQuestionManager"; import { WorkspaceLifecycleHooks } from "./workspaceLifecycleHooks"; import { EventEmitter } from "events"; @@ -3832,6 +3835,21 @@ describe("WorkspaceService truncateHistory goal acknowledgment", () => { return { aiService, config, historyService, workspaceService, goalService, cleanup }; } + // Simulates the in-memory snapshot a previous stream left behind: + // 95k of gpt-4o's 128k window is past the 70% on-send compaction threshold. + function seedStaleHighContextUsage(session: AgentSession): void { + (session as unknown as { lastUsageState?: AutoCompactionUsageState }).lastUsageState = { + lastContextUsage: createDisplayUsage( + { inputTokens: 95_000, outputTokens: 200, totalTokens: 95_200 }, + "openai:gpt-4o" + ), + }; + } + + function getUsageState(session: AgentSession): AutoCompactionUsageState | undefined { + return (session as unknown as { lastUsageState?: AutoCompactionUsageState }).lastUsageState; + } + test("idle wait follows auto-retry startup into the resumed stream", async () => { const { workspaceService, cleanup } = await createServices(); const workspaceId = "idle-wait-auto-retry-starting"; @@ -4273,6 +4291,163 @@ describe("WorkspaceService truncateHistory goal acknowledgment", () => { } }); + test("start-here replacement clears stale usage so the next send does not auto-compact", async () => { + const { config, historyService, workspaceService, cleanup } = await createServices(); + const workspaceId = "start-here-clears-usage-state"; + const streamMessage = mock((..._args: unknown[]) => Promise.resolve(Ok(undefined))); + const harness = await createAgentSessionHarness({ + workspaceId, + config, + historyService, + aiServiceOverrides: { + streamMessage: streamMessage as unknown as AIService["streamMessage"], + }, + }); + try { + await config.addWorkspace("/tmp/start-here-usage-project", { + id: workspaceId, + name: workspaceId, + projectName: "start-here-usage-project", + projectPath: "/tmp/start-here-usage-project", + runtimeConfig: { type: "local" }, + }); + expect( + ( + await historyService.appendToHistory( + workspaceId, + createMuxMessage("pre-start-here-user", "user", "long conversation", {}) + ) + ).success + ).toBe(true); + expect( + ( + await historyService.appendToHistory( + workspaceId, + createMuxMessage("pre-start-here-assistant", "assistant", "long reply", { + model: "openai:gpt-4o", + contextUsage: { inputTokens: 95_000, outputTokens: 200, totalTokens: 95_200 }, + }) + ) + ).success + ).toBe(true); + + (workspaceService as unknown as { sessions: Map }).sessions.set( + workspaceId, + harness.session + ); + seedStaleHighContextUsage(harness.session); + + const replaceResult = await workspaceService.replaceHistory( + workspaceId, + createMuxMessage("start-here-summary", "assistant", "Start Here summary", { + compacted: "user", + }), + { mode: "append-compaction-boundary" } + ); + expect(replaceResult.success).toBe(true); + + const sendResult = await harness.session.sendMessage("follow-up after start here", { + model: "openai:gpt-4o", + agentId: "exec", + }); + expect(sendResult.success).toBe(true); + + const activeWindow = await historyService.getHistoryFromLatestBoundary(workspaceId); + expect(activeWindow.success).toBe(true); + const activeMessages = activeWindow.success ? activeWindow.data : []; + expect( + activeMessages.filter( + (message) => message.metadata?.muxMetadata?.type === "compaction-request" + ) + ).toHaveLength(0); + const followUp = activeMessages.find((message) => message.role === "user"); + expect(followUp?.parts[0]).toMatchObject({ + type: "text", + text: "follow-up after start here", + }); + expect(streamMessage).toHaveBeenCalledTimes(1); + } finally { + harness.session.dispose(); + await cleanup(); + } + }); + + test("context reset clears stale session usage state", async () => { + const { config, historyService, workspaceService, cleanup } = await createServices(); + const workspaceId = "context-reset-clears-usage-state"; + const harness = await createAgentSessionHarness({ workspaceId, config, historyService }); + try { + await config.addWorkspace("/tmp/context-reset-usage-project", { + id: workspaceId, + name: workspaceId, + projectName: "context-reset-usage-project", + projectPath: "/tmp/context-reset-usage-project", + runtimeConfig: { type: "local" }, + }); + expect( + ( + await historyService.appendToHistory( + workspaceId, + createMuxMessage("pre-reset-user", "user", "before reset", {}) + ) + ).success + ).toBe(true); + + (workspaceService as unknown as { sessions: Map }).sessions.set( + workspaceId, + harness.session + ); + seedStaleHighContextUsage(harness.session); + + expect(await workspaceService.resetContext(workspaceId)).toEqual({ + success: true, + data: "reset", + }); + + expect(getUsageState(harness.session)).toBeUndefined(); + } finally { + harness.session.dispose(); + await cleanup(); + } + }); + + test("full history clear clears stale session usage state", async () => { + const { config, historyService, workspaceService, cleanup } = await createServices(); + const workspaceId = "full-clear-clears-usage-state"; + const harness = await createAgentSessionHarness({ workspaceId, config, historyService }); + try { + await config.addWorkspace("/tmp/full-clear-usage-project", { + id: workspaceId, + name: workspaceId, + projectName: "full-clear-usage-project", + projectPath: "/tmp/full-clear-usage-project", + runtimeConfig: { type: "local" }, + }); + expect( + ( + await historyService.appendToHistory( + workspaceId, + createMuxMessage("pre-clear-user", "user", "before clear", {}) + ) + ).success + ).toBe(true); + + (workspaceService as unknown as { sessions: Map }).sessions.set( + workspaceId, + harness.session + ); + seedStaleHighContextUsage(harness.session); + + const result = await workspaceService.truncateHistory(workspaceId, 1.0); + + expect(result.success).toBe(true); + expect(getUsageState(harness.session)).toBeUndefined(); + } finally { + harness.session.dispose(); + await cleanup(); + } + }); + test("context reset is a no-op when repeated without provider-eligible messages", async () => { const { config, historyService, workspaceService, cleanup } = await createServices(); const workspaceId = "context-reset-noop"; diff --git a/src/node/services/workspaceService.ts b/src/node/services/workspaceService.ts index 5aa54d0520..dccb9a3374 100644 --- a/src/node/services/workspaceService.ts +++ b/src/node/services/workspaceService.ts @@ -9677,6 +9677,8 @@ export class WorkspaceService extends EventEmitter { return Err(truncateResult.error); } + session?.clearUsageState(); + const deletedSequences = truncateResult.data; if (deletedSequences.length > 0) { const deleteMessage: DeleteMessage = { @@ -9758,6 +9760,8 @@ export class WorkspaceService extends EventEmitter { return Err(`Failed to append context reset boundary: ${appendResult.error}`); } + session?.clearUsageState(); + const typedBoundaryMessage = { ...boundaryMessage, type: "message" as const }; if (session) { session.emitChatEvent(typedBoundaryMessage); @@ -9883,6 +9887,7 @@ export class WorkspaceService extends EventEmitter { // Emit through the session so ORPC subscriptions receive the events const session = this.sessions.get(workspaceId); + session?.clearUsageState(); if (deletedSequences.length > 0) { const deleteMessage: DeleteMessage = { type: "delete", From cfb05fb2a03e8cc70129abf9396a54e369787da2 Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Fri, 7 Aug 2026 19:08:45 +0000 Subject: [PATCH 02/36] =?UTF-8?q?=F0=9F=A4=96=20fix:=20clear=20stale=20usa?= =?UTF-8?q?ge=20on=20remaining=20history-rewrite=20paths?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Cleanup-gate follow-up: the same stale-usage defect class existed on the heartbeat context reset, exec-subagent hard restart, and edit truncation paths, plus two ordering gaps (destructive replaceHistory cleared history before the summary append could fail, and zero-percentage truncation cleared usage without rewriting anything). Route all context rewrites through clearUsageState() and slim the new tests down to the repo's fake-session wiring pattern. --- _Generated with `mux` • Model: `anthropic:claude-fable-5` • Thinking: `xhigh`_ --- .../agentSession.autoCompaction.test.ts | 19 ++++++ src/node/services/agentSession.ts | 17 +++-- src/node/services/workspaceService.test.ts | 62 ++++++++++--------- src/node/services/workspaceService.ts | 6 +- 4 files changed, 69 insertions(+), 35 deletions(-) diff --git a/src/node/services/agentSession.autoCompaction.test.ts b/src/node/services/agentSession.autoCompaction.test.ts index 785e6cb7d6..2242ae2d34 100644 --- a/src/node/services/agentSession.autoCompaction.test.ts +++ b/src/node/services/agentSession.autoCompaction.test.ts @@ -12,6 +12,7 @@ import { type MuxMessage, } from "@/common/types/message"; import { GOAL_CONTINUATION_KIND } from "@/constants/goals"; +import type { AutoCompactionUsageState } from "@/common/utils/compaction/autoCompactionCheck"; import { Ok, Err } from "@/common/types/result"; import type { Config } from "@/node/config"; import type { AIService } from "@/node/services/aiService"; @@ -854,6 +855,24 @@ describe("AgentSession on-send auto-compaction snapshot deferral", () => { session.dispose(); }); + test("heartbeat context reset clears stale usage before its follow-up dispatches", async () => { + const workspaceId = "ws-heartbeat-reset-clears-usage"; + const { session } = await createSessionHarness({ workspaceId }); + + const sessionState = session as unknown as { lastUsageState?: AutoCompactionUsageState }; + sessionState.lastUsageState = { totalTokens: 95_000 }; + + const result = await session.appendHeartbeatContextResetBoundary({ + boundaryText: "Heartbeat context reset boundary", + pendingFollowUp: { text: "heartbeat follow-up", model: "openai:gpt-4o", agentId: "exec" }, + }); + + expect(result.success).toBe(true); + expect(sessionState.lastUsageState).toBeUndefined(); + + session.dispose(); + }); + test("surfaces nested dispatch failures after mid-stream compaction interrupt", async () => { const workspaceId = "ws-auto-compaction-mid-stream-dispatch-failure"; diff --git a/src/node/services/agentSession.ts b/src/node/services/agentSession.ts index 0d990d7fa8..1afd7fb0b5 100644 --- a/src/node/services/agentSession.ts +++ b/src/node/services/agentSession.ts @@ -2737,7 +2737,9 @@ export class AgentSession { this.workspaceId, truncateTargetId ); - if (!truncateResult.success) { + if (truncateResult.success) { + this.clearUsageState(); + } else { const isMissingEditTarget = truncateResult.error.includes("Message with ID") && truncateResult.error.includes("not found in history"); @@ -3419,10 +3421,10 @@ export class AgentSession { } /** - * Called when history is rewritten to a fresh boundary outside the compaction - * stream path (Start Here, /reset, /clear): stale usage would make the next - * send auto-compact the already-cleared context. sendMessage re-seeds accurate - * usage from post-boundary history. + * Invalidate cached context usage after the active provider context is + * rewritten (boundary append, truncation, history replacement): stale usage + * would make the next send auto-compact the already-rewritten context. The + * next send re-seeds from post-rewrite history. */ clearUsageState(): void { this.lastUsageState = undefined; @@ -4606,6 +4608,8 @@ export class AgentSession { return false; } + this.clearUsageState(); + // This clear bypasses WorkspaceService.replaceHistory, so announce it on the chat funnel the // timeline already consumes: a log that cannot explain missing history defeats its purpose. this.emitChatEvent({ @@ -5206,7 +5210,7 @@ export class AgentSession { // Compaction collapses history to a boundary summary, so prior context-usage snapshots // are stale. Clear them to prevent immediate re-trigger loops on the follow-up turn. - this.lastUsageState = undefined; + this.clearUsageState(); if (completedCompactionRequest?.source === "auto-compaction") { this.emitChatEvent({ @@ -6734,6 +6738,7 @@ export class AgentSession { pendingFollowUp: params.pendingFollowUp, }); if (result.success) { + this.clearUsageState(); this.onPostCompactionStateChange?.(); } return result; diff --git a/src/node/services/workspaceService.test.ts b/src/node/services/workspaceService.test.ts index 19be622c03..00c23080ac 100644 --- a/src/node/services/workspaceService.test.ts +++ b/src/node/services/workspaceService.test.ts @@ -3835,21 +3835,6 @@ describe("WorkspaceService truncateHistory goal acknowledgment", () => { return { aiService, config, historyService, workspaceService, goalService, cleanup }; } - // Simulates the in-memory snapshot a previous stream left behind: - // 95k of gpt-4o's 128k window is past the 70% on-send compaction threshold. - function seedStaleHighContextUsage(session: AgentSession): void { - (session as unknown as { lastUsageState?: AutoCompactionUsageState }).lastUsageState = { - lastContextUsage: createDisplayUsage( - { inputTokens: 95_000, outputTokens: 200, totalTokens: 95_200 }, - "openai:gpt-4o" - ), - }; - } - - function getUsageState(session: AgentSession): AutoCompactionUsageState | undefined { - return (session as unknown as { lastUsageState?: AutoCompactionUsageState }).lastUsageState; - } - test("idle wait follows auto-retry startup into the resumed stream", async () => { const { workspaceService, cleanup } = await createServices(); const workspaceId = "idle-wait-auto-retry-starting"; @@ -4335,7 +4320,15 @@ describe("WorkspaceService truncateHistory goal acknowledgment", () => { workspaceId, harness.session ); - seedStaleHighContextUsage(harness.session); + // In-memory snapshot the previous stream left behind: 95k exceeds 70% of + // gpt-4o's 128k window, so a send with this state would auto-compact. + (harness.session as unknown as { lastUsageState?: AutoCompactionUsageState }).lastUsageState = + { + lastContextUsage: createDisplayUsage( + { inputTokens: 95_000, outputTokens: 200, totalTokens: 95_200 }, + "openai:gpt-4o" + ), + }; const replaceResult = await workspaceService.replaceHistory( workspaceId, @@ -4375,7 +4368,6 @@ describe("WorkspaceService truncateHistory goal acknowledgment", () => { test("context reset clears stale session usage state", async () => { const { config, historyService, workspaceService, cleanup } = await createServices(); const workspaceId = "context-reset-clears-usage-state"; - const harness = await createAgentSessionHarness({ workspaceId, config, historyService }); try { await config.addWorkspace("/tmp/context-reset-usage-project", { id: workspaceId, @@ -4393,20 +4385,28 @@ describe("WorkspaceService truncateHistory goal acknowledgment", () => { ).success ).toBe(true); + const clearUsageState = mock(() => undefined); + const session = { + isBusy: mock(() => false), + hasQueuedMessages: mock(() => false), + isPreparingTurn: mock(() => false), + hasPendingAutoRetry: mock(() => false), + emitChatEvent: mock(() => undefined), + clearUsageState, + clearFileState: mock(() => undefined), + } as unknown as AgentSession; (workspaceService as unknown as { sessions: Map }).sessions.set( workspaceId, - harness.session + session ); - seedStaleHighContextUsage(harness.session); expect(await workspaceService.resetContext(workspaceId)).toEqual({ success: true, data: "reset", }); - expect(getUsageState(harness.session)).toBeUndefined(); + expect(clearUsageState).toHaveBeenCalledTimes(1); } finally { - harness.session.dispose(); await cleanup(); } }); @@ -4414,7 +4414,6 @@ describe("WorkspaceService truncateHistory goal acknowledgment", () => { test("full history clear clears stale session usage state", async () => { const { config, historyService, workspaceService, cleanup } = await createServices(); const workspaceId = "full-clear-clears-usage-state"; - const harness = await createAgentSessionHarness({ workspaceId, config, historyService }); try { await config.addWorkspace("/tmp/full-clear-usage-project", { id: workspaceId, @@ -4432,18 +4431,25 @@ describe("WorkspaceService truncateHistory goal acknowledgment", () => { ).success ).toBe(true); + const clearUsageState = mock(() => undefined); + const session = { + isBusy: mock(() => false), + emitChatEvent: mock(() => undefined), + clearUsageState, + clearFileState: mock(() => undefined), + } as unknown as AgentSession; (workspaceService as unknown as { sessions: Map }).sessions.set( workspaceId, - harness.session + session ); - seedStaleHighContextUsage(harness.session); - const result = await workspaceService.truncateHistory(workspaceId, 1.0); + // A zero-percentage truncation rewrites nothing, so usage must survive. + expect((await workspaceService.truncateHistory(workspaceId, 0)).success).toBe(true); + expect(clearUsageState).toHaveBeenCalledTimes(0); - expect(result.success).toBe(true); - expect(getUsageState(harness.session)).toBeUndefined(); + expect((await workspaceService.truncateHistory(workspaceId, 1.0)).success).toBe(true); + expect(clearUsageState).toHaveBeenCalledTimes(1); } finally { - harness.session.dispose(); await cleanup(); } }); diff --git a/src/node/services/workspaceService.ts b/src/node/services/workspaceService.ts index dccb9a3374..58f3a26062 100644 --- a/src/node/services/workspaceService.ts +++ b/src/node/services/workspaceService.ts @@ -9677,7 +9677,9 @@ export class WorkspaceService extends EventEmitter { return Err(truncateResult.error); } - session?.clearUsageState(); + if (effectivePercentage > 0) { + session?.clearUsageState(); + } const deletedSequences = truncateResult.data; if (deletedSequences.length > 0) { @@ -9872,6 +9874,8 @@ export class WorkspaceService extends EventEmitter { if (!clearResult.success) { return Err(`Failed to clear history: ${clearResult.error}`); } + // History is already gone even if the summary append below fails. + this.sessions.get(workspaceId)?.clearUsageState(); this.timelineRecorder.record(workspaceId, { kind: "history.cleared", source: { system: "chat" }, From e3e6dcd4cf9b127850e85614d62b33498f3b1708 Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Fri, 7 Aug 2026 20:10:09 +0000 Subject: [PATCH 03/36] =?UTF-8?q?=F0=9F=A4=96=20tests:=20self-heal=20poiso?= =?UTF-8?q?ned=20DOM=20globals=20and=20pre-cache=20react-dnd=20in=20test?= =?UTF-8?q?=20bootstrap?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CI-only failure on this PR: 35 AgentListItem/GitStatusIndicatorView/ TaskGroupListItem tests failed with "Cannot access 'DragPreviewImage' before initialization". Changing test files shifts bun's CI discovery order, letting a pre-existing order bug surface: files that tear down with `globalThis.document = undefined` poison installDom()'s snapshot/restore, which propagates a document-less environment (with baseline observers present) to later files; @react-dnd/asap then crashes at module eval and leaves react-dnd's internal exports in TDZ, so every later import including `?real=1` re-evals fails. Fix at the chokepoint in tests/ui/dom.ts: the uninstaller re-bootstraps a baseline when it would restore a poisoned document, and react-dnd's module graph is evaluated eagerly while the DOM is healthy so re-evals resolve initialized internals. Both guards in tests/ui/domIsolation.test.ts fail against their own toggle. --- _Generated with `mux` • Model: `anthropic:claude-fable-5` • Thinking: `xhigh`_ --- tests/ui/dom.ts | 22 +++++++++++++++++++ tests/ui/domIsolation.test.ts | 40 +++++++++++++++++++++++++++++++++++ 2 files changed, 62 insertions(+) create mode 100644 tests/ui/domIsolation.test.ts diff --git a/tests/ui/dom.ts b/tests/ui/dom.ts index 60a8f05b5b..b2e8287698 100644 --- a/tests/ui/dom.ts +++ b/tests/ui/dom.ts @@ -225,6 +225,16 @@ export function installDom(): () => void { previous.IntersectionObserver; (globalThis as unknown as { ResizeObserver?: unknown }).ResizeObserver = previous.ResizeObserver; + + // Self-heal: some test files tear down with `globalThis.document = undefined`. + // If that poison leaked into our snapshot, restoring it would propagate a + // document-less environment (with baseline observers still present) to later + // test files, crashing module evals that probe the DOM (e.g. @react-dnd/asap + // calls document.createTextNode when MutationObserver exists). Re-bootstrap a + // baseline instead of re-exposing the poisoned snapshot. + if (typeof globalThis.document === "undefined") { + installDom(); + } }; } @@ -243,3 +253,15 @@ export function installDom(): () => void { if (typeof globalThis.document === "undefined") { installDom(); } + +// Evaluate react-dnd's module graph once while the DOM baseline is healthy. +// bun test runs every file in one process; if a poisoned environment (document +// undefined, MutationObserver present) reaches react-dnd's first evaluation, +// @react-dnd/asap throws at module scope and leaves the package's internal +// exports in TDZ, so every later import (including `?real=1` re-evals) fails +// with "Cannot access 'DragPreviewImage' before initialization". Caching the +// healthy evaluation here makes later re-evals resolve initialized internals. +// eslint-disable-next-line @typescript-eslint/no-require-imports +require("react-dnd"); +// eslint-disable-next-line @typescript-eslint/no-require-imports +require("react-dnd-html5-backend"); diff --git a/tests/ui/domIsolation.test.ts b/tests/ui/domIsolation.test.ts new file mode 100644 index 0000000000..3ea69e9abf --- /dev/null +++ b/tests/ui/domIsolation.test.ts @@ -0,0 +1,40 @@ +import { describe, expect, test } from "bun:test"; + +import { installDom } from "./dom"; + +describe("dom isolation", () => { + test("uninstall re-bootstraps a baseline instead of restoring a poisoned document", () => { + // Outer scope snapshots the healthy globals so this test leaves the ambient + // baseline untouched for later files. + const uninstallOuter = installDom(); + try { + // Simulate a foreign test file that tore down with document poisoned. + globalThis.document = undefined as unknown as Document; + const uninstall = installDom(); + uninstall(); + + expect(globalThis.document).toBeDefined(); + expect(globalThis.document.createTextNode("x").textContent).toBe("x"); + } finally { + uninstallOuter(); + } + }); + + test("react-dnd internals survive re-evaluation in a document-less environment", () => { + // The eager require in dom.ts must have initialized react-dnd's internal + // module graph while the DOM was healthy; a fresh top-level re-eval (query + // suffix) then resolves cached internals instead of crashing in + // @react-dnd/asap. Without that cache this probe throws. + const healthyDocument = globalThis.document; + try { + globalThis.document = undefined as unknown as Document; + // eslint-disable-next-line @typescript-eslint/no-require-imports + const fresh = require("react-dnd?dom-isolation-probe=1") as { + DragPreviewImage?: unknown; + }; + expect(fresh.DragPreviewImage).toBeDefined(); + } finally { + globalThis.document = healthyDocument; + } + }); +}); From fadf52b1fc158bb6216efe955b0deebf6d7feb7a Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Fri, 7 Aug 2026 20:18:34 +0000 Subject: [PATCH 04/36] =?UTF-8?q?=F0=9F=A4=96=20tests:=20exclude=20bun-onl?= =?UTF-8?q?y=20DOM=20isolation=20guards=20from=20Jest?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit tests/ui/domIsolation.test.ts uses bun:test (it guards bun's single-process module cache behavior), so the Jest-based integration run fails to resolve its import. Skip it via testPathIgnorePatterns like the storybook bun tests. --- _Generated with `mux` • Model: `anthropic:claude-fable-5` • Thinking: `xhigh`_ --- jest.config.js | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/jest.config.js b/jest.config.js index aa01f2d591..3562bd65a8 100644 --- a/jest.config.js +++ b/jest.config.js @@ -29,8 +29,9 @@ module.exports = { "\\.txt$": "/tests/__mocks__/textMock.js", "\\.svg$": "/tests/__mocks__/svgMock.js", }, - // Storybook UI tests use bun:test and are run via `bun test`, so Jest must skip them. - testPathIgnorePatterns: ["/tests/ui/storybook/"], + // Storybook UI tests and the DOM isolation guards use bun:test and are run + // via `bun test`, so Jest must skip them. + testPathIgnorePatterns: ["/tests/ui/storybook/", "/tests/ui/domIsolation"], // Avoid haste module collision with vscode extension modulePathIgnorePatterns: ["/vscode/"], transform: { From 37ced8cacecccced0cbb4834c38dcd6153d93759 Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Fri, 7 Aug 2026 20:25:56 +0000 Subject: [PATCH 05/36] =?UTF-8?q?=F0=9F=A4=96=20fix:=20suppress=20usage=20?= =?UTF-8?q?seeding=20after=20history=20rewrites=20until=20fresh=20provider?= =?UTF-8?q?=20usage?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Codex P2: for a partial /clear, clearing the in-memory usage cache was ineffective because the retained newest assistant row still carries pre-truncation contextUsage and partial truncation appends no boundary, so seedUsageStateFromHistory() restored the same stale value on the next send. clearUsageState() now also suppresses history seeding until the provider reports fresh usage (updateUsageStateFromModelUsage resets the flag). Guard tests: a baseline proving the boundary-less fixture seeds, and a suppression test that fails without the new guard. --- _Generated with `mux` • Model: `anthropic:claude-fable-5` • Thinking: `xhigh`_ --- .../agentSession.autoCompaction.test.ts | 88 +++++++++++++++++++ src/node/services/agentSession.ts | 13 ++- 2 files changed, 98 insertions(+), 3 deletions(-) diff --git a/src/node/services/agentSession.autoCompaction.test.ts b/src/node/services/agentSession.autoCompaction.test.ts index 2242ae2d34..68fd59c3ee 100644 --- a/src/node/services/agentSession.autoCompaction.test.ts +++ b/src/node/services/agentSession.autoCompaction.test.ts @@ -744,6 +744,94 @@ describe("AgentSession on-send auto-compaction snapshot deferral", () => { session.dispose(); }); + // Boundary-less history retaining a high-usage assistant row, as after a + // partial /clear that removed only an older prefix. + async function seedBoundarylessHighUsageHistory(workspaceId: string) { + const { historyService, config, cleanup } = await createTestHistoryService(); + historyCleanup = cleanup; + + const appendUser = await historyService.appendToHistory( + workspaceId, + createMuxMessage("user-retained", "user", "retained prompt", { + timestamp: Date.now() - 2_000, + }) + ); + expect(appendUser.success).toBe(true); + + const appendAssistant = await historyService.appendToHistory( + workspaceId, + createMuxMessage("assistant-retained", "assistant", "retained reply", { + timestamp: Date.now() - 1_000, + model: "openai:gpt-4o", + contextUsage: { inputTokens: 95_000, outputTokens: 100, totalTokens: 95_100 }, + }) + ); + expect(appendAssistant.success).toBe(true); + + return { historyService, config }; + } + + function installUsageCapturingMonitor(session: AgentSession): unknown[] { + const seenUsages: unknown[] = []; + (session as unknown as { compactionMonitor: CompactionMonitor }).compactionMonitor = { + checkBeforeSend: mock((params: { usage?: unknown }) => { + seenUsages.push(params.usage); + return { + shouldShowWarning: false, + shouldForceCompact: false, + usagePercentage: 0, + thresholdPercentage: 85, + }; + }), + checkMidStream: mock(() => false), + resetForNewStream: mock(() => undefined), + setThreshold: mock(() => undefined), + getThreshold: mock(() => 0.85), + } as unknown as CompactionMonitor; + return seenUsages; + } + + test("history seeding restores persisted usage when no rewrite occurred", async () => { + const workspaceId = "ws-usage-seeding-baseline"; + const { historyService, config } = await seedBoundarylessHighUsageHistory(workspaceId); + + const harness = await createAgentSessionHarness({ workspaceId, historyService, config }); + const seenUsages = installUsageCapturingMonitor(harness.session); + + const result = await harness.session.sendMessage("restart send", { + model: "openai:gpt-4o", + agentId: "exec", + }); + expect(result.success).toBe(true); + expect(seenUsages).toHaveLength(1); + const seeded = seenUsages[0] as AutoCompactionUsageState | undefined; + expect(seeded?.lastContextUsage).toBeDefined(); + + harness.session.dispose(); + }); + + test("clearUsageState suppresses history seeding until fresh provider usage arrives", async () => { + const workspaceId = "ws-usage-seeding-suppressed"; + const { historyService, config } = await seedBoundarylessHighUsageHistory(workspaceId); + + const harness = await createAgentSessionHarness({ workspaceId, historyService, config }); + const seenUsages = installUsageCapturingMonitor(harness.session); + + // Same fixture as the baseline test, but a rewrite invalidated usage: + // the retained row's contextUsage still counts removed tokens and must not reseed. + harness.session.clearUsageState(); + + const result = await harness.session.sendMessage("send after partial clear", { + model: "openai:gpt-4o", + agentId: "exec", + }); + expect(result.success).toBe(true); + expect(seenUsages).toHaveLength(1); + expect(seenUsages[0]).toBeUndefined(); + + harness.session.dispose(); + }); + test("seeds on-send compaction usage from the active compaction epoch only", async () => { const workspaceId = "ws-auto-compaction-seed-active-epoch"; diff --git a/src/node/services/agentSession.ts b/src/node/services/agentSession.ts index 1afd7fb0b5..7dfba68150 100644 --- a/src/node/services/agentSession.ts +++ b/src/node/services/agentSession.ts @@ -472,6 +472,7 @@ export class AgentSession { /** Latest context-usage snapshot used for on-send compaction checks. */ private lastUsageState?: AutoCompactionUsageState; + private usageSeedingSuppressed = false; /** Prevent duplicate mid-stream compaction interrupts while we are already transitioning. */ private midStreamCompactionPending = false; @@ -3391,6 +3392,8 @@ export class AgentSession { return; } + this.usageSeedingSuppressed = false; + const totalTokens = params.usage.totalTokens ?? this.lastUsageState?.totalTokens; if (params.live) { this.lastUsageState = { @@ -3423,11 +3426,15 @@ export class AgentSession { /** * Invalidate cached context usage after the active provider context is * rewritten (boundary append, truncation, history replacement): stale usage - * would make the next send auto-compact the already-rewritten context. The - * next send re-seeds from post-rewrite history. + * would make the next send auto-compact the already-rewritten context. + * History seeding is also suppressed until the provider reports fresh usage, + * because boundary-less rewrites (partial /clear) retain rows whose persisted + * contextUsage still counts removed tokens; re-seeding those would restore + * the same stale value. */ clearUsageState(): void { this.lastUsageState = undefined; + this.usageSeedingSuppressed = true; } /** @@ -3514,7 +3521,7 @@ export class AgentSession { * `lastUsageState` is still undefined. */ private async seedUsageStateFromHistory(): Promise { - if (this.lastUsageState !== undefined) { + if (this.lastUsageState !== undefined || this.usageSeedingSuppressed) { return; } From acc9f3d7b02f3d02f461367b99b88ec0edf71580 Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Fri, 7 Aug 2026 20:29:30 +0000 Subject: [PATCH 06/36] =?UTF-8?q?=F0=9F=A4=96=20tests:=20run=20the=20bun-o?= =?UTF-8?q?nly=20DOM=20isolation=20guards=20in=20test-unit=20and=20CI=20Un?= =?UTF-8?q?it?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Codex P2: tests/ui/domIsolation.test.ts was excluded from Jest but no bun target ran it, so the guards never executed in local validation or CI. Add the file to the Makefile test-unit bun invocation and to the CI Unit job's explicit file list. --- _Generated with `mux` • Model: `anthropic:claude-fable-5` • Thinking: `xhigh`_ --- .github/workflows/pr.yml | 3 +++ Makefile | 2 +- 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/.github/workflows/pr.yml b/.github/workflows/pr.yml index e0d0e1d364..7b8ee35aa8 100644 --- a/.github/workflows/pr.yml +++ b/.github/workflows/pr.yml @@ -228,6 +228,9 @@ jobs: ! -path 'src/browser/features/RightSidebar/Memory/MemoryTab.test.tsx' \ -print0 ) + # Bun-only DOM isolation guards live outside src/ and are excluded + # from Jest, so include them here explicitly. + unit_files+=("tests/ui/domIsolation.test.ts") bun test --max-concurrency=1 --coverage --coverage-reporter=lcov "${unit_files[@]}" - uses: codecov/codecov-action@671740ac38dd9b0130fbe1cec585b89eea48d3de # v5.5.2 with: diff --git a/Makefile b/Makefile index 0d9a8c661a..f4b9cf7ca3 100644 --- a/Makefile +++ b/Makefile @@ -406,7 +406,7 @@ test-integration: node_modules/.installed build-main ## Run all tests (unit + in test-unit: node_modules/.installed build-main ## Run unit tests @bun test src - @bun test ./tests/ui/storybook/ + @bun test ./tests/ui/storybook/ ./tests/ui/domIsolation.test.ts test: test-unit ## Alias for test-unit From b25223c6c53e014485c6ffd8b329ed1f3c76642e Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Fri, 7 Aug 2026 20:34:33 +0000 Subject: [PATCH 07/36] =?UTF-8?q?=F0=9F=A4=96=20fix:=20strip=20stale=20con?= =?UTF-8?q?textUsage=20from=20rows=20retained=20by=20partial=20truncation?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Codex P2: the in-memory seeding suppression does not survive an app restart. After a partial /clear plus restart, seedUsageStateFromHistory() reloaded the retained assistant row's pre-truncation contextUsage and could spuriously auto-compact again. Sanitize at the source: the partial-truncation rewrite now strips contextUsage and contextProviderMetadata from retained rows, since those snapshots measured a context that no longer exists. Restart-safe with no extra persisted state; the in-memory suppression flag still covers the same-process window until fresh provider usage arrives. Red-green verified via a historyService test. --- _Generated with `mux` • Model: `anthropic:claude-fable-5` • Thinking: `xhigh`_ --- src/node/services/historyService.test.ts | 30 ++++++++++++++++++++++++ src/node/services/historyService.ts | 18 +++++++++++++- 2 files changed, 47 insertions(+), 1 deletion(-) diff --git a/src/node/services/historyService.test.ts b/src/node/services/historyService.test.ts index 52d4ec3822..36fe2d5732 100644 --- a/src/node/services/historyService.test.ts +++ b/src/node/services/historyService.test.ts @@ -2019,6 +2019,36 @@ describe("HistoryService", () => { expect(msg.metadata?.historySequence).toBe(3); }); + it("strips stale contextUsage from rows retained by partial truncation", async () => { + // Filler prefix makes the newest assistant row survive a 50% cut. + await appendNumberedMessages(service, wsId, 8); + await service.appendToHistory( + wsId, + createMuxMessage("assistant-usage", "assistant", "reply", { + contextUsage: { inputTokens: 95_000, outputTokens: 100, totalTokens: 95_100 }, + contextProviderMetadata: { anthropic: {} }, + model: "openai:gpt-4o", + }) + ); + + const truncateResult = await service.truncateHistory(wsId, 0.5); + expect(truncateResult.success).toBe(true); + if (truncateResult.success) { + expect(truncateResult.data.length).toBeGreaterThan(0); + } + + const remaining = await service.getHistoryFromLatestBoundary(wsId); + expect(remaining.success).toBe(true); + if (remaining.success) { + const retainedAssistant = remaining.data.find((msg) => msg.id === "assistant-usage"); + expect(retainedAssistant).toBeDefined(); + expect(retainedAssistant?.metadata?.contextUsage).toBeUndefined(); + expect(retainedAssistant?.metadata?.contextProviderMetadata).toBeUndefined(); + // Only usage snapshots are sanitized; the rest of the row survives. + expect(retainedAssistant?.metadata?.model).toBe("openai:gpt-4o"); + } + }); + it("keeps the archive intact on a no-op percentage truncation", async () => { await appendNumberedMessages(service, wsId, 3); await service.appendToHistory(wsId, boundaryMessage("boundary-1", 1)); diff --git a/src/node/services/historyService.ts b/src/node/services/historyService.ts index df71938525..4bcc8696cf 100644 --- a/src/node/services/historyService.ts +++ b/src/node/services/historyService.ts @@ -2004,7 +2004,23 @@ export class HistoryService { } // Keep messages after removeCount - const remainingMessages = messages.slice(removeCount); + const remainingMessages = messages.slice(removeCount).map((msg) => { + // Retained rows' contextUsage measured the pre-truncation context + // (including the removed prefix). Persisting it would reseed stale + // auto-compaction pressure, even across app restarts. Strip it; the + // next provider response reports fresh usage. + if (msg.metadata?.contextUsage === undefined) { + return msg; + } + return { + ...msg, + metadata: { + ...msg.metadata, + contextUsage: undefined, + contextProviderMetadata: undefined, + }, + }; + }); const deletedMessages = messages.slice(0, removeCount); const deletedSequences = deletedMessages .map((msg) => msg.metadata?.historySequence) From f9dd1adc41cc6167240e313c3600e5ea24dce1ea Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Fri, 7 Aug 2026 20:38:49 +0000 Subject: [PATCH 08/36] =?UTF-8?q?=F0=9F=A4=96=20fix:=20re-enable=20usage?= =?UTF-8?q?=20seeding=20when=20a=20heartbeat=20reset=20rolls=20back?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Codex P2: queued user input rolls back an undispatched heartbeat reset boundary and restores the pre-reset provider context, but the seeding suppression from the boundary append stayed active, so the queued turn could not recover the restored history's near-limit usage and skipped on-send compaction. Re-enable seeding on successful rollback. Guard test drives the real append -> queue -> rollback path and fails without the re-enable. --- _Generated with `mux` • Model: `anthropic:claude-fable-5` • Thinking: `xhigh`_ --- .../agentSession.autoCompaction.test.ts | 44 +++++++++++++++++++ src/node/services/agentSession.ts | 5 +++ 2 files changed, 49 insertions(+) diff --git a/src/node/services/agentSession.autoCompaction.test.ts b/src/node/services/agentSession.autoCompaction.test.ts index 68fd59c3ee..a303ba63b9 100644 --- a/src/node/services/agentSession.autoCompaction.test.ts +++ b/src/node/services/agentSession.autoCompaction.test.ts @@ -961,6 +961,50 @@ describe("AgentSession on-send auto-compaction snapshot deferral", () => { session.dispose(); }); + test("heartbeat reset rollback re-enables usage seeding for the queued turn", async () => { + const workspaceId = "ws-heartbeat-rollback-reenables-seeding"; + const { session, historyService } = await createSessionHarness({ workspaceId }); + + const appendAssistant = await historyService.appendToHistory( + workspaceId, + createMuxMessage("assistant-near-limit", "assistant", "reply", { + timestamp: Date.now() - 1_000, + model: "openai:gpt-4o", + contextUsage: { inputTokens: 95_000, outputTokens: 100, totalTokens: 95_100 }, + }) + ); + expect(appendAssistant.success).toBe(true); + + const appendBoundary = await session.appendHeartbeatContextResetBoundary({ + boundaryText: "Heartbeat context reset boundary", + pendingFollowUp: { + text: "heartbeat follow-up", + model: "openai:gpt-4o", + agentId: "exec", + dispatchOptions: { requireIdle: true }, + }, + }); + expect(appendBoundary.success).toBe(true); + + // User input queued after the boundary forces the idle-only follow-up to roll back. + session.queueMessage("queued user input", { model: "openai:gpt-4o", agentId: "exec" }); + + const dispatched = await session.dispatchPendingCompactionFollowUpIfNeeded(); + expect(dispatched).toBe(false); + + const seenUsages = installUsageCapturingMonitor(session); + const result = await session.sendMessage("post-rollback send", { + model: "openai:gpt-4o", + agentId: "exec", + }); + expect(result.success).toBe(true); + expect(seenUsages).toHaveLength(1); + const seeded = seenUsages[0] as AutoCompactionUsageState | undefined; + expect(seeded?.lastContextUsage).toBeDefined(); + + session.dispose(); + }); + test("surfaces nested dispatch failures after mid-stream compaction interrupt", async () => { const workspaceId = "ws-auto-compaction-mid-stream-dispatch-failure"; diff --git a/src/node/services/agentSession.ts b/src/node/services/agentSession.ts index 7dfba68150..cd810e806e 100644 --- a/src/node/services/agentSession.ts +++ b/src/node/services/agentSession.ts @@ -6010,6 +6010,11 @@ export class AgentSession { if (!rollbackResult.success) { throw new Error(`Failed to rollback heartbeat reset boundary: ${rollbackResult.error}`); } + // Rollback restored the pre-reset provider context, so the invalidation + // from appendHeartbeatContextResetBoundary no longer applies. Re-enable + // seeding so the queued turn recovers the restored history's usage and + // on-send compaction still fires for a near-limit context. + this.usageSeedingSuppressed = false; this.onPostCompactionStateChange?.(); } else { await this.clearPendingFollowUpFromSummary(lastMessage); From db716c50a6660e9225596d1d29399b15e58d9ab3 Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Fri, 7 Aug 2026 20:43:52 +0000 Subject: [PATCH 09/36] =?UTF-8?q?=F0=9F=A4=96=20fix:=20clear=20usage=20ins?= =?UTF-8?q?ide=20the=20truncate=20step,=20before=20wake=20restoration?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Codex P2: when a partial truncation committed but the monitor-wake restoration step of clearHistoryWithRetiredBashMonitorWakes threw, the invalidation after the wrapper never ran and the session kept its pre-truncation usage. Move the clear into the truncate closure, immediately after the successful rewrite. Guard test injects a wake-restore failure and fails without the move. --- _Generated with `mux` • Model: `anthropic:claude-fable-5` • Thinking: `xhigh`_ --- src/node/services/workspaceService.test.ts | 57 ++++++++++++++++++++++ src/node/services/workspaceService.ts | 15 ++++-- 2 files changed, 67 insertions(+), 5 deletions(-) diff --git a/src/node/services/workspaceService.test.ts b/src/node/services/workspaceService.test.ts index 00c23080ac..1365966da0 100644 --- a/src/node/services/workspaceService.test.ts +++ b/src/node/services/workspaceService.test.ts @@ -4454,6 +4454,63 @@ describe("WorkspaceService truncateHistory goal acknowledgment", () => { } }); + test("usage is cleared when wake restoration fails after a committed truncation", async () => { + const { config, historyService, workspaceService, cleanup } = await createServices(); + const workspaceId = "truncate-usage-clear-before-wake-restore"; + try { + await config.addWorkspace("/tmp/truncate-wake-restore-project", { + id: workspaceId, + name: workspaceId, + projectName: "truncate-wake-restore-project", + projectPath: "/tmp/truncate-wake-restore-project", + runtimeConfig: { type: "local" }, + }); + for (let i = 0; i < 6; i++) { + expect( + ( + await historyService.appendToHistory( + workspaceId, + createMuxMessage(`msg-${i}`, "user", `message ${i} with some padding text`, {}) + ) + ).success + ).toBe(true); + } + + const clearUsageState = mock(() => undefined); + const session = { + isBusy: mock(() => false), + emitChatEvent: mock(() => undefined), + clearUsageState, + clearFileState: mock(() => undefined), + } as unknown as AgentSession; + (workspaceService as unknown as { sessions: Map }).sessions.set( + workspaceId, + session + ); + + const wakeStore = ( + workspaceService as unknown as { bashMonitorWakeStore: BashMonitorWakeStore } + ).bashMonitorWakeStore; + const restoreSpy = spyOn(wakeStore, "restorePendingSnapshots").mockImplementation(() => + Promise.reject(new Error("injected wake restore failure")) + ); + + // Partial truncation commits the rewrite, then wake restoration throws. + let thrown: unknown; + try { + await workspaceService.truncateHistory(workspaceId, 0.5); + } catch (error) { + thrown = error; + } + expect(thrown).toBeInstanceOf(Error); + expect((thrown as Error).message).toBe("injected wake restore failure"); + expect(clearUsageState).toHaveBeenCalledTimes(1); + restoreSpy.mockRestore(); + } finally { + await cleanup(); + } + }); + test("context reset is a no-op when repeated without provider-eligible messages", async () => { const { config, historyService, workspaceService, cleanup } = await createServices(); const workspaceId = "context-reset-noop"; diff --git a/src/node/services/workspaceService.ts b/src/node/services/workspaceService.ts index 58f3a26062..42cd4b7be2 100644 --- a/src/node/services/workspaceService.ts +++ b/src/node/services/workspaceService.ts @@ -9666,7 +9666,16 @@ export class WorkspaceService extends EventEmitter { const effectivePercentage = percentage ?? 1.0; const isFullClear = effectivePercentage >= 1.0; - const truncate = () => this.historyService.truncateHistory(workspaceId, effectivePercentage); + // Invalidate usage inside the truncate step, immediately after the rewrite + // commits: later wrapper steps (monitor-wake restoration) can fail after + // chat.jsonl has already changed, and stale usage must not survive that. + const truncate = async () => { + const result = await this.historyService.truncateHistory(workspaceId, effectivePercentage); + if (result.success && effectivePercentage > 0) { + this.sessions.get(workspaceId)?.clearUsageState(); + } + return result; + }; const truncateResult = effectivePercentage > 0 ? await this.clearHistoryWithRetiredBashMonitorWakes(workspaceId, truncate, { @@ -9677,10 +9686,6 @@ export class WorkspaceService extends EventEmitter { return Err(truncateResult.error); } - if (effectivePercentage > 0) { - session?.clearUsageState(); - } - const deletedSequences = truncateResult.data; if (deletedSequences.length > 0) { const deleteMessage: DeleteMessage = { From c6704dbd2b53b0c89a1f83782e4c5bd897fe7831 Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Fri, 7 Aug 2026 20:47:56 +0000 Subject: [PATCH 10/36] =?UTF-8?q?=F0=9F=A4=96=20fix:=20keep=20history=20se?= =?UTF-8?q?eding=20available=20after=20message-edit=20truncation?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Codex P2: message edits truncate a suffix, so the retained prefix's persisted contextUsage still describes the active context. Blanket seeding suppression meant an edit whose stream failed before reporting usage left the next send unable to recover that valid snapshot, bypassing on-send compaction for near-limit prefixes. clearUsageState() gains a preserveHistorySeeding option used by the edit path. Guard test drives edit-then-send and fails without it. --- _Generated with `mux` • Model: `anthropic:claude-fable-5` • Thinking: `xhigh`_ --- .../agentSession.autoCompaction.test.ts | 45 +++++++++++++++++++ src/node/services/agentSession.ts | 15 +++++-- 2 files changed, 57 insertions(+), 3 deletions(-) diff --git a/src/node/services/agentSession.autoCompaction.test.ts b/src/node/services/agentSession.autoCompaction.test.ts index a303ba63b9..77b469e075 100644 --- a/src/node/services/agentSession.autoCompaction.test.ts +++ b/src/node/services/agentSession.autoCompaction.test.ts @@ -961,6 +961,51 @@ describe("AgentSession on-send auto-compaction snapshot deferral", () => { session.dispose(); }); + test("edit truncation keeps history seeding available for the retained prefix", async () => { + const workspaceId = "ws-edit-truncation-preserves-seeding"; + const { session, historyService } = await createSessionHarness({ workspaceId }); + + // Retained prefix: assistant row whose usage is valid post-edit. + // Suffix after the edit target gets truncated away. + const rows = [ + createMuxMessage("user-1", "user", "first prompt", { timestamp: Date.now() - 5_000 }), + createMuxMessage("assistant-1", "assistant", "first reply", { + timestamp: Date.now() - 4_000, + model: "openai:gpt-4o", + contextUsage: { inputTokens: 95_000, outputTokens: 100, totalTokens: 95_100 }, + }), + createMuxMessage("user-2", "user", "second prompt", { timestamp: Date.now() - 3_000 }), + createMuxMessage("assistant-2", "assistant", "second reply", { + timestamp: Date.now() - 2_000, + model: "openai:gpt-4o", + }), + ]; + for (const row of rows) { + expect((await historyService.appendToHistory(workspaceId, row)).success).toBe(true); + } + + const editResult = await session.sendMessage("second prompt, edited", { + model: "openai:gpt-4o", + agentId: "exec", + editMessageId: "user-2", + }); + expect(editResult.success).toBe(true); + + // The edit stream reported no usage (e.g. failed early); the next normal + // send must still seed from the retained prefix so compaction stays armed. + const seenUsages = installUsageCapturingMonitor(session); + const result = await session.sendMessage("follow-up send", { + model: "openai:gpt-4o", + agentId: "exec", + }); + expect(result.success).toBe(true); + expect(seenUsages).toHaveLength(1); + const seeded = seenUsages[0] as AutoCompactionUsageState | undefined; + expect(seeded?.lastContextUsage).toBeDefined(); + + session.dispose(); + }); + test("heartbeat reset rollback re-enables usage seeding for the queued turn", async () => { const workspaceId = "ws-heartbeat-rollback-reenables-seeding"; const { session, historyService } = await createSessionHarness({ workspaceId }); diff --git a/src/node/services/agentSession.ts b/src/node/services/agentSession.ts index cd810e806e..8d5945d2de 100644 --- a/src/node/services/agentSession.ts +++ b/src/node/services/agentSession.ts @@ -2739,7 +2739,9 @@ export class AgentSession { truncateTargetId ); if (truncateResult.success) { - this.clearUsageState(); + // Edits cut a suffix; the retained prefix's persisted usage is still + // valid, so keep history seeding available for the edited send chain. + this.clearUsageState({ preserveHistorySeeding: true }); } else { const isMissingEditTarget = truncateResult.error.includes("Message with ID") && @@ -3431,10 +3433,17 @@ export class AgentSession { * because boundary-less rewrites (partial /clear) retain rows whose persisted * contextUsage still counts removed tokens; re-seeding those would restore * the same stale value. + * + * Pass preserveHistorySeeding for suffix-only rewrites (message edits): the + * retained prefix's persisted contextUsage still describes the active + * context, and seeding from it keeps on-send compaction armed for + * near-limit prefixes. */ - clearUsageState(): void { + clearUsageState(options?: { preserveHistorySeeding?: boolean }): void { this.lastUsageState = undefined; - this.usageSeedingSuppressed = true; + if (options?.preserveHistorySeeding !== true) { + this.usageSeedingSuppressed = true; + } } /** From 23fc92da26904ebd043b1c81515be5acbbed2af5 Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Fri, 7 Aug 2026 20:53:50 +0000 Subject: [PATCH 11/36] =?UTF-8?q?=F0=9F=A4=96=20fix:=20preserve=20usage=20?= =?UTF-8?q?when=20a=20partial=20truncation=20stays=20before=20the=20latest?= =?UTF-8?q?=20boundary?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Codex P2: after a compaction boundary exists, a 25/50/75% truncation can remove only sealed pre-boundary rows, leaving the active provider context unchanged; stripping retained contextUsage and suppressing the in-memory seed then discarded a valid near-limit snapshot and could skip required on-send compaction. truncateHistory now reports whether the cut reached the active window (removed the latest durable boundary or later rows). Rows are sanitized and session usage cleared only in that case. Red-green: forcing the flag true fails the new preserve test; the strip test still guards boundary-less cuts. --- _Generated with `mux` • Model: `anthropic:claude-fable-5` • Thinking: `xhigh`_ --- src/node/services/historyService.test.ts | 36 ++++++++++++++++++++-- src/node/services/historyService.ts | 30 ++++++++++++------ src/node/services/workspaceService.test.ts | 4 ++- src/node/services/workspaceService.ts | 6 ++-- 4 files changed, 62 insertions(+), 14 deletions(-) diff --git a/src/node/services/historyService.test.ts b/src/node/services/historyService.test.ts index 36fe2d5732..f08897a318 100644 --- a/src/node/services/historyService.test.ts +++ b/src/node/services/historyService.test.ts @@ -2034,7 +2034,8 @@ describe("HistoryService", () => { const truncateResult = await service.truncateHistory(wsId, 0.5); expect(truncateResult.success).toBe(true); if (truncateResult.success) { - expect(truncateResult.data.length).toBeGreaterThan(0); + expect(truncateResult.data.deletedSequences.length).toBeGreaterThan(0); + expect(truncateResult.data.activeContextTruncated).toBe(true); } const remaining = await service.getHistoryFromLatestBoundary(wsId); @@ -2049,6 +2050,34 @@ describe("HistoryService", () => { } }); + it("preserves contextUsage when the cut stays before the latest boundary", async () => { + // Heavy sealed prefix, then a boundary, then a light active epoch whose + // usage snapshot must survive a cut confined to pre-boundary rows. + await appendNumberedMessages(service, wsId, 12); + await service.appendToHistory(wsId, boundaryMessage("boundary-1", 1)); + await service.appendToHistory( + wsId, + createMuxMessage("assistant-active", "assistant", "active reply", { + contextUsage: { inputTokens: 95_000, outputTokens: 100, totalTokens: 95_100 }, + model: "openai:gpt-4o", + }) + ); + + const truncateResult = await service.truncateHistory(wsId, 0.25); + expect(truncateResult.success).toBe(true); + if (truncateResult.success) { + expect(truncateResult.data.deletedSequences.length).toBeGreaterThan(0); + expect(truncateResult.data.activeContextTruncated).toBe(false); + } + + const remaining = await service.getHistoryFromLatestBoundary(wsId); + expect(remaining.success).toBe(true); + if (remaining.success) { + const activeAssistant = remaining.data.find((msg) => msg.id === "assistant-active"); + expect(activeAssistant?.metadata?.contextUsage).toMatchObject({ inputTokens: 95_000 }); + } + }); + it("keeps the archive intact on a no-op percentage truncation", async () => { await appendNumberedMessages(service, wsId, 3); await service.appendToHistory(wsId, boundaryMessage("boundary-1", 1)); @@ -2059,7 +2088,10 @@ describe("HistoryService", () => { const truncateResult = await service.truncateHistory(wsId, 0); expect(truncateResult.success).toBe(true); if (truncateResult.success) { - expect(truncateResult.data).toEqual([]); + expect(truncateResult.data).toEqual({ + deletedSequences: [], + activeContextTruncated: false, + }); } // No-op truncation must not collapse the archive back into chat.jsonl. diff --git a/src/node/services/historyService.ts b/src/node/services/historyService.ts index 4bcc8696cf..8e2d5c1cb0 100644 --- a/src/node/services/historyService.ts +++ b/src/node/services/historyService.ts @@ -20,6 +20,7 @@ import { safeStringifyForCounting } from "@/common/utils/tokens/safeStringifyFor import { normalizeLegacyMuxMetadata } from "@/node/utils/messages/legacy"; import { CONTEXT_BOUNDARY_KINDS } from "@/common/constants/contextBoundary"; import { + findLatestContextBoundaryIndex, isDurableCompactedMarker, isDurableContextBoundaryMarker, } from "@/common/utils/messages/compactionBoundary"; @@ -1917,12 +1918,15 @@ export class HistoryService { * Truncate history by removing approximately the given percentage of tokens from the beginning * @param workspaceId The workspace ID * @param percentage Percentage to truncate (0.0 to 1.0). 1.0 = delete all - * @returns Result containing array of deleted historySequence numbers + * @returns Result with deleted historySequence numbers plus whether the cut + * reached the active provider-context window (at or past the latest + * durable boundary). Callers use that flag to decide whether cached + * context usage is stale. */ async truncateHistory( workspaceId: string, percentage: number - ): Promise> { + ): Promise> { return this.fileLocks.withLock(workspaceId, async () => { try { const historyPath = this.getChatHistoryPath(workspaceId); @@ -1944,7 +1948,7 @@ export class HistoryService { // Reset sequence counter when clearing history this.sequenceCounters.set(workspaceId, 0); - return Ok(deletedSequences); + return Ok({ deletedSequences, activeContextTruncated: true }); } // Structural rewrite requires full history content (oldest rows live in @@ -1955,7 +1959,7 @@ export class HistoryService { ...(await this.readChatHistory(workspaceId)), ]; if (messages.length === 0) { - return Ok([]); // Nothing to truncate + return Ok({ deletedSequences: [], activeContextTruncated: false }); // Nothing to truncate } // Get tokenizer for counting (use a default model) @@ -1989,7 +1993,7 @@ export class HistoryService { // rewrite anything — collapsing the archive back into chat.jsonl would // undo rotation and put lifetime history back on the hot path. if (removeCount === 0) { - return Ok([]); + return Ok({ deletedSequences: [], activeContextTruncated: false }); } // If we're removing all messages, use fast path @@ -2000,16 +2004,24 @@ export class HistoryService { const deletedSequences = messages .map((msg) => msg.metadata?.historySequence) .filter((s): s is number => isNonNegativeInteger(s)); - return Ok(deletedSequences); + return Ok({ deletedSequences, activeContextTruncated: true }); } + // The cut changes the active provider context only when it removes the + // latest durable boundary (or any row after it). A cut confined to + // sealed pre-boundary rows leaves the provider window and its usage + // snapshots valid. + const latestBoundaryIndex = findLatestContextBoundaryIndex(messages); + const activeContextTruncated = + latestBoundaryIndex >= 0 ? removeCount > latestBoundaryIndex : true; + // Keep messages after removeCount const remainingMessages = messages.slice(removeCount).map((msg) => { // Retained rows' contextUsage measured the pre-truncation context // (including the removed prefix). Persisting it would reseed stale // auto-compaction pressure, even across app restarts. Strip it; the // next provider response reports fresh usage. - if (msg.metadata?.contextUsage === undefined) { + if (!activeContextTruncated || msg.metadata?.contextUsage === undefined) { return msg; } return { @@ -2065,7 +2077,7 @@ export class HistoryService { ); this.sequenceCounters.set(workspaceId, nextSeq); - return Ok(deletedSequences); + return Ok({ deletedSequences, activeContextTruncated }); } catch (error) { const message = getErrorMessage(error); return Err(`Failed to truncate history: ${message}`); @@ -2078,7 +2090,7 @@ export class HistoryService { if (!result.success) { return Err(result.error); } - return Ok(result.data); + return Ok(result.data.deletedSequences); } /** diff --git a/src/node/services/workspaceService.test.ts b/src/node/services/workspaceService.test.ts index 1365966da0..a54645cc51 100644 --- a/src/node/services/workspaceService.test.ts +++ b/src/node/services/workspaceService.test.ts @@ -3905,7 +3905,9 @@ describe("WorkspaceService truncateHistory goal acknowledgment", () => { bashMonitorRecoveryPromise: Promise; }; internal.bashMonitorRecoveryPromise = recovery.promise; - const truncateSpy = spyOn(historyService, "truncateHistory").mockResolvedValue(Ok([])); + const truncateSpy = spyOn(historyService, "truncateHistory").mockResolvedValue( + Ok({ deletedSequences: [], activeContextTruncated: true }) + ); try { const clearPromise = workspaceService.truncateHistory(workspaceId, 1.0); diff --git a/src/node/services/workspaceService.ts b/src/node/services/workspaceService.ts index 42cd4b7be2..6ff822ff29 100644 --- a/src/node/services/workspaceService.ts +++ b/src/node/services/workspaceService.ts @@ -9669,9 +9669,11 @@ export class WorkspaceService extends EventEmitter { // Invalidate usage inside the truncate step, immediately after the rewrite // commits: later wrapper steps (monitor-wake restoration) can fail after // chat.jsonl has already changed, and stale usage must not survive that. + // A cut confined to sealed pre-boundary rows leaves the active provider + // context (and its usage snapshots) valid, so it must not invalidate. const truncate = async () => { const result = await this.historyService.truncateHistory(workspaceId, effectivePercentage); - if (result.success && effectivePercentage > 0) { + if (result.success && result.data.activeContextTruncated) { this.sessions.get(workspaceId)?.clearUsageState(); } return result; @@ -9686,7 +9688,7 @@ export class WorkspaceService extends EventEmitter { return Err(truncateResult.error); } - const deletedSequences = truncateResult.data; + const deletedSequences = truncateResult.data.deletedSequences; if (deletedSequences.length > 0) { const deleteMessage: DeleteMessage = { type: "delete", From fc725aec4e0774421ce97296013997659d944a51 Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Fri, 7 Aug 2026 21:01:42 +0000 Subject: [PATCH 12/36] fix: re-enable usage seeding when an edit restores a pre-reset prefix --- .../agentSession.autoCompaction.test.ts | 50 +++++++++++++++++++ src/node/services/agentSession.ts | 20 ++++---- 2 files changed, 59 insertions(+), 11 deletions(-) diff --git a/src/node/services/agentSession.autoCompaction.test.ts b/src/node/services/agentSession.autoCompaction.test.ts index 77b469e075..77dee552c0 100644 --- a/src/node/services/agentSession.autoCompaction.test.ts +++ b/src/node/services/agentSession.autoCompaction.test.ts @@ -1006,6 +1006,56 @@ describe("AgentSession on-send auto-compaction snapshot deferral", () => { session.dispose(); }); + test("editing a pre-reset turn re-enables seeding for the restored prefix", async () => { + const workspaceId = "ws-pre-reset-edit-reenables-seeding"; + const { session, historyService } = await createSessionHarness({ workspaceId }); + + const rows = [ + createMuxMessage("user-1", "user", "first prompt", { timestamp: Date.now() - 5_000 }), + createMuxMessage("assistant-1", "assistant", "first reply", { + timestamp: Date.now() - 4_000, + model: "openai:gpt-4o", + contextUsage: { inputTokens: 95_000, outputTokens: 100, totalTokens: 95_100 }, + }), + createMuxMessage("user-2", "user", "second prompt", { timestamp: Date.now() - 3_000 }), + createMuxMessage("reset-boundary", "assistant", "context reset", { + timestamp: Date.now() - 2_000, + compacted: "user", + compactionBoundary: true, + compactionEpoch: 1, + }), + ]; + for (const row of rows) { + expect((await historyService.appendToHistory(workspaceId, row)).success).toBe(true); + } + + // The reset suppressed seeding (resetContext() calls clearUsageState()). + session.clearUsageState(); + + // Editing a pre-reset turn truncates the boundary away and restores the + // near-limit prefix as the active context. + const editResult = await session.sendMessage("second prompt, edited", { + model: "openai:gpt-4o", + agentId: "exec", + editMessageId: "user-2", + }); + expect(editResult.success).toBe(true); + + // The edit stream reported no usage; the next send must still seed from + // the restored assistant-1 row despite the earlier reset suppression. + const seenUsages = installUsageCapturingMonitor(session); + const result = await session.sendMessage("post-edit send", { + model: "openai:gpt-4o", + agentId: "exec", + }); + expect(result.success).toBe(true); + expect(seenUsages).toHaveLength(1); + const seeded = seenUsages[0] as AutoCompactionUsageState | undefined; + expect(seeded?.lastContextUsage).toBeDefined(); + + session.dispose(); + }); + test("heartbeat reset rollback re-enables usage seeding for the queued turn", async () => { const workspaceId = "ws-heartbeat-rollback-reenables-seeding"; const { session, historyService } = await createSessionHarness({ workspaceId }); diff --git a/src/node/services/agentSession.ts b/src/node/services/agentSession.ts index 8d5945d2de..3dffc22f4b 100644 --- a/src/node/services/agentSession.ts +++ b/src/node/services/agentSession.ts @@ -2739,9 +2739,9 @@ export class AgentSession { truncateTargetId ); if (truncateResult.success) { - // Edits cut a suffix; the retained prefix's persisted usage is still - // valid, so keep history seeding available for the edited send chain. - this.clearUsageState({ preserveHistorySeeding: true }); + // Edits cut a suffix; the retained prefix's persisted usage is valid, + // so re-enable history seeding even if a prior rewrite suppressed it. + this.clearUsageState({ reenableHistorySeeding: true }); } else { const isMissingEditTarget = truncateResult.error.includes("Message with ID") && @@ -3434,16 +3434,14 @@ export class AgentSession { * contextUsage still counts removed tokens; re-seeding those would restore * the same stale value. * - * Pass preserveHistorySeeding for suffix-only rewrites (message edits): the - * retained prefix's persisted contextUsage still describes the active - * context, and seeding from it keeps on-send compaction armed for - * near-limit prefixes. + * Pass reenableHistorySeeding for suffix-only rewrites (message edits): the + * retained prefix becomes the active context and its persisted contextUsage + * is valid, so seeding is re-enabled even when an earlier rewrite (e.g. a + * context reset whose boundary the edit just truncated away) suppressed it. */ - clearUsageState(options?: { preserveHistorySeeding?: boolean }): void { + clearUsageState(options?: { reenableHistorySeeding?: boolean }): void { this.lastUsageState = undefined; - if (options?.preserveHistorySeeding !== true) { - this.usageSeedingSuppressed = true; - } + this.usageSeedingSuppressed = options?.reenableHistorySeeding !== true; } /** From f0476c2e191f9f69001b0f67b5d8da0a6814c367 Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Fri, 7 Aug 2026 21:10:55 +0000 Subject: [PATCH 13/36] fix: keep usage valid when a cut ends at a provider-invisible reset boundary --- src/node/services/historyService.test.ts | 64 ++++++++++++++++++++++++ src/node/services/historyService.ts | 21 +++++--- 2 files changed, 79 insertions(+), 6 deletions(-) diff --git a/src/node/services/historyService.test.ts b/src/node/services/historyService.test.ts index f08897a318..fa3817000e 100644 --- a/src/node/services/historyService.test.ts +++ b/src/node/services/historyService.test.ts @@ -2078,6 +2078,70 @@ describe("HistoryService", () => { } }); + it("preserves contextUsage when the cut ends at a reset boundary", async () => { + // Reset boundaries are provider-invisible: the provider window starts + // after them, so deleting the marker (and nothing later) leaves the + // active context and its usage snapshots unchanged. + await service.appendToHistory( + wsId, + createMuxMessage("reset-boundary", "assistant", "", { + contextBoundaryKind: CONTEXT_BOUNDARY_KINDS.RESET, + }) + ); + await service.appendToHistory(wsId, createMuxMessage("user-active", "user", "active prompt")); + await service.appendToHistory( + wsId, + createMuxMessage("assistant-active", "assistant", "active reply", { + contextUsage: { inputTokens: 95_000, outputTokens: 100, totalTokens: 95_100 }, + model: "openai:gpt-4o", + }) + ); + + const truncateResult = await service.truncateHistory(wsId, 0.1); + expect(truncateResult.success).toBe(true); + if (truncateResult.success) { + expect(truncateResult.data.deletedSequences.length).toBe(1); + expect(truncateResult.data.activeContextTruncated).toBe(false); + } + + const remaining = await service.getHistoryFromLatestBoundary(wsId); + expect(remaining.success).toBe(true); + if (remaining.success) { + expect(remaining.data.find((msg) => msg.id === "reset-boundary")).toBeUndefined(); + const activeAssistant = remaining.data.find((msg) => msg.id === "assistant-active"); + expect(activeAssistant?.metadata?.contextUsage).toMatchObject({ inputTokens: 95_000 }); + } + }); + + it("strips contextUsage when the cut removes a compaction boundary", async () => { + // Mirror of the reset case: compaction boundaries carry the summary the + // provider sees, so deleting the boundary row changes the active context. + await service.appendToHistory(wsId, boundaryMessage("boundary-1", 1)); + await service.appendToHistory(wsId, createMuxMessage("user-active", "user", "active prompt")); + await service.appendToHistory( + wsId, + createMuxMessage("assistant-active", "assistant", "active reply", { + contextUsage: { inputTokens: 95_000, outputTokens: 100, totalTokens: 95_100 }, + model: "openai:gpt-4o", + }) + ); + + const truncateResult = await service.truncateHistory(wsId, 0.1); + expect(truncateResult.success).toBe(true); + if (truncateResult.success) { + expect(truncateResult.data.deletedSequences.length).toBe(1); + expect(truncateResult.data.activeContextTruncated).toBe(true); + } + + const remaining = await service.getHistoryFromLatestBoundary(wsId); + expect(remaining.success).toBe(true); + if (remaining.success) { + const activeAssistant = remaining.data.find((msg) => msg.id === "assistant-active"); + expect(activeAssistant).toBeDefined(); + expect(activeAssistant?.metadata?.contextUsage).toBeUndefined(); + } + }); + it("keeps the archive intact on a no-op percentage truncation", async () => { await appendNumberedMessages(service, wsId, 3); await service.appendToHistory(wsId, boundaryMessage("boundary-1", 1)); diff --git a/src/node/services/historyService.ts b/src/node/services/historyService.ts index 8e2d5c1cb0..81dc855716 100644 --- a/src/node/services/historyService.ts +++ b/src/node/services/historyService.ts @@ -21,6 +21,7 @@ import { normalizeLegacyMuxMetadata } from "@/node/utils/messages/legacy"; import { CONTEXT_BOUNDARY_KINDS } from "@/common/constants/contextBoundary"; import { findLatestContextBoundaryIndex, + getContextBoundaryKind, isDurableCompactedMarker, isDurableContextBoundaryMarker, } from "@/common/utils/messages/compactionBoundary"; @@ -2007,13 +2008,21 @@ export class HistoryService { return Ok({ deletedSequences, activeContextTruncated: true }); } - // The cut changes the active provider context only when it removes the - // latest durable boundary (or any row after it). A cut confined to - // sealed pre-boundary rows leaves the provider window and its usage - // snapshots valid. + // The cut changes the active provider context only when it removes a + // row inside the provider window. Compaction boundaries are + // provider-visible (the row carries the summary), so the window starts + // AT the boundary; reset boundaries are provider-invisible markers, so + // the window starts AFTER them and deleting the marker itself changes + // nothing the provider sees. Cuts confined before the window leave its + // usage snapshots valid. const latestBoundaryIndex = findLatestContextBoundaryIndex(messages); - const activeContextTruncated = - latestBoundaryIndex >= 0 ? removeCount > latestBoundaryIndex : true; + const activeContextStart = + latestBoundaryIndex < 0 + ? 0 + : getContextBoundaryKind(messages[latestBoundaryIndex]) === CONTEXT_BOUNDARY_KINDS.RESET + ? latestBoundaryIndex + 1 + : latestBoundaryIndex; + const activeContextTruncated = removeCount > activeContextStart; // Keep messages after removeCount const remainingMessages = messages.slice(removeCount).map((msg) => { From c273f9812acd97991626f768a0ce74d83e9429aa Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Fri, 7 Aug 2026 21:23:53 +0000 Subject: [PATCH 14/36] fix: keep usage seeding enabled after compaction so the fresh boundary estimate arms on-send checks --- .../agentSession.autoCompaction.test.ts | 102 ++++++++++++++++++ src/node/services/agentSession.ts | 5 +- 2 files changed, 106 insertions(+), 1 deletion(-) diff --git a/src/node/services/agentSession.autoCompaction.test.ts b/src/node/services/agentSession.autoCompaction.test.ts index 77dee552c0..cce594b6be 100644 --- a/src/node/services/agentSession.autoCompaction.test.ts +++ b/src/node/services/agentSession.autoCompaction.test.ts @@ -1056,6 +1056,108 @@ describe("AgentSession on-send auto-compaction snapshot deferral", () => { session.dispose(); }); + test("compaction completion keeps seeding enabled for the fresh boundary estimate", async () => { + const workspaceId = "ws-compaction-completion-keeps-seeding"; + + // Real per-test config: compaction persists pending post-compaction state + // under getSessionDir, which must not leak into other tests' sessions. + const { historyService, config, cleanup } = await createTestHistoryService(); + historyCleanup = cleanup; + + // Near-limit context so the real monitor converts the send into an + // on-send compaction (gpt-4o window 128K, default threshold 85%). + const appendUser = await historyService.appendToHistory( + workspaceId, + createMuxMessage("user-1", "user", "prompt", { timestamp: Date.now() - 2_000 }) + ); + expect(appendUser.success).toBe(true); + const appendAssistant = await historyService.appendToHistory( + workspaceId, + createMuxMessage("assistant-1", "assistant", "reply", { + timestamp: Date.now() - 1_000, + model: "openai:gpt-4o", + contextUsage: { inputTokens: 120_000, outputTokens: 100, totalTokens: 120_100 }, + }) + ); + expect(appendAssistant.success).toBe(true); + + const aiEmitter = new EventEmitter(); + let streamCallCount = 0; + const streamMessage = mock((_request: unknown) => { + streamCallCount += 1; + if (streamCallCount === 1) { + // The compaction stream: a prose summary whose post-compaction + // estimate is small (1_200 system + 800 summary tokens). + aiEmitter.emit("stream-end", { + type: "stream-end", + workspaceId, + messageId: "assistant-compaction-summary", + parts: [{ type: "text", text: "A concise prose summary of the conversation." }], + metadata: { + model: "openai:gpt-4o", + usage: { inputTokens: 120_000, outputTokens: 800, totalTokens: 120_800 }, + systemMessageTokens: 1_200, + }, + }); + } + // The follow-up stream (call 2) reports no usage. + return Promise.resolve(Ok(undefined)); + }); + + const aiService = Object.assign(aiEmitter, { + isStreaming: mock((_workspaceId: string) => false), + stopStream: mock((_workspaceId: string) => Promise.resolve(Ok(undefined))), + // Post-compaction attachment building reads workspace metadata; a + // failure result makes it skip the plan reference and continue. + getWorkspaceMetadata: mock((_workspaceId: string) => + Promise.resolve(Err("no metadata in test")) + ), + streamMessage: streamMessage as unknown as ( + ...args: Parameters + ) => Promise, + }) as unknown as AIService; + + const session = new AgentSession({ + workspaceId, + config, + historyService, + aiService, + initStateManager: new EventEmitter() as unknown as InitStateManager, + backgroundProcessManager: { + cleanup: mock((_workspaceId: string) => Promise.resolve()), + setMessageQueued: mock(() => undefined), + } as unknown as BackgroundProcessManager, + }); + + const result = await session.sendMessage("original request", { + model: "openai:gpt-4o", + agentId: "exec", + }); + expect(result.success).toBe(true); + + // Wait for compaction handling to collapse history and dispatch the follow-up. + const deadline = Date.now() + 1_500; + while (streamCallCount < 2 && Date.now() < deadline) { + await new Promise((resolve) => setTimeout(resolve, 10)); + } + expect(streamCallCount).toBe(2); + + // The follow-up reported no usage, so the next send must seed the boundary + // summary's fresh estimate rather than staying suppressed. + const seenUsages = installUsageCapturingMonitor(session); + const postResult = await session.sendMessage("post-compaction send", { + model: "openai:gpt-4o", + agentId: "exec", + }); + expect(postResult.success).toBe(true); + expect(seenUsages).toHaveLength(1); + const seeded = seenUsages[0] as AutoCompactionUsageState | undefined; + expect(seeded?.lastContextUsage).toBeDefined(); + expect(seeded?.totalTokens).toBe(2_000); + + session.dispose(); + }); + test("heartbeat reset rollback re-enables usage seeding for the queued turn", async () => { const workspaceId = "ws-heartbeat-rollback-reenables-seeding"; const { session, historyService } = await createSessionHarness({ workspaceId }); diff --git a/src/node/services/agentSession.ts b/src/node/services/agentSession.ts index 3dffc22f4b..e580c2bd1c 100644 --- a/src/node/services/agentSession.ts +++ b/src/node/services/agentSession.ts @@ -5224,7 +5224,10 @@ export class AgentSession { // Compaction collapses history to a boundary summary, so prior context-usage snapshots // are stale. Clear them to prevent immediate re-trigger loops on the follow-up turn. - this.clearUsageState(); + // Seeding stays enabled: the boundary read starts at the just-written summary row, + // whose contextUsage is a fresh post-compaction estimate the follow-up needs for + // checkBeforeSend (a large system prompt or summary can stay near the threshold). + this.clearUsageState({ reenableHistorySeeding: true }); if (completedCompactionRequest?.source === "auto-compaction") { this.emitChatEvent({ From 344d7fe5a6b968039bcd4fef2109ddf92b8b43ab Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Fri, 7 Aug 2026 22:20:35 +0000 Subject: [PATCH 15/36] fix: order truncation's two-file rewrite so failures never change the active provider window --- src/node/services/historyService.test.ts | 120 ++++++++++++++++++++++- src/node/services/historyService.ts | 57 ++++++++++- src/node/services/workspaceService.ts | 2 + 3 files changed, 174 insertions(+), 5 deletions(-) diff --git a/src/node/services/historyService.test.ts b/src/node/services/historyService.test.ts index fa3817000e..cda6eaf95f 100644 --- a/src/node/services/historyService.test.ts +++ b/src/node/services/historyService.test.ts @@ -1,4 +1,4 @@ -import { describe, it, expect, beforeEach, afterEach } from "bun:test"; +import { describe, it, expect, beforeEach, afterEach, spyOn } from "bun:test"; import { CONTEXT_BOUNDARY_KINDS } from "@/common/constants/contextBoundary"; import { HistoryService } from "./historyService"; import { Config } from "@/node/config"; @@ -1763,6 +1763,15 @@ describe("HistoryService", () => { return path.join(config.getSessionDir(workspaceId), "chat-archive.jsonl"); } + async function fileExists(filePath: string): Promise { + try { + await fs.access(filePath); + return true; + } catch { + return false; + } + } + it("rotates the sealed prefix into the archive when a boundary is appended", async () => { await appendNumberedMessages(service, wsId, 3); // seq 0..2 await service.appendToHistory(wsId, boundaryMessage("boundary-1", 1)); // seq 3 @@ -2142,6 +2151,115 @@ describe("HistoryService", () => { } }); + it("succeeds despite tombstone-removal failure without resurrecting cut rows", async () => { + await appendNumberedMessages(service, wsId, 12); + await service.appendToHistory(wsId, boundaryMessage("boundary-1", 1)); + await service.appendToHistory( + wsId, + createMuxMessage("assistant-active", "assistant", "active reply") + ); + expect(await fs.readFile(archivePath(wsId), "utf-8")).not.toBe(""); + + // Fail every post-commit archive cleanup (parked tombstone or direct + // archive delete); rotation migration inside the read paths also uses + // fs.rm, so other paths stay real. + const realRm = fs.rm; + const rmSpy = spyOn(fs, "rm").mockImplementation((...args: Parameters) => { + if (args[0] === archivePath(wsId) || args[0] === `${archivePath(wsId)}.trash`) { + return Promise.reject( + Object.assign(new Error("EACCES: permission denied"), { code: "EACCES" }) + ); + } + return realRm(...args); + }); + try { + const truncateResult = await service.truncateHistory(wsId, 0.25); + expect(truncateResult.success).toBe(true); + if (truncateResult.success) { + expect(truncateResult.data.deletedSequences.length).toBeGreaterThan(0); + + const remaining = await readJsonlFile(chatPath(wsId)); + const remainingIds = new Set(remaining.map((msg) => msg.id)); + for (const seq of truncateResult.data.deletedSequences) { + expect(remaining.some((msg) => msg.metadata?.historySequence === seq)).toBe(false); + } + expect(remainingIds.has("assistant-active")).toBe(true); + // The archive was parked, not left in place, so cut rows cannot + // resurrect through the archive read path. + expect(await fileExists(archivePath(wsId))).toBe(false); + } + } finally { + rmSpy.mockRestore(); + } + }); + + it("leaves history untouched when parking the archive fails", async () => { + await appendNumberedMessages(service, wsId, 12); + await service.appendToHistory(wsId, boundaryMessage("boundary-1", 1)); + await service.appendToHistory( + wsId, + createMuxMessage("assistant-active", "assistant", "active reply") + ); + + const chatBefore = await fs.readFile(chatPath(wsId), "utf-8"); + const archiveBefore = await fs.readFile(archivePath(wsId), "utf-8"); + + // Fail only the archive-parking rename; rotation migration inside the + // read paths also uses fs.rename. + const realRename = fs.rename; + const renameSpy = spyOn(fs, "rename").mockImplementation( + (...args: Parameters) => { + if (args[0] === archivePath(wsId)) { + return Promise.reject( + Object.assign(new Error("EACCES: permission denied"), { code: "EACCES" }) + ); + } + return realRename(...args); + } + ); + try { + const truncateResult = await service.truncateHistory(wsId, 0.25); + expect(truncateResult.success).toBe(false); + // A failed truncation must not have mutated either history file, so + // the caller's success-only usage invalidation stays correct. + expect(await fs.readFile(chatPath(wsId), "utf-8")).toBe(chatBefore); + expect(await fs.readFile(archivePath(wsId), "utf-8")).toBe(archiveBefore); + } finally { + renameSpy.mockRestore(); + } + }); + + it("keeps the active file intact when full clear fails on the second delete", async () => { + await appendNumberedMessages(service, wsId, 3); + await service.appendToHistory(wsId, boundaryMessage("boundary-1", 1)); + await service.appendToHistory( + wsId, + createMuxMessage("assistant-active", "assistant", "active reply") + ); + + const chatBefore = await fs.readFile(chatPath(wsId), "utf-8"); + + // Full clear removes the archive first: if deleting chat.jsonl then + // fails, the active provider window is still intact and Err is correct. + const realRm = fs.rm; + const rmSpy = spyOn(fs, "rm").mockImplementation((...args: Parameters) => { + if (args[0] === chatPath(wsId)) { + return Promise.reject( + Object.assign(new Error("EACCES: permission denied"), { code: "EACCES" }) + ); + } + return realRm(...args); + }); + try { + const truncateResult = await service.truncateHistory(wsId, 1.0); + expect(truncateResult.success).toBe(false); + expect(await fs.readFile(chatPath(wsId), "utf-8")).toBe(chatBefore); + expect(await fileExists(archivePath(wsId))).toBe(false); + } finally { + rmSpy.mockRestore(); + } + }); + it("keeps the archive intact on a no-op percentage truncation", async () => { await appendNumberedMessages(service, wsId, 3); await service.appendToHistory(wsId, boundaryMessage("boundary-1", 1)); diff --git a/src/node/services/historyService.ts b/src/node/services/historyService.ts index 81dc855716..0e411e4d04 100644 --- a/src/node/services/historyService.ts +++ b/src/node/services/historyService.ts @@ -1944,8 +1944,11 @@ export class HistoryService { .map((msg) => msg.metadata?.historySequence) .filter((s): s is number => isNonNegativeInteger(s)); - await fs.rm(historyPath, { force: true }); + // Archive first: it holds only sealed pre-boundary rows, so if the + // second rm fails the active provider window is still intact and the + // caller's success-only usage invalidation remains correct. await fs.rm(archivePath, { force: true }); + await fs.rm(historyPath, { force: true }); // Reset sequence counter when clearing history this.sequenceCounters.set(workspaceId, 0); @@ -2052,9 +2055,55 @@ export class HistoryService { // boundary write re-seals it. const historyEntries = this.serializeHistoryEntries(remainingMessages, workspaceId); - // Atomic write prevents corruption if app crashes mid-write - await writeFileAtomic(historyPath, historyEntries); - await fs.rm(archivePath, { force: true }); + // Order the two-file rewrite so no failure can change the active + // provider window yet still return Err (the caller invalidates usage + // only on success). Park the archive under a tombstone name (atomic + // rename), commit chat.jsonl atomically, then discard the tombstone: + // a pre-commit failure rolls the archive back and leaves history + // untouched, while a post-commit tombstone-removal failure only strays + // a file that no read path consumes. + const tombstonePath = `${archivePath}.trash`; + let archiveParked = false; + try { + await fs.rename(archivePath, tombstonePath); + archiveParked = true; + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== "ENOENT") { + throw error; + } + } + + try { + // Atomic write prevents corruption if app crashes mid-write + await writeFileAtomic(historyPath, historyEntries); + } catch (error) { + if (archiveParked) { + try { + await fs.rename(tombstonePath, archivePath); + } catch (rollbackError) { + // Sealed pre-boundary rows stay parked in the tombstone; the + // active window in chat.jsonl is still intact. + log.error("Failed to restore parked chat archive after truncation write failure", { + workspaceId, + tombstonePath, + error: rollbackError, + }); + } + } + throw error; + } + + if (archiveParked) { + try { + await fs.rm(tombstonePath, { force: true }); + } catch (error) { + log.warn("Failed to remove parked chat archive after truncation", { + workspaceId, + tombstonePath, + error, + }); + } + } this.sealedRotationChecked.delete(workspaceId); // Update sequence counter to continue from where we are. diff --git a/src/node/services/workspaceService.ts b/src/node/services/workspaceService.ts index 6ff822ff29..389b1a73c5 100644 --- a/src/node/services/workspaceService.ts +++ b/src/node/services/workspaceService.ts @@ -9671,6 +9671,8 @@ export class WorkspaceService extends EventEmitter { // chat.jsonl has already changed, and stale usage must not survive that. // A cut confined to sealed pre-boundary rows leaves the active provider // context (and its usage snapshots) valid, so it must not invalidate. + // The success-only guard is safe because HistoryService.truncateHistory + // orders its two-file rewrite so no failure changes the active window. const truncate = async () => { const result = await this.historyService.truncateHistory(workspaceId, effectivePercentage); if (result.success && result.data.activeContextTruncated) { From 499940409768cd58a5e46834230b99315838a542 Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Fri, 7 Aug 2026 22:20:35 +0000 Subject: [PATCH 16/36] test: restore the real Dialog module after every suite that stubs it --- .../__tests__/ProjectDeleteConfirmationModal.test.tsx | 10 +++++++++- .../SshPromptDialog/SshPromptDialog.test.tsx | 11 ++++++++++- .../features/Analytics/SavedQuerySqlDialog.test.tsx | 10 +++++++++- .../features/RightSidebar/Memory/MemoryTab.test.tsx | 10 +++++++++- .../features/RightSidebar/PlanFileDialog.test.tsx | 10 +++++++++- .../features/Tools/WorkflowRunToolCall.test.tsx | 10 +++++++++- 6 files changed, 55 insertions(+), 6 deletions(-) diff --git a/src/browser/components/ProjectDeleteConfirmationModal/__tests__/ProjectDeleteConfirmationModal.test.tsx b/src/browser/components/ProjectDeleteConfirmationModal/__tests__/ProjectDeleteConfirmationModal.test.tsx index 15b958d493..c3f583ece6 100644 --- a/src/browser/components/ProjectDeleteConfirmationModal/__tests__/ProjectDeleteConfirmationModal.test.tsx +++ b/src/browser/components/ProjectDeleteConfirmationModal/__tests__/ProjectDeleteConfirmationModal.test.tsx @@ -1,11 +1,16 @@ import "../../../../../tests/ui/dom"; -import { afterEach, beforeEach, describe, expect, it, mock } from "bun:test"; +import { afterAll, afterEach, beforeEach, describe, expect, it, mock } from "bun:test"; import { cleanup, fireEvent, render } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; import type { ComponentProps, KeyboardEvent, ReactNode } from "react"; import { installDom } from "../../../../../tests/ui/dom"; +import * as RealDialogModule from "@/browser/components/Dialog/Dialog"; + +// bun test shares module mocks across suites; leaking this stub would hide later dialogs. +const realDialogExports = { ...RealDialogModule }; + void mock.module("@/browser/components/Dialog/Dialog", () => ({ Dialog: (props: { open: boolean; @@ -223,3 +228,6 @@ describe("ProjectDeleteConfirmationModal", () => { expect(reopenedInput.value).toBe(""); }); }); +afterAll(() => { + void mock.module("@/browser/components/Dialog/Dialog", () => realDialogExports); +}); diff --git a/src/browser/components/SshPromptDialog/SshPromptDialog.test.tsx b/src/browser/components/SshPromptDialog/SshPromptDialog.test.tsx index eb94320112..8e7044e278 100644 --- a/src/browser/components/SshPromptDialog/SshPromptDialog.test.tsx +++ b/src/browser/components/SshPromptDialog/SshPromptDialog.test.tsx @@ -1,6 +1,6 @@ import "../../../../tests/ui/dom"; -import { afterEach, beforeEach, describe, expect, it, mock } from "bun:test"; +import { afterAll, afterEach, beforeEach, describe, expect, it, mock } from "bun:test"; import { act, cleanup, fireEvent, render, waitFor } from "@testing-library/react"; import type { SshPromptEvent, SshPromptRequest } from "@/common/orpc/schemas/ssh"; import { @@ -10,6 +10,11 @@ import { import type { ReactNode } from "react"; import { installDom } from "../../../../tests/ui/dom"; +import * as RealDialogModule from "@/browser/components/Dialog/Dialog"; + +// bun test shares module mocks across suites; leaking this stub would hide later dialogs. +const realDialogExports = { ...RealDialogModule }; + // Self-contained dialog stub — bun's mock.module is process-global, so other // test files may register incomplete Dialog stubs that omit // DialogDescription/DialogFooter/Warning*. Our own complete mock prevents @@ -333,3 +338,7 @@ describe("SshPromptDialog", () => { expect(queryByRole("button", { name: "Reject" })).toBeNull(); }); }); + +afterAll(() => { + void mock.module("@/browser/components/Dialog/Dialog", () => realDialogExports); +}); diff --git a/src/browser/features/Analytics/SavedQuerySqlDialog.test.tsx b/src/browser/features/Analytics/SavedQuerySqlDialog.test.tsx index fba134193c..05c8837813 100644 --- a/src/browser/features/Analytics/SavedQuerySqlDialog.test.tsx +++ b/src/browser/features/Analytics/SavedQuerySqlDialog.test.tsx @@ -1,8 +1,13 @@ import type { ReactNode } from "react"; -import { afterEach, beforeEach, describe, expect, mock, test } from "bun:test"; +import { afterAll, afterEach, beforeEach, describe, expect, mock, test } from "bun:test"; import { GlobalWindow } from "happy-dom"; import { cleanup, fireEvent, render } from "@testing-library/react"; +import * as RealDialogModule from "@/browser/components/Dialog/Dialog"; + +// bun test shares module mocks across suites; leaking this stub would hide later dialogs. +const realDialogExports = { ...RealDialogModule }; + void mock.module("@/browser/components/Dialog/Dialog", () => ({ Dialog: (props: { open: boolean; children: ReactNode }) => props.open ?
{props.children}
: null, @@ -87,3 +92,6 @@ describe("SavedQuerySqlDialog", () => { expect(view.onSave).toHaveBeenCalledTimes(1); }); }); +afterAll(() => { + void mock.module("@/browser/components/Dialog/Dialog", () => realDialogExports); +}); diff --git a/src/browser/features/RightSidebar/Memory/MemoryTab.test.tsx b/src/browser/features/RightSidebar/Memory/MemoryTab.test.tsx index 68a5432f5d..98c8828a61 100644 --- a/src/browser/features/RightSidebar/Memory/MemoryTab.test.tsx +++ b/src/browser/features/RightSidebar/Memory/MemoryTab.test.tsx @@ -3,7 +3,7 @@ // if `document` was undefined when react-dom loaded. import "../../../../../tests/ui/dom"; -import { afterEach, beforeEach, describe, expect, mock, test } from "bun:test"; +import { afterAll, afterEach, beforeEach, describe, expect, mock, test } from "bun:test"; import { cleanup, fireEvent, render, waitFor } from "@testing-library/react"; import { createContext, type ReactNode } from "react"; import { installDom } from "../../../../../tests/ui/dom"; @@ -189,6 +189,11 @@ void mock.module("@/browser/hooks/useExperiments", () => ({ experimentId === EXPERIMENT_IDS.MEMORY_CONSOLIDATION, })); +import * as RealDialogModule from "@/browser/components/Dialog/Dialog"; + +// bun test shares module mocks across suites; leaking this stub would hide later dialogs. +const realDialogExports = { ...RealDialogModule }; + // The delete flow confirms through ConfirmationModal, which renders via a // Radix Dialog portal that happy-dom cannot see. Mock the Dialog primitives // to render inline so the real confirm/cancel behavior stays under test. @@ -581,3 +586,6 @@ describe("MemoryTab", () => { }); }); }); +afterAll(() => { + void mock.module("@/browser/components/Dialog/Dialog", () => realDialogExports); +}); diff --git a/src/browser/features/RightSidebar/PlanFileDialog.test.tsx b/src/browser/features/RightSidebar/PlanFileDialog.test.tsx index 2aec74e286..8dbe84e8b4 100644 --- a/src/browser/features/RightSidebar/PlanFileDialog.test.tsx +++ b/src/browser/features/RightSidebar/PlanFileDialog.test.tsx @@ -1,5 +1,5 @@ import type { ReactNode } from "react"; -import { afterEach, beforeEach, describe, expect, mock, test } from "bun:test"; +import { afterAll, afterEach, beforeEach, describe, expect, mock, test } from "bun:test"; import { GlobalWindow } from "happy-dom"; import { cleanup, render, waitFor } from "@testing-library/react"; @@ -15,6 +15,11 @@ interface MockApiClient { let mockApi: MockApiClient | null = null; +import * as RealDialogModule from "@/browser/components/Dialog/Dialog"; + +// bun test shares module mocks across suites; leaking this stub would hide later dialogs. +const realDialogExports = { ...RealDialogModule }; + void mock.module("@/browser/components/Dialog/Dialog", () => ({ Dialog: (props: { open: boolean; children: ReactNode }) => props.open ?
{props.children}
: null, @@ -148,3 +153,6 @@ describe("PlanFileDialog", () => { }); }); }); +afterAll(() => { + void mock.module("@/browser/components/Dialog/Dialog", () => realDialogExports); +}); diff --git a/src/browser/features/Tools/WorkflowRunToolCall.test.tsx b/src/browser/features/Tools/WorkflowRunToolCall.test.tsx index 434f3b9842..4c1b892cdf 100644 --- a/src/browser/features/Tools/WorkflowRunToolCall.test.tsx +++ b/src/browser/features/Tools/WorkflowRunToolCall.test.tsx @@ -1,5 +1,5 @@ /* eslint-disable @typescript-eslint/require-await */ -import { afterEach, beforeEach, describe, expect, mock, test } from "bun:test"; +import { afterAll, afterEach, beforeEach, describe, expect, mock, test } from "bun:test"; import { GlobalWindow } from "happy-dom"; import { cleanup, fireEvent, render, waitFor } from "@testing-library/react"; import { @@ -55,6 +55,11 @@ interface MockDialogTriggerChildProps { "aria-haspopup"?: "dialog"; } +import * as RealDialogModule from "@/browser/components/Dialog/Dialog"; + +// bun test shares module mocks across suites; leaking this stub would hide later dialogs. +const realDialogExports = { ...RealDialogModule }; + void mock.module("@/browser/components/Dialog/Dialog", () => ({ Dialog: (props: { open: boolean; @@ -3940,3 +3945,6 @@ describe("WorkflowRunToolCall", () => { expect(view.queryByText("completed")).toBeNull(); }); }); +afterAll(() => { + void mock.module("@/browser/components/Dialog/Dialog", () => realDialogExports); +}); From 626a0d8060205325c518d18341725ca54eb848eb Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Fri, 7 Aug 2026 22:31:00 +0000 Subject: [PATCH 17/36] fix: preserve usage when a cut removes only provider-ineligible active rows --- src/node/services/historyService.test.ts | 79 ++++++++++++++++++++++++ src/node/services/historyService.ts | 18 +++--- 2 files changed, 90 insertions(+), 7 deletions(-) diff --git a/src/node/services/historyService.test.ts b/src/node/services/historyService.test.ts index cda6eaf95f..64706cfd01 100644 --- a/src/node/services/historyService.test.ts +++ b/src/node/services/historyService.test.ts @@ -2122,6 +2122,85 @@ describe("HistoryService", () => { } }); + it("preserves contextUsage when the cut removes only provider-ineligible active rows", async () => { + // The reasoning-only assistant turn after the reset is never replayed to + // the provider, so removing it (plus the sealed prefix and the marker) + // leaves the provider request and its usage snapshot unchanged. + await appendNumberedMessages(service, wsId, 12); + await service.appendToHistory( + wsId, + createMuxMessage("reset-boundary", "assistant", "", { + contextBoundaryKind: CONTEXT_BOUNDARY_KINDS.RESET, + }) + ); + await service.appendToHistory(wsId, { + ...createMuxMessage("assistant-reasoning-only", "assistant", ""), + parts: [{ type: "reasoning", text: `internal deliberation ${"x".repeat(2_000)}` }], + }); + await service.appendToHistory(wsId, createMuxMessage("user-active", "user", "prompt")); + await service.appendToHistory( + wsId, + createMuxMessage("assistant-active", "assistant", "active reply", { + contextUsage: { inputTokens: 95_000, outputTokens: 100, totalTokens: 95_100 }, + model: "openai:gpt-4o", + }) + ); + + // Half the token mass sits in the sealed prefix plus the large + // reasoning row, so a 50% cut ends inside the reasoning row. + const truncateResult = await service.truncateHistory(wsId, 0.5); + expect(truncateResult.success).toBe(true); + if (truncateResult.success) { + expect(truncateResult.data.activeContextTruncated).toBe(false); + } + + const remaining = await service.getHistoryFromLatestBoundary(wsId); + expect(remaining.success).toBe(true); + if (remaining.success) { + expect(remaining.data.find((msg) => msg.id === "assistant-reasoning-only")).toBeUndefined(); + const activeAssistant = remaining.data.find((msg) => msg.id === "assistant-active"); + expect(activeAssistant?.metadata?.contextUsage).toMatchObject({ inputTokens: 95_000 }); + } + }); + + it("strips contextUsage when the cut removes an eligible post-reset row", async () => { + // Mirror of the ineligible case: a replayable user turn in the same + // position must still invalidate usage when removed. + await appendNumberedMessages(service, wsId, 12); + await service.appendToHistory( + wsId, + createMuxMessage("reset-boundary", "assistant", "", { + contextBoundaryKind: CONTEXT_BOUNDARY_KINDS.RESET, + }) + ); + await service.appendToHistory( + wsId, + createMuxMessage("user-removed", "user", `large replayable prompt ${"x".repeat(2_000)}`) + ); + await service.appendToHistory(wsId, createMuxMessage("user-active", "user", "prompt")); + await service.appendToHistory( + wsId, + createMuxMessage("assistant-active", "assistant", "active reply", { + contextUsage: { inputTokens: 95_000, outputTokens: 100, totalTokens: 95_100 }, + model: "openai:gpt-4o", + }) + ); + + const truncateResult = await service.truncateHistory(wsId, 0.5); + expect(truncateResult.success).toBe(true); + if (truncateResult.success) { + expect(truncateResult.data.activeContextTruncated).toBe(true); + } + + const remaining = await service.getHistoryFromLatestBoundary(wsId); + expect(remaining.success).toBe(true); + if (remaining.success) { + expect(remaining.data.find((msg) => msg.id === "user-removed")).toBeUndefined(); + const activeAssistant = remaining.data.find((msg) => msg.id === "assistant-active"); + expect(activeAssistant?.metadata?.contextUsage).toBeUndefined(); + } + }); + it("strips contextUsage when the cut removes a compaction boundary", async () => { // Mirror of the reset case: compaction boundaries carry the summary the // provider sees, so deleting the boundary row changes the active context. diff --git a/src/node/services/historyService.ts b/src/node/services/historyService.ts index 0e411e4d04..194b66e87f 100644 --- a/src/node/services/historyService.ts +++ b/src/node/services/historyService.ts @@ -22,6 +22,7 @@ import { CONTEXT_BOUNDARY_KINDS } from "@/common/constants/contextBoundary"; import { findLatestContextBoundaryIndex, getContextBoundaryKind, + hasProviderEligibleMessages, isDurableCompactedMarker, isDurableContextBoundaryMarker, } from "@/common/utils/messages/compactionBoundary"; @@ -2012,12 +2013,13 @@ export class HistoryService { } // The cut changes the active provider context only when it removes a - // row inside the provider window. Compaction boundaries are - // provider-visible (the row carries the summary), so the window starts - // AT the boundary; reset boundaries are provider-invisible markers, so - // the window starts AFTER them and deleting the marker itself changes - // nothing the provider sees. Cuts confined before the window leave its - // usage snapshots valid. + // provider-replayable row inside the provider window. Compaction + // boundaries are provider-visible (the row carries the summary), so + // the window starts AT the boundary; reset boundaries are + // provider-invisible markers, so the window starts AFTER them. Rows + // inside the window that requests never replay (e.g. reasoning-only + // assistant turns) leave the request, and therefore the usage + // snapshots measured from it, unchanged when removed. const latestBoundaryIndex = findLatestContextBoundaryIndex(messages); const activeContextStart = latestBoundaryIndex < 0 @@ -2025,7 +2027,9 @@ export class HistoryService { : getContextBoundaryKind(messages[latestBoundaryIndex]) === CONTEXT_BOUNDARY_KINDS.RESET ? latestBoundaryIndex + 1 : latestBoundaryIndex; - const activeContextTruncated = removeCount > activeContextStart; + const activeContextTruncated = hasProviderEligibleMessages( + messages.slice(Math.min(activeContextStart, removeCount), removeCount) + ); // Keep messages after removeCount const remainingMessages = messages.slice(removeCount).map((msg) => { From e843e67d0fb30648e27d546eb2b832466c91072c Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Fri, 7 Aug 2026 22:38:16 +0000 Subject: [PATCH 18/36] fix: truncate archive and chat.jsonl in place so no crash or failure strands or loses history --- src/node/services/historyService.test.ts | 132 ++++++++++++++++------- src/node/services/historyService.ts | 112 +++++++++---------- 2 files changed, 140 insertions(+), 104 deletions(-) diff --git a/src/node/services/historyService.test.ts b/src/node/services/historyService.test.ts index 64706cfd01..accd28a997 100644 --- a/src/node/services/historyService.test.ts +++ b/src/node/services/historyService.test.ts @@ -2230,21 +2230,52 @@ describe("HistoryService", () => { } }); - it("succeeds despite tombstone-removal failure without resurrecting cut rows", async () => { + it("rewrites the archive in place when the cut stays inside it", async () => { await appendNumberedMessages(service, wsId, 12); await service.appendToHistory(wsId, boundaryMessage("boundary-1", 1)); await service.appendToHistory( wsId, createMuxMessage("assistant-active", "assistant", "active reply") ); - expect(await fs.readFile(archivePath(wsId), "utf-8")).not.toBe(""); - // Fail every post-commit archive cleanup (parked tombstone or direct - // archive delete); rotation migration inside the read paths also uses - // fs.rm, so other paths stay real. + const chatBefore = await fs.readFile(chatPath(wsId), "utf-8"); + const archiveRowsBefore = (await readJsonlFile(archivePath(wsId))).length; + + const truncateResult = await service.truncateHistory(wsId, 0.25); + expect(truncateResult.success).toBe(true); + if (truncateResult.success) { + expect(truncateResult.data.deletedSequences.length).toBeGreaterThan(0); + + // The archive shrinks by exactly the cut rows; chat.jsonl (the active + // window) is untouched, so no crash between steps can lose rows. + const archiveRows = await readJsonlFile(archivePath(wsId)); + expect(archiveRows.length).toBe( + archiveRowsBefore - truncateResult.data.deletedSequences.length + ); + expect(await fs.readFile(chatPath(wsId), "utf-8")).toBe(chatBefore); + } + }); + + it("leaves history untouched when deleting a fully-cut archive fails", async () => { + // Small archive + heavy chat prefix so the cut consumes the whole + // archive and reaches into chat.jsonl. + await appendNumberedMessages(service, wsId, 2); + await service.appendToHistory(wsId, boundaryMessage("boundary-1", 1)); + await service.appendToHistory( + wsId, + createMuxMessage("user-heavy", "user", `heavy prompt ${"x".repeat(3_000)}`) + ); + await service.appendToHistory( + wsId, + createMuxMessage("assistant-active", "assistant", "active reply") + ); + + const chatBefore = await fs.readFile(chatPath(wsId), "utf-8"); + const archiveBefore = await fs.readFile(archivePath(wsId), "utf-8"); + const realRm = fs.rm; const rmSpy = spyOn(fs, "rm").mockImplementation((...args: Parameters) => { - if (args[0] === archivePath(wsId) || args[0] === `${archivePath(wsId)}.trash`) { + if (args[0] === archivePath(wsId)) { return Promise.reject( Object.assign(new Error("EACCES: permission denied"), { code: "EACCES" }) ); @@ -2252,59 +2283,80 @@ describe("HistoryService", () => { return realRm(...args); }); try { - const truncateResult = await service.truncateHistory(wsId, 0.25); - expect(truncateResult.success).toBe(true); - if (truncateResult.success) { - expect(truncateResult.data.deletedSequences.length).toBeGreaterThan(0); - - const remaining = await readJsonlFile(chatPath(wsId)); - const remainingIds = new Set(remaining.map((msg) => msg.id)); - for (const seq of truncateResult.data.deletedSequences) { - expect(remaining.some((msg) => msg.metadata?.historySequence === seq)).toBe(false); - } - expect(remainingIds.has("assistant-active")).toBe(true); - // The archive was parked, not left in place, so cut rows cannot - // resurrect through the archive read path. - expect(await fileExists(archivePath(wsId))).toBe(false); - } + const truncateResult = await service.truncateHistory(wsId, 0.5); + expect(truncateResult.success).toBe(false); + // A failed truncation must not have mutated either history file, so + // the caller's success-only usage invalidation stays correct. + expect(await fs.readFile(chatPath(wsId), "utf-8")).toBe(chatBefore); + expect(await fs.readFile(archivePath(wsId), "utf-8")).toBe(archiveBefore); } finally { rmSpy.mockRestore(); } }); - it("leaves history untouched when parking the archive fails", async () => { - await appendNumberedMessages(service, wsId, 12); + it("only deletes fully-cut archive rows when the chat commit fails", async () => { + await appendNumberedMessages(service, wsId, 2); await service.appendToHistory(wsId, boundaryMessage("boundary-1", 1)); + await service.appendToHistory( + wsId, + createMuxMessage("user-heavy", "user", `heavy prompt ${"x".repeat(3_000)}`) + ); await service.appendToHistory( wsId, createMuxMessage("assistant-active", "assistant", "active reply") ); const chatBefore = await fs.readFile(chatPath(wsId), "utf-8"); - const archiveBefore = await fs.readFile(archivePath(wsId), "utf-8"); - // Fail only the archive-parking rename; rotation migration inside the - // read paths also uses fs.rename. - const realRename = fs.rename; - const renameSpy = spyOn(fs, "rename").mockImplementation( - (...args: Parameters) => { - if (args[0] === archivePath(wsId)) { - return Promise.reject( - Object.assign(new Error("EACCES: permission denied"), { code: "EACCES" }) - ); - } - return realRename(...args); + // Abort between the archive delete and the chat.jsonl commit. Every + // archive row was a cut target, so the failure must leave chat.jsonl + // (the active window and all retained rows) untouched. + const internals = service as unknown as { + serializeHistoryEntries: (messages: MuxMessage[], workspaceId: string) => string; + }; + const serializeSpy = spyOn(internals, "serializeHistoryEntries").mockImplementationOnce( + () => { + throw new Error("injected serialize failure"); } ); try { - const truncateResult = await service.truncateHistory(wsId, 0.25); + const truncateResult = await service.truncateHistory(wsId, 0.5); expect(truncateResult.success).toBe(false); - // A failed truncation must not have mutated either history file, so - // the caller's success-only usage invalidation stays correct. expect(await fs.readFile(chatPath(wsId), "utf-8")).toBe(chatBefore); - expect(await fs.readFile(archivePath(wsId), "utf-8")).toBe(archiveBefore); + expect(await fileExists(archivePath(wsId))).toBe(false); } finally { - renameSpy.mockRestore(); + serializeSpy.mockRestore(); + } + }); + + it("keeps the active file intact when a remove-all cut fails on the archive delete", async () => { + await appendNumberedMessages(service, wsId, 2); + await service.appendToHistory(wsId, boundaryMessage("boundary-1", 1)); + await service.appendToHistory( + wsId, + createMuxMessage("assistant-active", "assistant", "active reply") + ); + + const chatBefore = await fs.readFile(chatPath(wsId), "utf-8"); + + // A 99% cut with similar-sized rows removes every row without taking the + // percentage >= 1.0 fast path; the remove-all branch must also delete + // archive-first so this failure cannot orphan the active window. + const realRm = fs.rm; + const rmSpy = spyOn(fs, "rm").mockImplementation((...args: Parameters) => { + if (args[0] === archivePath(wsId)) { + return Promise.reject( + Object.assign(new Error("EACCES: permission denied"), { code: "EACCES" }) + ); + } + return realRm(...args); + }); + try { + const truncateResult = await service.truncateHistory(wsId, 0.99); + expect(truncateResult.success).toBe(false); + expect(await fs.readFile(chatPath(wsId), "utf-8")).toBe(chatBefore); + } finally { + rmSpy.mockRestore(); } }); diff --git a/src/node/services/historyService.ts b/src/node/services/historyService.ts index 194b66e87f..52d87c0d03 100644 --- a/src/node/services/historyService.ts +++ b/src/node/services/historyService.ts @@ -1959,10 +1959,9 @@ export class HistoryService { // Structural rewrite requires full history content (oldest rows live in // the sealed archive). Percentage truncation is a rare recovery path // (compaction-failure retry), so the O(total-history) read is acceptable. - const messages = [ - ...(await this.readArchivedHistory(workspaceId)), - ...(await this.readChatHistory(workspaceId)), - ]; + const archivedMessages = await this.readArchivedHistory(workspaceId); + const chatMessages = await this.readChatHistory(workspaceId); + const messages = [...archivedMessages, ...chatMessages]; if (messages.length === 0) { return Ok({ deletedSequences: [], activeContextTruncated: false }); // Nothing to truncate } @@ -2001,10 +2000,11 @@ export class HistoryService { return Ok({ deletedSequences: [], activeContextTruncated: false }); } - // If we're removing all messages, use fast path + // If we're removing all messages, use fast path. Archive first for + // the same failure-ordering reason as the full-clear branch above. if (removeCount >= messages.length) { - await fs.rm(historyPath, { force: true }); await fs.rm(archivePath, { force: true }); + await fs.rm(historyPath, { force: true }); this.sequenceCounters.set(workspaceId, 0); const deletedSequences = messages .map((msg) => msg.metadata?.historySequence) @@ -2031,12 +2031,11 @@ export class HistoryService { messages.slice(Math.min(activeContextStart, removeCount), removeCount) ); - // Keep messages after removeCount - const remainingMessages = messages.slice(removeCount).map((msg) => { - // Retained rows' contextUsage measured the pre-truncation context - // (including the removed prefix). Persisting it would reseed stale - // auto-compaction pressure, even across app restarts. Strip it; the - // next provider response reports fresh usage. + // Retained rows' contextUsage measured the pre-truncation context + // (including the removed prefix). Persisting it would reseed stale + // auto-compaction pressure, even across app restarts. Strip it; the + // next provider response reports fresh usage. + const sanitizeRetained = (msg: MuxMessage): MuxMessage => { if (!activeContextTruncated || msg.metadata?.contextUsage === undefined) { return msg; } @@ -2048,65 +2047,50 @@ export class HistoryService { contextProviderMetadata: undefined, }, }; - }); + }; + const remainingMessages = messages.slice(removeCount).map(sanitizeRetained); const deletedMessages = messages.slice(0, removeCount); const deletedSequences = deletedMessages .map((msg) => msg.metadata?.historySequence) .filter((s): s is number => isNonNegativeInteger(s)); - // Collapse the remainder into chat.jsonl and drop the archive (the cut - // may fall anywhere inside it). It may contain old boundaries; a later - // boundary write re-seals it. - const historyEntries = this.serializeHistoryEntries(remainingMessages, workspaceId); - - // Order the two-file rewrite so no failure can change the active - // provider window yet still return Err (the caller invalidates usage - // only on success). Park the archive under a tombstone name (atomic - // rename), commit chat.jsonl atomically, then discard the tombstone: - // a pre-commit failure rolls the archive back and leaves history - // untouched, while a post-commit tombstone-removal failure only strays - // a file that no read path consumes. - const tombstonePath = `${archivePath}.trash`; - let archiveParked = false; - try { - await fs.rename(archivePath, tombstonePath); - archiveParked = true; - } catch (error) { - if ((error as NodeJS.ErrnoException).code !== "ENOENT") { - throw error; - } - } - - try { - // Atomic write prevents corruption if app crashes mid-write - await writeFileAtomic(historyPath, historyEntries); - } catch (error) { - if (archiveParked) { - try { - await fs.rename(tombstonePath, archivePath); - } catch (rollbackError) { - // Sealed pre-boundary rows stay parked in the tombstone; the - // active window in chat.jsonl is still intact. - log.error("Failed to restore parked chat archive after truncation write failure", { - workspaceId, - tombstonePath, - error: rollbackError, - }); - } + // Rewrite each file in place instead of collapsing the archive into + // chat.jsonl: every step is either an atomic single-file write or a + // deletion of rows the cut removes anyway, so no failure or crash can + // change the active provider window while returning Err (the caller + // invalidates usage only on success), lose retained rows, or leave + // duplicate rows behind. + if (removeCount < archivedMessages.length) { + // Cut confined to the archive: rewrite it atomically. chat.jsonl + // needs a rewrite only when retained rows must be sanitized (a + // malformed boundary-less archive state); ordering archive-first + // keeps a between-writes crash free of row loss or duplication. + const retainedArchive = archivedMessages.slice(removeCount).map(sanitizeRetained); + await writeFileAtomic( + archivePath, + this.serializeHistoryEntries(retainedArchive, workspaceId) + ); + if (activeContextTruncated) { + await writeFileAtomic( + historyPath, + this.serializeHistoryEntries(chatMessages.map(sanitizeRetained), workspaceId) + ); } - throw error; - } - - if (archiveParked) { - try { - await fs.rm(tombstonePath, { force: true }); - } catch (error) { - log.warn("Failed to remove parked chat archive after truncation", { - workspaceId, - tombstonePath, - error, - }); + } else { + // Cut consumes the whole archive: every archive row is being + // deleted, so removing the archive before committing chat.jsonl can + // only delete rows the cut targets. A failure after the rm returns + // Err with chat.jsonl (and the provider window) untouched. + if (archivedMessages.length > 0) { + await fs.rm(archivePath, { force: true }); } + const retainedChat = chatMessages + .slice(removeCount - archivedMessages.length) + .map(sanitizeRetained); + await writeFileAtomic( + historyPath, + this.serializeHistoryEntries(retainedChat, workspaceId) + ); } this.sealedRotationChecked.delete(workspaceId); From 2a483a28b85655a9b2cf85e38051627addcb53a0 Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Fri, 7 Aug 2026 22:41:57 +0000 Subject: [PATCH 19/36] fix: ignore workflow display-only rows when detecting active-context truncation --- src/node/services/historyService.test.ts | 43 ++++++++++++++++++++++++ src/node/services/historyService.ts | 12 ++++--- 2 files changed, 51 insertions(+), 4 deletions(-) diff --git a/src/node/services/historyService.test.ts b/src/node/services/historyService.test.ts index accd28a997..35aa98765a 100644 --- a/src/node/services/historyService.test.ts +++ b/src/node/services/historyService.test.ts @@ -2163,6 +2163,49 @@ describe("HistoryService", () => { } }); + it("preserves contextUsage when the cut removes only workflow display rows", async () => { + // Workflow trigger/run-card rows are filtered out before request + // assembly, so removing them leaves the provider context unchanged. + await appendNumberedMessages(service, wsId, 12); + await service.appendToHistory( + wsId, + createMuxMessage("reset-boundary", "assistant", "", { + contextBoundaryKind: CONTEXT_BOUNDARY_KINDS.RESET, + }) + ); + await service.appendToHistory( + wsId, + createMuxMessage( + "workflow-display", + "user", + `workflow trigger display ${"x".repeat(2_000)}`, + { muxMetadata: { type: "workflow-trigger-display", rawCommand: "/wf", runId: "run-1" } } + ) + ); + await service.appendToHistory(wsId, createMuxMessage("user-active", "user", "prompt")); + await service.appendToHistory( + wsId, + createMuxMessage("assistant-active", "assistant", "active reply", { + contextUsage: { inputTokens: 95_000, outputTokens: 100, totalTokens: 95_100 }, + model: "openai:gpt-4o", + }) + ); + + const truncateResult = await service.truncateHistory(wsId, 0.5); + expect(truncateResult.success).toBe(true); + if (truncateResult.success) { + expect(truncateResult.data.activeContextTruncated).toBe(false); + } + + const remaining = await service.getHistoryFromLatestBoundary(wsId); + expect(remaining.success).toBe(true); + if (remaining.success) { + expect(remaining.data.find((msg) => msg.id === "workflow-display")).toBeUndefined(); + const activeAssistant = remaining.data.find((msg) => msg.id === "assistant-active"); + expect(activeAssistant?.metadata?.contextUsage).toMatchObject({ inputTokens: 95_000 }); + } + }); + it("strips contextUsage when the cut removes an eligible post-reset row", async () => { // Mirror of the ineligible case: a replayable user turn in the same // position must still invalidate usage when removed. diff --git a/src/node/services/historyService.ts b/src/node/services/historyService.ts index 52d87c0d03..4a1c06f972 100644 --- a/src/node/services/historyService.ts +++ b/src/node/services/historyService.ts @@ -26,6 +26,7 @@ import { isDurableCompactedMarker, isDurableContextBoundaryMarker, } from "@/common/utils/messages/compactionBoundary"; +import { filterWorkflowDisplayOnlyMessages } from "@/common/utils/workflowRunMessages"; import { CHAT_FILE_NAME, CHAT_ARCHIVE_FILE_NAME } from "@/common/constants/paths"; import { isRefusalFinishReason } from "@/common/utils/messages/refusalFinishReason"; import { getErrorMessage } from "@/common/utils/errors"; @@ -2017,9 +2018,10 @@ export class HistoryService { // boundaries are provider-visible (the row carries the summary), so // the window starts AT the boundary; reset boundaries are // provider-invisible markers, so the window starts AFTER them. Rows - // inside the window that requests never replay (e.g. reasoning-only - // assistant turns) leave the request, and therefore the usage - // snapshots measured from it, unchanged when removed. + // inside the window that requests never replay (workflow display-only + // rows, reasoning-only assistant turns) leave the request, and + // therefore the usage snapshots measured from it, unchanged when + // removed. const latestBoundaryIndex = findLatestContextBoundaryIndex(messages); const activeContextStart = latestBoundaryIndex < 0 @@ -2028,7 +2030,9 @@ export class HistoryService { ? latestBoundaryIndex + 1 : latestBoundaryIndex; const activeContextTruncated = hasProviderEligibleMessages( - messages.slice(Math.min(activeContextStart, removeCount), removeCount) + filterWorkflowDisplayOnlyMessages( + messages.slice(Math.min(activeContextStart, removeCount), removeCount) + ) ); // Retained rows' contextUsage measured the pre-truncation context From e11580c55aedce3e697efd255fe51b4a20eb1618 Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Fri, 7 Aug 2026 22:49:13 +0000 Subject: [PATCH 20/36] fix: strip persisted usage before any truncation step can change the provider window --- src/node/services/historyService.test.ts | 117 +++++++++++++++++++++-- src/node/services/historyService.ts | 43 +++++---- 2 files changed, 133 insertions(+), 27 deletions(-) diff --git a/src/node/services/historyService.test.ts b/src/node/services/historyService.test.ts index 35aa98765a..83ee31a825 100644 --- a/src/node/services/historyService.test.ts +++ b/src/node/services/historyService.test.ts @@ -2349,29 +2349,128 @@ describe("HistoryService", () => { createMuxMessage("assistant-active", "assistant", "active reply") ); - const chatBefore = await fs.readFile(chatPath(wsId), "utf-8"); - - // Abort between the archive delete and the chat.jsonl commit. Every - // archive row was a cut target, so the failure must leave chat.jsonl - // (the active window and all retained rows) untouched. + // Abort between the archive delete and the final chat.jsonl commit + // (serialize call 1 is the pre-cut usage sanitization, call 2 the cut). + // Every archive row was a cut target, so the failure must leave every + // chat.jsonl row (the active window and all retained rows) in place. const internals = service as unknown as { serializeHistoryEntries: (messages: MuxMessage[], workspaceId: string) => string; }; - const serializeSpy = spyOn(internals, "serializeHistoryEntries").mockImplementationOnce( - () => { - throw new Error("injected serialize failure"); + const realSerialize = internals.serializeHistoryEntries.bind(service); + let serializeCalls = 0; + const serializeSpy = spyOn(internals, "serializeHistoryEntries").mockImplementation( + (messages: MuxMessage[], workspaceId: string) => { + serializeCalls += 1; + if (serializeCalls === 2) { + throw new Error("injected serialize failure"); + } + return realSerialize(messages, workspaceId); } ); try { const truncateResult = await service.truncateHistory(wsId, 0.5); expect(truncateResult.success).toBe(false); - expect(await fs.readFile(chatPath(wsId), "utf-8")).toBe(chatBefore); + const chatRows = await readJsonlFile(chatPath(wsId)); + const chatIds = new Set(chatRows.map((msg) => msg.id)); + expect(chatIds.has("boundary-1")).toBe(true); + expect(chatIds.has("user-heavy")).toBe(true); + expect(chatIds.has("assistant-active")).toBe(true); expect(await fileExists(archivePath(wsId))).toBe(false); } finally { serializeSpy.mockRestore(); } }); + it("strips chat usage before an archive-confined cut can change the window", async () => { + // Malformed boundary-less state: rotation created the archive, then the + // boundary row was deleted, so the whole file pair is one active window. + await appendNumberedMessages(service, wsId, 12); + await service.appendToHistory(wsId, boundaryMessage("boundary-1", 1)); + await service.appendToHistory( + wsId, + createMuxMessage("assistant-active", "assistant", "active reply", { + contextUsage: { inputTokens: 95_000, outputTokens: 100, totalTokens: 95_100 }, + model: "openai:gpt-4o", + }) + ); + const deleteResult = await service.deleteMessage(wsId, "boundary-1"); + expect(deleteResult.success).toBe(true); + + const archiveBefore = await fs.readFile(archivePath(wsId), "utf-8"); + + // Fail the archive rewrite (the only serialize whose rows are archive + // rows). The chat sanitization must already be durable at that point so + // a crash here cannot leave a shrunken window with reseedable usage. + const internals = service as unknown as { + serializeHistoryEntries: (messages: MuxMessage[], workspaceId: string) => string; + }; + const realSerialize = internals.serializeHistoryEntries.bind(service); + const serializeSpy = spyOn(internals, "serializeHistoryEntries").mockImplementation( + (messages: MuxMessage[], workspaceId: string) => { + if (messages.some((msg) => msg.id === "msg-5")) { + throw new Error("injected archive serialize failure"); + } + return realSerialize(messages, workspaceId); + } + ); + try { + const truncateResult = await service.truncateHistory(wsId, 0.25); + expect(truncateResult.success).toBe(false); + expect(await fs.readFile(archivePath(wsId), "utf-8")).toBe(archiveBefore); + const activeAssistant = (await readJsonlFile(chatPath(wsId))).find( + (msg) => msg.id === "assistant-active" + ); + expect(activeAssistant).toBeDefined(); + expect(activeAssistant?.metadata?.contextUsage).toBeUndefined(); + } finally { + serializeSpy.mockRestore(); + } + }); + + it("strips chat usage before a whole-archive cut can change the window", async () => { + // Same malformed boundary-less state, but the cut consumes the archive: + // the archive delete must find chat usage already stripped. + await appendNumberedMessages(service, wsId, 2); + await service.appendToHistory(wsId, boundaryMessage("boundary-1", 1)); + await service.appendToHistory( + wsId, + createMuxMessage("user-heavy", "user", `heavy prompt ${"x".repeat(3_000)}`) + ); + await service.appendToHistory( + wsId, + createMuxMessage("assistant-active", "assistant", "active reply", { + contextUsage: { inputTokens: 95_000, outputTokens: 100, totalTokens: 95_100 }, + model: "openai:gpt-4o", + }) + ); + const deleteResult = await service.deleteMessage(wsId, "boundary-1"); + expect(deleteResult.success).toBe(true); + + const archiveBefore = await fs.readFile(archivePath(wsId), "utf-8"); + + const realRm = fs.rm; + const rmSpy = spyOn(fs, "rm").mockImplementation((...args: Parameters) => { + if (args[0] === archivePath(wsId)) { + return Promise.reject( + Object.assign(new Error("EACCES: permission denied"), { code: "EACCES" }) + ); + } + return realRm(...args); + }); + try { + const truncateResult = await service.truncateHistory(wsId, 0.5); + expect(truncateResult.success).toBe(false); + expect(await fs.readFile(archivePath(wsId), "utf-8")).toBe(archiveBefore); + const chatRows = await readJsonlFile(chatPath(wsId)); + // Cut not applied: all chat rows survive, but usage is stripped. + expect(chatRows.some((msg) => msg.id === "user-heavy")).toBe(true); + const activeAssistant = chatRows.find((msg) => msg.id === "assistant-active"); + expect(activeAssistant?.metadata?.contextUsage).toBeUndefined(); + } finally { + rmSpy.mockRestore(); + } + }); + it("keeps the active file intact when a remove-all cut fails on the archive delete", async () => { await appendNumberedMessages(service, wsId, 2); await service.appendToHistory(wsId, boundaryMessage("boundary-1", 1)); diff --git a/src/node/services/historyService.ts b/src/node/services/historyService.ts index 4a1c06f972..e0b36845f7 100644 --- a/src/node/services/historyService.ts +++ b/src/node/services/historyService.ts @@ -2021,7 +2021,12 @@ export class HistoryService { // inside the window that requests never replay (workflow display-only // rows, reasoning-only assistant turns) leave the request, and // therefore the usage snapshots measured from it, unchanged when - // removed. + // removed. Deliberate asymmetry: Anthropic with thinking enabled + // replays reasoning-only rows (preserveReasoningOnly), but the next + // send's provider is unknowable here, so we preserve usage. That can + // only overestimate (compact early); flagging truncated would strip + // valid usage for every other provider and bypass required on-send + // compaction, the failure this feature exists to prevent. const latestBoundaryIndex = findLatestContextBoundaryIndex(messages); const activeContextStart = latestBoundaryIndex < 0 @@ -2061,30 +2066,32 @@ export class HistoryService { // Rewrite each file in place instead of collapsing the archive into // chat.jsonl: every step is either an atomic single-file write or a // deletion of rows the cut removes anyway, so no failure or crash can - // change the active provider window while returning Err (the caller - // invalidates usage only on success), lose retained rows, or leave - // duplicate rows behind. + // lose retained rows or leave duplicates behind. + // + // When retained usage must be stripped, sanitize chat.jsonl FIRST: + // it is a metadata-only atomic write (rows unchanged), so if a later + // step fails the provider window is intact and the Err is accurate, + // while a crash after a later step already changed the window cannot + // pair that change with stale persisted usage for a restart to + // reseed. The cost of the early write is only over-stripping (next + // provider response restores usage), never a compaction bypass. + if (activeContextTruncated) { + await writeFileAtomic( + historyPath, + this.serializeHistoryEntries(chatMessages.map(sanitizeRetained), workspaceId) + ); + } if (removeCount < archivedMessages.length) { - // Cut confined to the archive: rewrite it atomically. chat.jsonl - // needs a rewrite only when retained rows must be sanitized (a - // malformed boundary-less archive state); ordering archive-first - // keeps a between-writes crash free of row loss or duplication. + // Cut confined to the archive: one atomic rewrite applies it. const retainedArchive = archivedMessages.slice(removeCount).map(sanitizeRetained); await writeFileAtomic( archivePath, this.serializeHistoryEntries(retainedArchive, workspaceId) ); - if (activeContextTruncated) { - await writeFileAtomic( - historyPath, - this.serializeHistoryEntries(chatMessages.map(sanitizeRetained), workspaceId) - ); - } } else { - // Cut consumes the whole archive: every archive row is being - // deleted, so removing the archive before committing chat.jsonl can - // only delete rows the cut targets. A failure after the rm returns - // Err with chat.jsonl (and the provider window) untouched. + // Cut consumes the whole archive: every archive row is a cut + // target, so deleting the archive before committing the chat cut + // can only remove rows the cut targets. if (archivedMessages.length > 0) { await fs.rm(archivePath, { force: true }); } From aca3df96ecaa3abfe050668d9c297fa459ca7264 Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Fri, 7 Aug 2026 22:58:25 +0000 Subject: [PATCH 21/36] fix: roll back pre-cut usage sanitization when the truncation cut fails before committing --- src/node/services/historyService.test.ts | 69 ++++++++++++++++---- src/node/services/historyService.ts | 81 ++++++++++++++++-------- 2 files changed, 109 insertions(+), 41 deletions(-) diff --git a/src/node/services/historyService.test.ts b/src/node/services/historyService.test.ts index 83ee31a825..b4993774cf 100644 --- a/src/node/services/historyService.test.ts +++ b/src/node/services/historyService.test.ts @@ -2381,7 +2381,7 @@ describe("HistoryService", () => { } }); - it("strips chat usage before an archive-confined cut can change the window", async () => { + it("restores chat usage when an archive-confined cut fails before committing", async () => { // Malformed boundary-less state: rotation created the archive, then the // boundary row was deleted, so the whole file pair is one active window. await appendNumberedMessages(service, wsId, 12); @@ -2396,11 +2396,12 @@ describe("HistoryService", () => { const deleteResult = await service.deleteMessage(wsId, "boundary-1"); expect(deleteResult.success).toBe(true); + const chatBefore = await fs.readFile(chatPath(wsId), "utf-8"); const archiveBefore = await fs.readFile(archivePath(wsId), "utf-8"); // Fail the archive rewrite (the only serialize whose rows are archive - // rows). The chat sanitization must already be durable at that point so - // a crash here cannot leave a shrunken window with reseedable usage. + // rows). No window-changing step committed, so the pre-cut sanitization + // must be rolled back and the retained usage stays seedable. const internals = service as unknown as { serializeHistoryEntries: (messages: MuxMessage[], workspaceId: string) => string; }; @@ -2417,19 +2418,14 @@ describe("HistoryService", () => { const truncateResult = await service.truncateHistory(wsId, 0.25); expect(truncateResult.success).toBe(false); expect(await fs.readFile(archivePath(wsId), "utf-8")).toBe(archiveBefore); - const activeAssistant = (await readJsonlFile(chatPath(wsId))).find( - (msg) => msg.id === "assistant-active" - ); - expect(activeAssistant).toBeDefined(); - expect(activeAssistant?.metadata?.contextUsage).toBeUndefined(); + expect(await fs.readFile(chatPath(wsId), "utf-8")).toBe(chatBefore); } finally { serializeSpy.mockRestore(); } }); - it("strips chat usage before a whole-archive cut can change the window", async () => { - // Same malformed boundary-less state, but the cut consumes the archive: - // the archive delete must find chat usage already stripped. + it("restores chat usage when a whole-archive cut fails before committing", async () => { + // Same malformed boundary-less state, but the cut consumes the archive. await appendNumberedMessages(service, wsId, 2); await service.appendToHistory(wsId, boundaryMessage("boundary-1", 1)); await service.appendToHistory( @@ -2446,6 +2442,7 @@ describe("HistoryService", () => { const deleteResult = await service.deleteMessage(wsId, "boundary-1"); expect(deleteResult.success).toBe(true); + const chatBefore = await fs.readFile(chatPath(wsId), "utf-8"); const archiveBefore = await fs.readFile(archivePath(wsId), "utf-8"); const realRm = fs.rm; @@ -2461,13 +2458,59 @@ describe("HistoryService", () => { const truncateResult = await service.truncateHistory(wsId, 0.5); expect(truncateResult.success).toBe(false); expect(await fs.readFile(archivePath(wsId), "utf-8")).toBe(archiveBefore); + expect(await fs.readFile(chatPath(wsId), "utf-8")).toBe(chatBefore); + } finally { + rmSpy.mockRestore(); + } + }); + + it("keeps usage stripped when the chat cut fails after the archive delete", async () => { + // The window already changed (archive rows deleted), so the failure + // must NOT restore usage: a restart pairing the shrunken window with + // pre-cut usage would reseed a spurious auto-compaction. + await appendNumberedMessages(service, wsId, 2); + await service.appendToHistory(wsId, boundaryMessage("boundary-1", 1)); + await service.appendToHistory( + wsId, + createMuxMessage("user-heavy", "user", `heavy prompt ${"x".repeat(3_000)}`) + ); + await service.appendToHistory( + wsId, + createMuxMessage("assistant-active", "assistant", "active reply", { + contextUsage: { inputTokens: 95_000, outputTokens: 100, totalTokens: 95_100 }, + model: "openai:gpt-4o", + }) + ); + const deleteResult = await service.deleteMessage(wsId, "boundary-1"); + expect(deleteResult.success).toBe(true); + + // Serialize call 1 is the pre-cut sanitization; call 2 is the chat cut. + const internals = service as unknown as { + serializeHistoryEntries: (messages: MuxMessage[], workspaceId: string) => string; + }; + const realSerialize = internals.serializeHistoryEntries.bind(service); + let serializeCalls = 0; + const serializeSpy = spyOn(internals, "serializeHistoryEntries").mockImplementation( + (messages: MuxMessage[], workspaceId: string) => { + serializeCalls += 1; + if (serializeCalls === 2) { + throw new Error("injected chat cut serialize failure"); + } + return realSerialize(messages, workspaceId); + } + ); + try { + const truncateResult = await service.truncateHistory(wsId, 0.5); + expect(truncateResult.success).toBe(false); + expect(await fileExists(archivePath(wsId))).toBe(false); const chatRows = await readJsonlFile(chatPath(wsId)); - // Cut not applied: all chat rows survive, but usage is stripped. + // Chat cut not applied: rows survive, but usage stays stripped. expect(chatRows.some((msg) => msg.id === "user-heavy")).toBe(true); const activeAssistant = chatRows.find((msg) => msg.id === "assistant-active"); + expect(activeAssistant).toBeDefined(); expect(activeAssistant?.metadata?.contextUsage).toBeUndefined(); } finally { - rmSpy.mockRestore(); + serializeSpy.mockRestore(); } }); diff --git a/src/node/services/historyService.ts b/src/node/services/historyService.ts index e0b36845f7..8308bfe91e 100644 --- a/src/node/services/historyService.ts +++ b/src/node/services/historyService.ts @@ -2068,40 +2068,65 @@ export class HistoryService { // deletion of rows the cut removes anyway, so no failure or crash can // lose retained rows or leave duplicates behind. // - // When retained usage must be stripped, sanitize chat.jsonl FIRST: - // it is a metadata-only atomic write (rows unchanged), so if a later - // step fails the provider window is intact and the Err is accurate, - // while a crash after a later step already changed the window cannot - // pair that change with stale persisted usage for a restart to - // reseed. The cost of the early write is only over-stripping (next - // provider response restores usage), never a compaction bypass. - if (activeContextTruncated) { + // When the cut spans two files and usage must be stripped, sanitize + // chat.jsonl FIRST (metadata-only atomic write, rows unchanged) and + // roll it back byte-exactly if the cut fails before any window- + // changing step commits, so every runtime failure returns Err with + // both files as they were. A process death between the sanitize + // commit and the cut commit leaves the window unchanged with usage + // stripped, the same state every legitimate active-window cut + // deliberately creates (one unmonitored send, repaired by the next + // provider response); the inverse ordering's death window instead + // pairs a changed window with stale usage, which a restart reseeds + // into a spurious auto-compaction that nothing repairs. + const needsPreSanitize = activeContextTruncated && archivedMessages.length > 0; + let originalChat: string | null = null; + if (needsPreSanitize) { + originalChat = await fs.readFile(historyPath, "utf-8").catch(() => null); await writeFileAtomic( historyPath, this.serializeHistoryEntries(chatMessages.map(sanitizeRetained), workspaceId) ); } - if (removeCount < archivedMessages.length) { - // Cut confined to the archive: one atomic rewrite applies it. - const retainedArchive = archivedMessages.slice(removeCount).map(sanitizeRetained); - await writeFileAtomic( - archivePath, - this.serializeHistoryEntries(retainedArchive, workspaceId) - ); - } else { - // Cut consumes the whole archive: every archive row is a cut - // target, so deleting the archive before committing the chat cut - // can only remove rows the cut targets. - if (archivedMessages.length > 0) { - await fs.rm(archivePath, { force: true }); + let windowChanged = false; + try { + if (removeCount < archivedMessages.length) { + // Cut confined to the archive: one atomic rewrite applies it. + const retainedArchive = archivedMessages.slice(removeCount).map(sanitizeRetained); + await writeFileAtomic( + archivePath, + this.serializeHistoryEntries(retainedArchive, workspaceId) + ); + } else { + // Cut consumes the whole archive: every archive row is a cut + // target, so deleting the archive before committing the chat cut + // can only remove rows the cut targets. + if (archivedMessages.length > 0) { + await fs.rm(archivePath, { force: true }); + windowChanged = true; + } + const retainedChat = chatMessages + .slice(removeCount - archivedMessages.length) + .map(sanitizeRetained); + await writeFileAtomic( + historyPath, + this.serializeHistoryEntries(retainedChat, workspaceId) + ); } - const retainedChat = chatMessages - .slice(removeCount - archivedMessages.length) - .map(sanitizeRetained); - await writeFileAtomic( - historyPath, - this.serializeHistoryEntries(retainedChat, workspaceId) - ); + } catch (error) { + if (needsPreSanitize && !windowChanged && originalChat !== null) { + try { + await writeFileAtomic(historyPath, originalChat); + } catch (rollbackError) { + // Window unchanged with usage stripped: over-strips until the + // next provider response, never bypasses required compaction. + log.error("Failed to restore chat usage after truncation cut failure", { + workspaceId, + error: rollbackError, + }); + } + } + throw error; } this.sealedRotationChecked.delete(workspaceId); From bb005038f4f8968c9836f93b491c7526acd16d9b Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Fri, 7 Aug 2026 23:13:18 +0000 Subject: [PATCH 22/36] fix: only treat archive deletion as a window change when the archive holds active-window rows A normally rotated archive contains only sealed pre-boundary rows, so deleting it leaves the provider context unchanged. Setting windowChanged unconditionally blocked the usage rollback when the subsequent chat cut failed, leaving an unchanged near-limit context with no seedable usage. --- src/node/services/historyService.test.ts | 47 ++++++++++++++++++++++++ src/node/services/historyService.ts | 15 +++++++- 2 files changed, 61 insertions(+), 1 deletion(-) diff --git a/src/node/services/historyService.test.ts b/src/node/services/historyService.test.ts index b4993774cf..57adb787ad 100644 --- a/src/node/services/historyService.test.ts +++ b/src/node/services/historyService.test.ts @@ -2514,6 +2514,53 @@ describe("HistoryService", () => { } }); + it("restores chat usage when the chat cut fails after deleting a fully sealed archive", async () => { + // Normal rotated layout: the boundary is the first chat.jsonl row, so + // every archive row is sealed pre-boundary history and deleting the + // archive leaves the provider window unchanged. The failed chat cut + // must still roll back the pre-cut sanitization. + await appendNumberedMessages(service, wsId, 2); + await service.appendToHistory(wsId, boundaryMessage("boundary-1", 1)); + await service.appendToHistory( + wsId, + createMuxMessage("user-heavy", "user", `heavy prompt ${"x".repeat(3_000)}`) + ); + await service.appendToHistory( + wsId, + createMuxMessage("assistant-active", "assistant", "active reply", { + contextUsage: { inputTokens: 95_000, outputTokens: 100, totalTokens: 95_100 }, + model: "openai:gpt-4o", + }) + ); + + const chatBefore = await fs.readFile(chatPath(wsId), "utf-8"); + + // Serialize call 1 is the pre-cut sanitization; call 2 is the chat cut. + const internals = service as unknown as { + serializeHistoryEntries: (messages: MuxMessage[], workspaceId: string) => string; + }; + const realSerialize = internals.serializeHistoryEntries.bind(service); + let serializeCalls = 0; + const serializeSpy = spyOn(internals, "serializeHistoryEntries").mockImplementation( + (messages: MuxMessage[], workspaceId: string) => { + serializeCalls += 1; + if (serializeCalls === 2) { + throw new Error("injected chat cut serialize failure"); + } + return realSerialize(messages, workspaceId); + } + ); + try { + const truncateResult = await service.truncateHistory(wsId, 0.5); + expect(truncateResult.success).toBe(false); + // Every archive row was a cut target, so its deletion stands. + expect(await fileExists(archivePath(wsId))).toBe(false); + expect(await fs.readFile(chatPath(wsId), "utf-8")).toBe(chatBefore); + } finally { + serializeSpy.mockRestore(); + } + }); + it("keeps the active file intact when a remove-all cut fails on the archive delete", async () => { await appendNumberedMessages(service, wsId, 2); await service.appendToHistory(wsId, boundaryMessage("boundary-1", 1)); diff --git a/src/node/services/historyService.ts b/src/node/services/historyService.ts index 8308bfe91e..6a92dc7e15 100644 --- a/src/node/services/historyService.ts +++ b/src/node/services/historyService.ts @@ -2088,6 +2088,19 @@ export class HistoryService { this.serializeHistoryEntries(chatMessages.map(sanitizeRetained), workspaceId) ); } + // Deleting the whole archive commits a window change only when the + // window extends into it (boundary inside the archive, or none at + // all). A normally rotated archive holds only sealed pre-boundary + // rows, so its deletion leaves the provider context unchanged and + // must not block the usage rollback in the catch below. + const archiveDeleteChangesWindow = hasProviderEligibleMessages( + filterWorkflowDisplayOnlyMessages( + messages.slice( + Math.min(activeContextStart, archivedMessages.length), + archivedMessages.length + ) + ) + ); let windowChanged = false; try { if (removeCount < archivedMessages.length) { @@ -2103,7 +2116,7 @@ export class HistoryService { // can only remove rows the cut targets. if (archivedMessages.length > 0) { await fs.rm(archivePath, { force: true }); - windowChanged = true; + windowChanged = archiveDeleteChangesWindow; } const retainedChat = chatMessages .slice(removeCount - archivedMessages.length) From f2cee1951a42dce9813a24d39c4be547cb49006f Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Fri, 7 Aug 2026 23:31:07 +0000 Subject: [PATCH 23/36] fix: notify usage invalidation at commit time so a failed cut cannot leave stale session usage truncateHistory now takes an onActiveContextTruncated callback fired at most once, the moment a committed step first removes provider-eligible rows from the active window, even when a later step fails. Callers (truncation, destructive replace, hard-restart clears) invalidate session usage through it instead of success-only guards, closing the window where a partially committed cut returned Err with window content already deleted. --- src/node/services/agentSession.ts | 2 +- src/node/services/historyService.test.ts | 28 ++++- src/node/services/historyService.ts | 125 +++++++++++++-------- src/node/services/workspaceService.test.ts | 100 ++++++++++++++++- src/node/services/workspaceService.ts | 37 +++--- 5 files changed, 224 insertions(+), 68 deletions(-) diff --git a/src/node/services/agentSession.ts b/src/node/services/agentSession.ts index e580c2bd1c..f070945778 100644 --- a/src/node/services/agentSession.ts +++ b/src/node/services/agentSession.ts @@ -4613,7 +4613,7 @@ export class AgentSession { ? await this.clearHistoryForHardRestart({ monitorHistoryLockHeld: context.monitorHistoryLockHeld === true, }) - : await this.historyService.clearHistory(this.workspaceId); + : await this.historyService.clearHistory(this.workspaceId, () => this.clearUsageState()); if (!clearResult.success) { log.warn("Failed to clear history for exec subagent hard restart", { workspaceId: this.workspaceId, diff --git a/src/node/services/historyService.test.ts b/src/node/services/historyService.test.ts index 57adb787ad..5743b9e5b7 100644 --- a/src/node/services/historyService.test.ts +++ b/src/node/services/historyService.test.ts @@ -2040,8 +2040,12 @@ describe("HistoryService", () => { }) ); - const truncateResult = await service.truncateHistory(wsId, 0.5); + let notified = 0; + const truncateResult = await service.truncateHistory(wsId, 0.5, () => { + notified += 1; + }); expect(truncateResult.success).toBe(true); + expect(notified).toBe(1); if (truncateResult.success) { expect(truncateResult.data.deletedSequences.length).toBeGreaterThan(0); expect(truncateResult.data.activeContextTruncated).toBe(true); @@ -2072,8 +2076,12 @@ describe("HistoryService", () => { }) ); - const truncateResult = await service.truncateHistory(wsId, 0.25); + let notified = 0; + const truncateResult = await service.truncateHistory(wsId, 0.25, () => { + notified += 1; + }); expect(truncateResult.success).toBe(true); + expect(notified).toBe(0); if (truncateResult.success) { expect(truncateResult.data.deletedSequences.length).toBeGreaterThan(0); expect(truncateResult.data.activeContextTruncated).toBe(false); @@ -2500,8 +2508,14 @@ describe("HistoryService", () => { } ); try { - const truncateResult = await service.truncateHistory(wsId, 0.5); + // The archive delete removed window content, so the caller must be + // notified at commit time despite the Err result. + let notified = 0; + const truncateResult = await service.truncateHistory(wsId, 0.5, () => { + notified += 1; + }); expect(truncateResult.success).toBe(false); + expect(notified).toBe(1); expect(await fileExists(archivePath(wsId))).toBe(false); const chatRows = await readJsonlFile(chatPath(wsId)); // Chat cut not applied: rows survive, but usage stays stripped. @@ -2551,8 +2565,14 @@ describe("HistoryService", () => { } ); try { - const truncateResult = await service.truncateHistory(wsId, 0.5); + // Sealed rows leaving cannot change the window, and the chat cut + // rolled back, so the caller must NOT be told to drop usage. + let notified = 0; + const truncateResult = await service.truncateHistory(wsId, 0.5, () => { + notified += 1; + }); expect(truncateResult.success).toBe(false); + expect(notified).toBe(0); // Every archive row was a cut target, so its deletion stands. expect(await fileExists(archivePath(wsId))).toBe(false); expect(await fs.readFile(chatPath(wsId), "utf-8")).toBe(chatBefore); diff --git a/src/node/services/historyService.ts b/src/node/services/historyService.ts index 6a92dc7e15..ed26f01b93 100644 --- a/src/node/services/historyService.ts +++ b/src/node/services/historyService.ts @@ -45,6 +45,36 @@ function hasDurableCompactionBoundary(metadata: MuxMetadata | undefined): boolea return isPositiveInteger(metadata.compactionEpoch); } +// Compaction boundaries are provider-visible (the row carries the summary), +// so the active window starts AT the boundary; reset boundaries are +// provider-invisible markers, so the window starts AFTER them. +function activeProviderWindowStart(messages: MuxMessage[]): number { + const latestBoundaryIndex = findLatestContextBoundaryIndex(messages); + if (latestBoundaryIndex < 0) { + return 0; + } + return getContextBoundaryKind(messages[latestBoundaryIndex]) === CONTEXT_BOUNDARY_KINDS.RESET + ? latestBoundaryIndex + 1 + : latestBoundaryIndex; +} + +// A prefix cut changes the active provider context only when it removes a +// provider-replayable row inside the provider window. Rows inside the window +// that requests never replay (workflow display-only rows, reasoning-only +// assistant turns) leave the request, and therefore the usage snapshots +// measured from it, unchanged when removed. Deliberate asymmetry: Anthropic +// with thinking enabled replays reasoning-only rows (preserveReasoningOnly), +// but the next send's provider is unknowable here, so we preserve usage. +// That can only overestimate (compact early); flagging truncated would strip +// valid usage for every other provider and bypass required on-send +// compaction, the failure this feature exists to prevent. +function cutChangesActiveWindow(messages: MuxMessage[], cutEnd: number): boolean { + const start = activeProviderWindowStart(messages); + return hasProviderEligibleMessages( + filterWorkflowDisplayOnlyMessages(messages.slice(Math.min(start, cutEnd), cutEnd)) + ); +} + function getCompactionMetadataToPreserve( workspaceId: string, existingMessage: MuxMessage, @@ -1921,6 +1951,10 @@ export class HistoryService { * Truncate history by removing approximately the given percentage of tokens from the beginning * @param workspaceId The workspace ID * @param percentage Percentage to truncate (0.0 to 1.0). 1.0 = delete all + * @param onActiveContextTruncated Invoked at most once, the moment a + * committed step first removes provider-eligible rows from the + * active window. Fires even when a later step fails, so callers can + * invalidate cached usage for mutations an Err result leaves on disk. * @returns Result with deleted historySequence numbers plus whether the cut * reached the active provider-context window (at or past the latest * durable boundary). Callers use that flag to decide whether cached @@ -1928,9 +1962,24 @@ export class HistoryService { */ async truncateHistory( workspaceId: string, - percentage: number + percentage: number, + onActiveContextTruncated?: () => void ): Promise> { return this.fileLocks.withLock(workspaceId, async () => { + let contextChangeNotified = false; + const notifyActiveContextTruncated = () => { + if (contextChangeNotified) { + return; + } + contextChangeNotified = true; + try { + onActiveContextTruncated?.(); + } catch (error) { + // A notification failure must not corrupt an already-committed + // rewrite (the catch below would roll back a successful cut). + log.error("truncateHistory context-change callback failed", { workspaceId, error }); + } + }; try { const historyPath = this.getChatHistoryPath(workspaceId); const archivePath = this.getChatArchivePath(workspaceId); @@ -1938,19 +1987,22 @@ export class HistoryService { // Fast path: 100% truncation = delete entire history (active + sealed archive) if (percentage >= 1.0) { // Need sequence numbers for return value before deleting - const messages = [ - ...(await this.readArchivedHistory(workspaceId)), - ...(await this.readChatHistory(workspaceId)), - ]; + const archivedMessages = await this.readArchivedHistory(workspaceId); + const messages = [...archivedMessages, ...(await this.readChatHistory(workspaceId))]; const deletedSequences = messages .map((msg) => msg.metadata?.historySequence) .filter((s): s is number => isNonNegativeInteger(s)); - // Archive first: it holds only sealed pre-boundary rows, so if the - // second rm fails the active provider window is still intact and the - // caller's success-only usage invalidation remains correct. + // Archive first: normally it holds only sealed pre-boundary rows, + // so if the second rm fails the active provider window is still + // intact. A malformed boundary-less archive IS window content, so + // notify at commit; the caller must not trust a later Err. await fs.rm(archivePath, { force: true }); + if (cutChangesActiveWindow(messages, archivedMessages.length)) { + notifyActiveContextTruncated(); + } await fs.rm(historyPath, { force: true }); + notifyActiveContextTruncated(); // Reset sequence counter when clearing history this.sequenceCounters.set(workspaceId, 0); @@ -2002,10 +2054,15 @@ export class HistoryService { } // If we're removing all messages, use fast path. Archive first for - // the same failure-ordering reason as the full-clear branch above. + // the same failure-ordering reason as the full-clear branch above, + // with the same commit-time notifications. if (removeCount >= messages.length) { await fs.rm(archivePath, { force: true }); + if (cutChangesActiveWindow(messages, archivedMessages.length)) { + notifyActiveContextTruncated(); + } await fs.rm(historyPath, { force: true }); + notifyActiveContextTruncated(); this.sequenceCounters.set(workspaceId, 0); const deletedSequences = messages .map((msg) => msg.metadata?.historySequence) @@ -2013,32 +2070,7 @@ export class HistoryService { return Ok({ deletedSequences, activeContextTruncated: true }); } - // The cut changes the active provider context only when it removes a - // provider-replayable row inside the provider window. Compaction - // boundaries are provider-visible (the row carries the summary), so - // the window starts AT the boundary; reset boundaries are - // provider-invisible markers, so the window starts AFTER them. Rows - // inside the window that requests never replay (workflow display-only - // rows, reasoning-only assistant turns) leave the request, and - // therefore the usage snapshots measured from it, unchanged when - // removed. Deliberate asymmetry: Anthropic with thinking enabled - // replays reasoning-only rows (preserveReasoningOnly), but the next - // send's provider is unknowable here, so we preserve usage. That can - // only overestimate (compact early); flagging truncated would strip - // valid usage for every other provider and bypass required on-send - // compaction, the failure this feature exists to prevent. - const latestBoundaryIndex = findLatestContextBoundaryIndex(messages); - const activeContextStart = - latestBoundaryIndex < 0 - ? 0 - : getContextBoundaryKind(messages[latestBoundaryIndex]) === CONTEXT_BOUNDARY_KINDS.RESET - ? latestBoundaryIndex + 1 - : latestBoundaryIndex; - const activeContextTruncated = hasProviderEligibleMessages( - filterWorkflowDisplayOnlyMessages( - messages.slice(Math.min(activeContextStart, removeCount), removeCount) - ) - ); + const activeContextTruncated = cutChangesActiveWindow(messages, removeCount); // Retained rows' contextUsage measured the pre-truncation context // (including the removed prefix). Persisting it would reseed stale @@ -2093,13 +2125,9 @@ export class HistoryService { // all). A normally rotated archive holds only sealed pre-boundary // rows, so its deletion leaves the provider context unchanged and // must not block the usage rollback in the catch below. - const archiveDeleteChangesWindow = hasProviderEligibleMessages( - filterWorkflowDisplayOnlyMessages( - messages.slice( - Math.min(activeContextStart, archivedMessages.length), - archivedMessages.length - ) - ) + const archiveDeleteChangesWindow = cutChangesActiveWindow( + messages, + archivedMessages.length ); let windowChanged = false; try { @@ -2117,6 +2145,9 @@ export class HistoryService { if (archivedMessages.length > 0) { await fs.rm(archivePath, { force: true }); windowChanged = archiveDeleteChangesWindow; + if (archiveDeleteChangesWindow) { + notifyActiveContextTruncated(); + } } const retainedChat = chatMessages .slice(removeCount - archivedMessages.length) @@ -2141,6 +2172,9 @@ export class HistoryService { } throw error; } + if (activeContextTruncated) { + notifyActiveContextTruncated(); + } this.sealedRotationChecked.delete(workspaceId); // Update sequence counter to continue from where we are. @@ -2180,8 +2214,11 @@ export class HistoryService { }); } - async clearHistory(workspaceId: string): Promise> { - const result = await this.truncateHistory(workspaceId, 1.0); + async clearHistory( + workspaceId: string, + onActiveContextTruncated?: () => void + ): Promise> { + const result = await this.truncateHistory(workspaceId, 1.0, onActiveContextTruncated); if (!result.success) { return Err(result.error); } diff --git a/src/node/services/workspaceService.test.ts b/src/node/services/workspaceService.test.ts index a54645cc51..3405fcf904 100644 --- a/src/node/services/workspaceService.test.ts +++ b/src/node/services/workspaceService.test.ts @@ -42,7 +42,7 @@ import type { DesktopSessionManager } from "@/node/services/desktop/DesktopSessi import type { WorktreeArchiveSnapshot } from "@/common/schemas/project"; import type { BashToolResult } from "@/common/types/tools"; import type { WorkspaceChatMessage } from "@/common/orpc/types"; -import { createMuxMessage } from "@/common/types/message"; +import { createMuxMessage, type MuxMessage } from "@/common/types/message"; import { buildStagedAttachmentNotice } from "@/browser/features/ChatInput/stagedAttachments"; import { WORKFLOW_RUN_CARD_DISPLAY_METADATA_TYPE, @@ -4513,6 +4513,104 @@ describe("WorkspaceService truncateHistory goal acknowledgment", () => { } }); + test("session usage is cleared when the cut fails after deleting window content", async () => { + const { config, historyService, workspaceService, cleanup } = await createServices(); + const workspaceId = "truncate-usage-clear-on-partial-commit"; + try { + await config.addWorkspace("/tmp/truncate-partial-commit-project", { + id: workspaceId, + name: workspaceId, + projectName: "truncate-partial-commit-project", + projectPath: "/tmp/truncate-partial-commit-project", + runtimeConfig: { type: "local" }, + }); + // Boundary-less rotated layout: the boundary append seals a prefix into + // the archive, then deleting the boundary row makes the archive rows + // active-window content. + for (let i = 0; i < 2; i++) { + expect( + ( + await historyService.appendToHistory( + workspaceId, + createMuxMessage(`msg-${i}`, "user", `message ${i}`) + ) + ).success + ).toBe(true); + } + expect( + ( + await historyService.appendToHistory( + workspaceId, + createMuxMessage("boundary-1", "assistant", "Summary 1", { + compactionBoundary: true, + compacted: "user", + compactionEpoch: 1, + }) + ) + ).success + ).toBe(true); + expect( + ( + await historyService.appendToHistory( + workspaceId, + createMuxMessage("user-heavy", "user", `heavy prompt ${"x".repeat(3_000)}`) + ) + ).success + ).toBe(true); + expect( + ( + await historyService.appendToHistory( + workspaceId, + createMuxMessage("assistant-active", "assistant", "active reply", { + contextUsage: { inputTokens: 95_000, outputTokens: 100, totalTokens: 95_100 }, + model: "openai:gpt-4o", + }) + ) + ).success + ).toBe(true); + expect((await historyService.deleteMessage(workspaceId, "boundary-1")).success).toBe(true); + + const clearUsageState = mock(() => undefined); + const session = { + isBusy: mock(() => false), + emitChatEvent: mock(() => undefined), + clearUsageState, + clearFileState: mock(() => undefined), + } as unknown as AgentSession; + (workspaceService as unknown as { sessions: Map }).sessions.set( + workspaceId, + session + ); + + // Fail the chat cut (serialize call 2) after the whole-archive delete + // commits: truncation returns Err, but window content is already gone, + // so the session's usage must still be invalidated. + const internals = historyService as unknown as { + serializeHistoryEntries: (messages: MuxMessage[], workspaceId: string) => string; + }; + const realSerialize = internals.serializeHistoryEntries.bind(historyService); + let serializeCalls = 0; + const serializeSpy = spyOn(internals, "serializeHistoryEntries").mockImplementation( + (messages: MuxMessage[], wsId: string) => { + serializeCalls += 1; + if (serializeCalls === 2) { + throw new Error("injected chat cut serialize failure"); + } + return realSerialize(messages, wsId); + } + ); + try { + const result = await workspaceService.truncateHistory(workspaceId, 0.5); + expect(result.success).toBe(false); + expect(clearUsageState).toHaveBeenCalledTimes(1); + } finally { + serializeSpy.mockRestore(); + } + } finally { + await cleanup(); + } + }); + test("context reset is a no-op when repeated without provider-eligible messages", async () => { const { config, historyService, workspaceService, cleanup } = await createServices(); const workspaceId = "context-reset-noop"; diff --git a/src/node/services/workspaceService.ts b/src/node/services/workspaceService.ts index 389b1a73c5..409640a9dc 100644 --- a/src/node/services/workspaceService.ts +++ b/src/node/services/workspaceService.ts @@ -3491,7 +3491,10 @@ export class WorkspaceService extends EventEmitter { initStateManager: this.initStateManager, workspaceGoalService: this.workspaceGoalService, clearHistoryForHardRestart: ({ monitorHistoryLockHeld }) => { - const clear = () => this.historyService.clearHistory(workspaceId); + const clear = () => + this.historyService.clearHistory(workspaceId, () => + this.sessions.get(workspaceId)?.clearUsageState() + ); const options = { discardUnacceptedOnSuccess: true }; return monitorHistoryLockHeld ? this.clearHistoryWithRetiredBashMonitorWakesUnlocked(workspaceId, clear, options) @@ -9666,20 +9669,17 @@ export class WorkspaceService extends EventEmitter { const effectivePercentage = percentage ?? 1.0; const isFullClear = effectivePercentage >= 1.0; - // Invalidate usage inside the truncate step, immediately after the rewrite - // commits: later wrapper steps (monitor-wake restoration) can fail after - // chat.jsonl has already changed, and stale usage must not survive that. - // A cut confined to sealed pre-boundary rows leaves the active provider - // context (and its usage snapshots) valid, so it must not invalidate. - // The success-only guard is safe because HistoryService.truncateHistory - // orders its two-file rewrite so no failure changes the active window. - const truncate = async () => { - const result = await this.historyService.truncateHistory(workspaceId, effectivePercentage); - if (result.success && result.data.activeContextTruncated) { - this.sessions.get(workspaceId)?.clearUsageState(); - } - return result; - }; + // Invalidate usage the moment a window-changing step commits, via the + // truncation callback: the cut can fail AFTER deleting provider-eligible + // archive rows (boundary-less rotated layout), and later wrapper steps + // (monitor-wake restoration) can fail after chat.jsonl has changed, so a + // success-only guard would leave stale usage alive across both windows. + // A cut confined to sealed pre-boundary rows never fires the callback, + // keeping the still-valid usage seedable. + const truncate = () => + this.historyService.truncateHistory(workspaceId, effectivePercentage, () => + this.sessions.get(workspaceId)?.clearUsageState() + ); const truncateResult = effectivePercentage > 0 ? await this.clearHistoryWithRetiredBashMonitorWakes(workspaceId, truncate, { @@ -9877,14 +9877,15 @@ export class WorkspaceService extends EventEmitter { const clearResult = await this.clearHistoryWithRetiredBashMonitorWakes( workspaceId, - () => this.historyService.clearHistory(workspaceId), + () => + this.historyService.clearHistory(workspaceId, () => + this.sessions.get(workspaceId)?.clearUsageState() + ), { discardUnacceptedOnSuccess: true } ); if (!clearResult.success) { return Err(`Failed to clear history: ${clearResult.error}`); } - // History is already gone even if the summary append below fails. - this.sessions.get(workspaceId)?.clearUsageState(); this.timelineRecorder.record(workspaceId, { kind: "history.cleared", source: { system: "chat" }, From b58b5daaa34919f8731f83a0db784c8e1dd552fc Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Fri, 7 Aug 2026 23:47:19 +0000 Subject: [PATCH 24/36] fix: skip pre-cut usage sanitization when only the chat cut changes the window In normal rotated layouts the final chat cut is the only window-changing step and strips usage in the same atomic write, so a separate sanitize write only created a crash window stranding an unchanged near-limit context with no seedable usage. Pre-sanitize now happens only when the archive step that runs first is itself window-changing (boundary-less or boundary-in-archive layouts), where stripping first remains the safe direction. --- src/node/services/historyService.test.ts | 70 ++++++++++++++++++++---- src/node/services/historyService.ts | 48 +++++++++------- 2 files changed, 87 insertions(+), 31 deletions(-) diff --git a/src/node/services/historyService.test.ts b/src/node/services/historyService.test.ts index 5743b9e5b7..7ed8b2f903 100644 --- a/src/node/services/historyService.test.ts +++ b/src/node/services/historyService.test.ts @@ -2358,18 +2358,16 @@ describe("HistoryService", () => { ); // Abort between the archive delete and the final chat.jsonl commit - // (serialize call 1 is the pre-cut usage sanitization, call 2 the cut). - // Every archive row was a cut target, so the failure must leave every - // chat.jsonl row (the active window and all retained rows) in place. + // (the cut serializes the retained rows). Every archive row was a cut + // target, so the failure must leave every chat.jsonl row (the active + // window and all retained rows) in place. const internals = service as unknown as { serializeHistoryEntries: (messages: MuxMessage[], workspaceId: string) => string; }; const realSerialize = internals.serializeHistoryEntries.bind(service); - let serializeCalls = 0; const serializeSpy = spyOn(internals, "serializeHistoryEntries").mockImplementation( (messages: MuxMessage[], workspaceId: string) => { - serializeCalls += 1; - if (serializeCalls === 2) { + if (messages.some((msg) => msg.id === "assistant-active")) { throw new Error("injected serialize failure"); } return realSerialize(messages, workspaceId); @@ -2532,7 +2530,7 @@ describe("HistoryService", () => { // Normal rotated layout: the boundary is the first chat.jsonl row, so // every archive row is sealed pre-boundary history and deleting the // archive leaves the provider window unchanged. The failed chat cut - // must still roll back the pre-cut sanitization. + // must leave the original chat bytes (and their usage) untouched. await appendNumberedMessages(service, wsId, 2); await service.appendToHistory(wsId, boundaryMessage("boundary-1", 1)); await service.appendToHistory( @@ -2549,16 +2547,14 @@ describe("HistoryService", () => { const chatBefore = await fs.readFile(chatPath(wsId), "utf-8"); - // Serialize call 1 is the pre-cut sanitization; call 2 is the chat cut. + // Fail the chat cut (the serialize of the retained rows). const internals = service as unknown as { serializeHistoryEntries: (messages: MuxMessage[], workspaceId: string) => string; }; const realSerialize = internals.serializeHistoryEntries.bind(service); - let serializeCalls = 0; const serializeSpy = spyOn(internals, "serializeHistoryEntries").mockImplementation( (messages: MuxMessage[], workspaceId: string) => { - serializeCalls += 1; - if (serializeCalls === 2) { + if (messages.some((msg) => msg.id === "assistant-active")) { throw new Error("injected chat cut serialize failure"); } return realSerialize(messages, workspaceId); @@ -2581,6 +2577,58 @@ describe("HistoryService", () => { } }); + it("never pre-sanitizes chat usage when only the chat cut changes the window", async () => { + // Normal rotated layout: the boundary is the first chat.jsonl row, so + // the archive delete is not window-changing and the final chat cut + // strips usage in the same atomic write. A separate sanitize write + // before the cut would create a crash window that strands an + // unchanged near-limit context with no seedable usage. + await appendNumberedMessages(service, wsId, 2); + await service.appendToHistory(wsId, boundaryMessage("boundary-1", 1)); + await service.appendToHistory( + wsId, + createMuxMessage("user-heavy", "user", `heavy prompt ${"x".repeat(3_000)}`) + ); + await service.appendToHistory( + wsId, + createMuxMessage("assistant-active", "assistant", "active reply", { + contextUsage: { inputTokens: 95_000, outputTokens: 100, totalTokens: 95_100 }, + model: "openai:gpt-4o", + }) + ); + + const chatBefore = await fs.readFile(chatPath(wsId), "utf-8"); + + // Probe chat.jsonl at the instant the archive delete runs: this is the + // state a crash between any earlier write and the cut would leave. + const probe: { chatAtArchiveDelete: string | null } = { chatAtArchiveDelete: null }; + const realRm = fs.rm; + const rmSpy = spyOn(fs, "rm").mockImplementation( + async (...args: Parameters) => { + if (args[0] === archivePath(wsId)) { + probe.chatAtArchiveDelete = await fs.readFile(chatPath(wsId), "utf-8"); + } + return realRm(...args); + } + ); + try { + const truncateResult = await service.truncateHistory(wsId, 0.5); + expect(truncateResult.success).toBe(true); + if (truncateResult.success) { + expect(truncateResult.data.activeContextTruncated).toBe(true); + } + expect(probe.chatAtArchiveDelete).toBe(chatBefore); + // The committed cut still strips usage from retained rows. + const chatRows = await readJsonlFile(chatPath(wsId)); + const activeAssistant = chatRows.find((msg) => msg.id === "assistant-active"); + expect(activeAssistant).toBeDefined(); + expect(activeAssistant?.metadata?.contextUsage).toBeUndefined(); + expect(await fileExists(archivePath(wsId))).toBe(false); + } finally { + rmSpy.mockRestore(); + } + }); + it("keeps the active file intact when a remove-all cut fails on the archive delete", async () => { await appendNumberedMessages(service, wsId, 2); await service.appendToHistory(wsId, boundaryMessage("boundary-1", 1)); diff --git a/src/node/services/historyService.ts b/src/node/services/historyService.ts index ed26f01b93..5752316110 100644 --- a/src/node/services/historyService.ts +++ b/src/node/services/historyService.ts @@ -2100,26 +2100,6 @@ export class HistoryService { // deletion of rows the cut removes anyway, so no failure or crash can // lose retained rows or leave duplicates behind. // - // When the cut spans two files and usage must be stripped, sanitize - // chat.jsonl FIRST (metadata-only atomic write, rows unchanged) and - // roll it back byte-exactly if the cut fails before any window- - // changing step commits, so every runtime failure returns Err with - // both files as they were. A process death between the sanitize - // commit and the cut commit leaves the window unchanged with usage - // stripped, the same state every legitimate active-window cut - // deliberately creates (one unmonitored send, repaired by the next - // provider response); the inverse ordering's death window instead - // pairs a changed window with stale usage, which a restart reseeds - // into a spurious auto-compaction that nothing repairs. - const needsPreSanitize = activeContextTruncated && archivedMessages.length > 0; - let originalChat: string | null = null; - if (needsPreSanitize) { - originalChat = await fs.readFile(historyPath, "utf-8").catch(() => null); - await writeFileAtomic( - historyPath, - this.serializeHistoryEntries(chatMessages.map(sanitizeRetained), workspaceId) - ); - } // Deleting the whole archive commits a window change only when the // window extends into it (boundary inside the archive, or none at // all). A normally rotated archive holds only sealed pre-boundary @@ -2129,6 +2109,34 @@ export class HistoryService { messages, archivedMessages.length ); + // Sanitize chat.jsonl BEFORE the cut only when the archive step that + // runs first is itself window-changing (an archive-confined cut + // removing window rows, or a whole-archive delete of window rows; + // both need a boundary-less or boundary-in-archive layout). Then a + // crash between sanitize and cut leaves the window unchanged with + // usage stripped: one unmonitored send, either repaired by the next + // provider response or surfaced as a provider context-length error, + // the same states as histories predating usage snapshots. The + // inverse ordering's crash window pairs a changed window with stale + // usage, which a restart reseeds into a spurious auto-compaction + // that nothing repairs. In normal rotated layouts the only + // window-changing step is the final chat cut, which strips usage in + // the same atomic write, so no separate sanitize write exists for a + // crash to strand. Runtime failures before any window-changing + // commit roll the sanitize back byte-exactly, returning Err with + // both files as they were. + const needsPreSanitize = + removeCount < archivedMessages.length + ? activeContextTruncated + : archiveDeleteChangesWindow; + let originalChat: string | null = null; + if (needsPreSanitize) { + originalChat = await fs.readFile(historyPath, "utf-8").catch(() => null); + await writeFileAtomic( + historyPath, + this.serializeHistoryEntries(chatMessages.map(sanitizeRetained), workspaceId) + ); + } let windowChanged = false; try { if (removeCount < archivedMessages.length) { From b59a3fe9a9fa0ddeb662f35fb86e520c3f0c7fba Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Sat, 8 Aug 2026 00:03:40 +0000 Subject: [PATCH 25/36] fix: clear usage at the edit-truncation commit point so a partial commit cannot leave stale usage truncateAfterMessage rewrites chat.jsonl and can still fail afterwards (archive removal in the pre-boundary branch, the sequence-floor archive read in the active branch). The new onContextRewritten callback fires the moment the rewrite commits, and AgentSession invalidates usage through it instead of only on success. --- .../agentSession.autoCompaction.test.ts | 53 +++++++++++- src/node/services/agentSession.ts | 15 ++-- src/node/services/historyService.test.ts | 84 +++++++++++++++++++ src/node/services/historyService.ts | 27 +++++- 4 files changed, 169 insertions(+), 10 deletions(-) diff --git a/src/node/services/agentSession.autoCompaction.test.ts b/src/node/services/agentSession.autoCompaction.test.ts index cce594b6be..1af648cc50 100644 --- a/src/node/services/agentSession.autoCompaction.test.ts +++ b/src/node/services/agentSession.autoCompaction.test.ts @@ -1,4 +1,4 @@ -import { afterEach, describe, expect, mock, test } from "bun:test"; +import { afterEach, describe, expect, mock, spyOn, test } from "bun:test"; import { EventEmitter } from "events"; import type { @@ -1006,6 +1006,57 @@ describe("AgentSession on-send auto-compaction snapshot deferral", () => { session.dispose(); }); + test("edit truncation clears usage even when the cut fails after committing", async () => { + const workspaceId = "ws-edit-truncation-partial-commit"; + const { session, historyService } = await createSessionHarness({ workspaceId }); + + const rows = [ + createMuxMessage("user-1", "user", "first prompt", { timestamp: Date.now() - 5_000 }), + createMuxMessage("assistant-1", "assistant", "first reply", { + timestamp: Date.now() - 4_000, + model: "openai:gpt-4o", + }), + createMuxMessage("user-2", "user", "second prompt", { timestamp: Date.now() - 3_000 }), + ]; + for (const row of rows) { + expect((await historyService.appendToHistory(workspaceId, row)).success).toBe(true); + } + + // Live pre-edit usage that the removed suffix produced. + const sessionState = session as unknown as { lastUsageState?: AutoCompactionUsageState }; + sessionState.lastUsageState = { totalTokens: 95_000 }; + + // Fail the sequence-floor read that runs after the rewrite commits, so + // truncation returns Err with chat.jsonl already cut. + const internals = historyService as unknown as { + getArchiveTailMaxSequence: (workspaceId: string) => Promise; + }; + const realSeq = internals.getArchiveTailMaxSequence.bind(historyService); + const seqSpy = spyOn(internals, "getArchiveTailMaxSequence").mockImplementation( + async (wsId: string) => { + const window = await historyService.getHistoryFromLatestBoundary(workspaceId); + const stillHasTarget = window.success && window.data.some((msg) => msg.id === "user-2"); + if (!stillHasTarget) { + throw new Error("injected sequence-floor read failure"); + } + return realSeq(wsId); + } + ); + try { + const editResult = await session.sendMessage("second prompt, edited", { + model: "openai:gpt-4o", + agentId: "exec", + editMessageId: "user-2", + }); + expect(editResult.success).toBe(false); + expect(sessionState.lastUsageState).toBeUndefined(); + } finally { + seqSpy.mockRestore(); + } + + session.dispose(); + }); + test("editing a pre-reset turn re-enables seeding for the restored prefix", async () => { const workspaceId = "ws-pre-reset-edit-reenables-seeding"; const { session, historyService } = await createSessionHarness({ workspaceId }); diff --git a/src/node/services/agentSession.ts b/src/node/services/agentSession.ts index f070945778..490a0a9eed 100644 --- a/src/node/services/agentSession.ts +++ b/src/node/services/agentSession.ts @@ -2734,15 +2734,18 @@ export class AgentSession { // when the edit target is outside the active context window. const truncateTargetId = await this.getEditTruncateTargetId(editMessageId); + // Invalidate usage the moment the rewrite commits, not on success: the + // truncation can fail after chat.jsonl changed (archive removal or the + // sequence-floor read), and stale usage from the removed suffix must + // not survive that. Edits cut a suffix; the retained prefix's persisted + // usage is valid, so re-enable history seeding even if a prior rewrite + // suppressed it. const truncateResult = await this.historyService.truncateAfterMessage( this.workspaceId, - truncateTargetId + truncateTargetId, + { onContextRewritten: () => this.clearUsageState({ reenableHistorySeeding: true }) } ); - if (truncateResult.success) { - // Edits cut a suffix; the retained prefix's persisted usage is valid, - // so re-enable history seeding even if a prior rewrite suppressed it. - this.clearUsageState({ reenableHistorySeeding: true }); - } else { + if (!truncateResult.success) { const isMissingEditTarget = truncateResult.error.includes("Message with ID") && truncateResult.error.includes("not found in history"); diff --git a/src/node/services/historyService.test.ts b/src/node/services/historyService.test.ts index 7ed8b2f903..42ea1e1c9c 100644 --- a/src/node/services/historyService.test.ts +++ b/src/node/services/historyService.test.ts @@ -1945,6 +1945,90 @@ describe("HistoryService", () => { expect(msg.metadata?.historySequence).toBe(2); }); + it("notifies context rewrite when the archived-edit cut fails after committing chat.jsonl", async () => { + // Pre-boundary edit: chat.jsonl is rewritten first, then the archive is + // removed. An archive-removal failure returns Err with the provider + // context already changed, so the caller must still hear about it. + await appendNumberedMessages(service, wsId, 3); + await service.appendToHistory(wsId, boundaryMessage("boundary-1", 1)); + await service.appendToHistory(wsId, createMuxMessage("post-0", "user", "after")); + + const realRm = fs.rm; + const rmSpy = spyOn(fs, "rm").mockImplementation((...args: Parameters) => { + if (args[0] === archivePath(wsId)) { + return Promise.reject( + Object.assign(new Error("EACCES: permission denied"), { code: "EACCES" }) + ); + } + return realRm(...args); + }); + try { + let notified = 0; + const truncateResult = await service.truncateAfterMessage(wsId, "msg-1", { + keepTargetMessage: true, + onContextRewritten: () => { + notified += 1; + }, + }); + expect(truncateResult.success).toBe(false); + expect(notified).toBe(1); + // The committed rewrite stands: chat.jsonl holds the collapsed prefix. + const chatRows = await readJsonlFile(chatPath(wsId)); + expect(chatRows.map((msg) => msg.id)).toEqual(["msg-0", "msg-1"]); + } finally { + rmSpy.mockRestore(); + } + }); + + it("does not notify context rewrite when the edit target is missing", async () => { + await appendNumberedMessages(service, wsId, 2); + + let notified = 0; + const truncateResult = await service.truncateAfterMessage(wsId, "nonexistent", { + onContextRewritten: () => { + notified += 1; + }, + }); + expect(truncateResult.success).toBe(false); + expect(notified).toBe(0); + }); + + it("notifies context rewrite when the active-edit cut fails after committing chat.jsonl", async () => { + // The sequence-floor read of the archive tail runs after the atomic + // rewrite; its failure returns Err with the cut already committed. + await appendNumberedMessages(service, wsId, 3); + + const internals = service as unknown as { + getArchiveTailMaxSequence: (workspaceId: string) => Promise; + }; + const realSeq = internals.getArchiveTailMaxSequence.bind(service); + // Earlier reads also consult the sequence floor, so reject only the + // call made after the rewrite committed (msg-1 gone from chat.jsonl). + const seqSpy = spyOn(internals, "getArchiveTailMaxSequence").mockImplementation( + async (workspaceId: string) => { + const chat = await fs.readFile(chatPath(wsId), "utf-8").catch(() => ""); + if (!chat.includes('"msg-1"')) { + throw new Error("injected sequence-floor read failure"); + } + return realSeq(workspaceId); + } + ); + try { + let notified = 0; + const truncateResult = await service.truncateAfterMessage(wsId, "msg-1", { + onContextRewritten: () => { + notified += 1; + }, + }); + expect(truncateResult.success).toBe(false); + expect(notified).toBe(1); + const chatRows = await readJsonlFile(chatPath(wsId)); + expect(chatRows.map((msg) => msg.id)).toEqual(["msg-0"]); + } finally { + seqSpy.mockRestore(); + } + }); + it("never reuses archived sequences after truncating the whole active epoch", async () => { await appendNumberedMessages(service, wsId, 3); // msg-0..2, seq 0..2 → archived await service.appendToHistory(wsId, boundaryMessage("boundary-1", 1)); // seq 3 diff --git a/src/node/services/historyService.ts b/src/node/services/historyService.ts index 5752316110..6fe50e6c90 100644 --- a/src/node/services/historyService.ts +++ b/src/node/services/historyService.ts @@ -1797,12 +1797,27 @@ export class HistoryService { * * By default this removes the target message and all subsequent messages. Callers can retain the * target message when branching a new workspace from a specific reply. + * + * options.onContextRewritten fires the moment the chat.jsonl rewrite + * commits, even when a later step fails, so callers can invalidate cached + * usage for mutations an Err result leaves on disk. */ async truncateAfterMessage( workspaceId: string, messageId: string, - options?: { keepTargetMessage?: boolean } + options?: { keepTargetMessage?: boolean; onContextRewritten?: () => void } ): Promise> { + const notifyContextRewritten = () => { + try { + options?.onContextRewritten?.(); + } catch (error) { + // A notification failure must not turn a committed rewrite into Err. + log.error("truncateAfterMessage context-rewritten callback failed", { + workspaceId, + error, + }); + } + }; return this.fileLocks.withLock(workspaceId, async () => { try { // Structural rewrite requires full file content @@ -1819,7 +1834,8 @@ export class HistoryService { return this.truncateAfterArchivedMessageUnlocked( workspaceId, messageId, - keepTargetMessage + keepTargetMessage, + notifyContextRewritten ); } @@ -1836,6 +1852,8 @@ export class HistoryService { // Atomic write prevents corruption if app crashes mid-write await writeFileAtomic(historyPath, historyEntries); + // The archive read below can still fail after this commit. + notifyContextRewritten(); // Update sequence counter to continue from where we truncated. // Self-healing read path: skip malformed persisted historySequence values. @@ -1888,7 +1906,8 @@ export class HistoryService { private async truncateAfterArchivedMessageUnlocked( workspaceId: string, messageId: string, - keepTargetMessage: boolean + keepTargetMessage: boolean, + notifyContextRewritten: () => void ): Promise> { try { const archiveMessages = await this.readArchivedHistory(workspaceId); @@ -1907,6 +1926,8 @@ export class HistoryService { this.getChatHistoryPath(workspaceId), this.serializeHistoryEntries(truncatedMessages, workspaceId) ); + // The archive removal below can still fail after this commit. + notifyContextRewritten(); await fs.rm(this.getChatArchivePath(workspaceId), { force: true }); // chat.jsonl may contain sealed epochs again — allow the lazy check to re-run. this.sealedRotationChecked.delete(workspaceId); From 5d1e3ebd0f5666af6c4c35ac3a1c7403ab9492ac Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Sat, 8 Aug 2026 00:11:02 +0000 Subject: [PATCH 26/36] fix: keep history seeding suppressed when an archived edit partially commits A pre-boundary edit that commits chat.jsonl but fails archive removal leaves the archive alongside a duplicated prefix whose usage snapshots no longer measure the real payload. The commit-point callback now only clears usage; seeding is re-enabled solely when the truncation fully succeeds and both files are consistent. --- .../agentSession.autoCompaction.test.ts | 63 +++++++++++++++++++ src/node/services/agentSession.ts | 16 +++-- 2 files changed, 74 insertions(+), 5 deletions(-) diff --git a/src/node/services/agentSession.autoCompaction.test.ts b/src/node/services/agentSession.autoCompaction.test.ts index 1af648cc50..07e79b7cbd 100644 --- a/src/node/services/agentSession.autoCompaction.test.ts +++ b/src/node/services/agentSession.autoCompaction.test.ts @@ -1,4 +1,5 @@ import { afterEach, describe, expect, mock, spyOn, test } from "bun:test"; +import * as fs from "fs/promises"; import { EventEmitter } from "events"; import type { @@ -1057,6 +1058,68 @@ describe("AgentSession on-send auto-compaction snapshot deferral", () => { session.dispose(); }); + test("archived edit partial failure keeps seeding suppressed", async () => { + const workspaceId = "ws-archived-edit-partial-failure"; + const { session, historyService } = await createSessionHarness({ workspaceId }); + + const rows = [ + createMuxMessage("user-1", "user", "first prompt", { timestamp: Date.now() - 6_000 }), + createMuxMessage("assistant-1", "assistant", "first reply", { + timestamp: Date.now() - 5_000, + model: "openai:gpt-4o", + contextUsage: { inputTokens: 95_000, outputTokens: 100, totalTokens: 95_100 }, + }), + createMuxMessage("user-2", "user", "second prompt", { timestamp: Date.now() - 4_000 }), + // Boundary append seals the prefix into the archive. + createMuxMessage("boundary-1", "assistant", "Summary 1", { + timestamp: Date.now() - 3_000, + compactionBoundary: true, + compacted: "user", + compactionEpoch: 1, + }), + ]; + for (const row of rows) { + expect((await historyService.appendToHistory(workspaceId, row)).success).toBe(true); + } + + // Fail the archive removal so the pre-boundary edit commits chat.jsonl + // (the copied prefix) but returns Err with the archive still present. + const realRm = fs.rm; + const rmSpy = spyOn(fs, "rm").mockImplementation((...args: Parameters) => { + const target = String(args[0]); + if (target.includes(workspaceId) && target.includes("chat-archive")) { + return Promise.reject( + Object.assign(new Error("EACCES: permission denied"), { code: "EACCES" }) + ); + } + return realRm(...args); + }); + try { + const editResult = await session.sendMessage("second prompt, edited", { + model: "openai:gpt-4o", + agentId: "exec", + editMessageId: "user-2", + }); + expect(editResult.success).toBe(false); + } finally { + rmSpy.mockRestore(); + } + + // The archive now coexists with a duplicated prefix whose usage snapshot + // no longer measures the real payload; seeding must stay suppressed + // until fresh provider usage arrives. + const seenUsages = installUsageCapturingMonitor(session); + const result = await session.sendMessage("follow-up send", { + model: "openai:gpt-4o", + agentId: "exec", + }); + expect(result.success).toBe(true); + expect(seenUsages).toHaveLength(1); + expect(seenUsages[0]).toBeUndefined(); + + session.dispose(); + }); + test("editing a pre-reset turn re-enables seeding for the restored prefix", async () => { const workspaceId = "ws-pre-reset-edit-reenables-seeding"; const { session, historyService } = await createSessionHarness({ workspaceId }); diff --git a/src/node/services/agentSession.ts b/src/node/services/agentSession.ts index 490a0a9eed..81ebc2daa1 100644 --- a/src/node/services/agentSession.ts +++ b/src/node/services/agentSession.ts @@ -2737,15 +2737,21 @@ export class AgentSession { // Invalidate usage the moment the rewrite commits, not on success: the // truncation can fail after chat.jsonl changed (archive removal or the // sequence-floor read), and stale usage from the removed suffix must - // not survive that. Edits cut a suffix; the retained prefix's persisted - // usage is valid, so re-enable history seeding even if a prior rewrite - // suppressed it. + // not survive that. Seeding stays suppressed at the commit point + // because a partial failure can leave the archive alongside a + // duplicated prefix, where retained-row snapshots no longer measure + // the real payload. const truncateResult = await this.historyService.truncateAfterMessage( this.workspaceId, truncateTargetId, - { onContextRewritten: () => this.clearUsageState({ reenableHistorySeeding: true }) } + { onContextRewritten: () => this.clearUsageState() } ); - if (!truncateResult.success) { + if (truncateResult.success) { + // Fully committed edits cut a suffix and leave both files + // consistent; the retained prefix's persisted usage is valid, so + // re-enable history seeding even if a prior rewrite suppressed it. + this.clearUsageState({ reenableHistorySeeding: true }); + } else { const isMissingEditTarget = truncateResult.error.includes("Message with ID") && truncateResult.error.includes("not found in history"); From c9bb86a07395877d3b3cb1e45eb481ee2e5b181d Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Sat, 8 Aug 2026 00:26:01 +0000 Subject: [PATCH 27/36] fix: report committed deletions at commit time so failed cuts cannot leave renderer ghost rows truncateHistory/clearHistory now take an onRowsDeleted callback fired with each committed step's deleted sequences, even when a later step fails. The three DeleteMessage emitters (truncateHistory, destructive replaceHistory, exec hard-restart clear) emit accumulated committed deletions on failure paths too, so rows already removed from disk cannot linger in the renderer with sequences no retry can report. --- src/node/services/agentSession.ts | 36 ++++++-- src/node/services/historyService.test.ts | 40 +++++++-- src/node/services/historyService.ts | 56 +++++++++---- src/node/services/workspaceService.test.ts | 16 +++- src/node/services/workspaceService.ts | 98 ++++++++++++++-------- 5 files changed, 180 insertions(+), 66 deletions(-) diff --git a/src/node/services/agentSession.ts b/src/node/services/agentSession.ts index 81ebc2daa1..9a8547741c 100644 --- a/src/node/services/agentSession.ts +++ b/src/node/services/agentSession.ts @@ -388,6 +388,7 @@ interface AgentSessionOptions { /** Destructive clear coordinator used by exec hard restart. */ clearHistoryForHardRestart?: (options: { monitorHistoryLockHeld: boolean; + onRowsDeleted?: (historySequences: number[]) => void; }) => Promise>; /** When true, skip terminating background processes on dispose/compaction (for bench/CI) */ keepBackgroundProcesses?: boolean; @@ -423,6 +424,7 @@ export class AgentSession { private readonly workspaceGoalService?: WorkspaceGoalService; private readonly clearHistoryForHardRestart?: (options: { monitorHistoryLockHeld: boolean; + onRowsDeleted?: (historySequences: number[]) => void; }) => Promise>; private readonly keepBackgroundProcesses: boolean; private readonly onPostCompactionStateChange?: () => void; @@ -4618,12 +4620,35 @@ export class AgentSession { }); } + // Same ghost-row invariant as WorkspaceService.truncateHistory: emit + // deletions for every committed clear step even when a later step fails. + const committedDeletedSequences: number[] = []; + const onRowsDeleted = (historySequences: number[]) => { + committedDeletedSequences.push(...historySequences); + }; + const emitCommittedDeletions = () => { + if (committedDeletedSequences.length === 0) { + return; + } + const deleteMessage: DeleteMessage = { + type: "delete", + historySequences: [...committedDeletedSequences], + }; + committedDeletedSequences.length = 0; + this.emitChatEvent(deleteMessage); + }; const clearResult = this.clearHistoryForHardRestart ? await this.clearHistoryForHardRestart({ monitorHistoryLockHeld: context.monitorHistoryLockHeld === true, + onRowsDeleted, }) - : await this.historyService.clearHistory(this.workspaceId, () => this.clearUsageState()); + : await this.historyService.clearHistory( + this.workspaceId, + () => this.clearUsageState(), + onRowsDeleted + ); if (!clearResult.success) { + emitCommittedDeletions(); log.warn("Failed to clear history for exec subagent hard restart", { workspaceId: this.workspaceId, error: clearResult.error, @@ -4641,14 +4666,7 @@ export class AgentSession { reason: "exec sub-agent hard restart", }); - const deletedSequences = clearResult.data; - if (deletedSequences.length > 0) { - const deleteMessage: DeleteMessage = { - type: "delete", - historySequences: deletedSequences, - }; - this.emitChatEvent(deleteMessage); - } + emitCommittedDeletions(); const cloneForAppend = (msg: MuxMessage): MuxMessage => { const metadataCopy = msg.metadata ? { ...msg.metadata } : undefined; diff --git a/src/node/services/historyService.test.ts b/src/node/services/historyService.test.ts index 42ea1e1c9c..c47e74497e 100644 --- a/src/node/services/historyService.test.ts +++ b/src/node/services/historyService.test.ts @@ -2125,14 +2125,22 @@ describe("HistoryService", () => { ); let notified = 0; - const truncateResult = await service.truncateHistory(wsId, 0.5, () => { - notified += 1; - }); + const reportedDeletions: number[] = []; + const truncateResult = await service.truncateHistory( + wsId, + 0.5, + () => { + notified += 1; + }, + (historySequences) => reportedDeletions.push(...historySequences) + ); expect(truncateResult.success).toBe(true); expect(notified).toBe(1); if (truncateResult.success) { expect(truncateResult.data.deletedSequences.length).toBeGreaterThan(0); expect(truncateResult.data.activeContextTruncated).toBe(true); + // Commit-time reports cover exactly the sequences the result returns. + expect(reportedDeletions).toEqual(truncateResult.data.deletedSequences); } const remaining = await service.getHistoryFromLatestBoundary(wsId); @@ -2591,11 +2599,18 @@ describe("HistoryService", () => { ); try { // The archive delete removed window content, so the caller must be - // notified at commit time despite the Err result. + // notified at commit time despite the Err result, and the committed + // archive deletions must be reported so the renderer can drop them. let notified = 0; - const truncateResult = await service.truncateHistory(wsId, 0.5, () => { - notified += 1; - }); + const reportedDeletions: number[] = []; + const truncateResult = await service.truncateHistory( + wsId, + 0.5, + () => { + notified += 1; + }, + (historySequences) => reportedDeletions.push(...historySequences) + ); expect(truncateResult.success).toBe(false); expect(notified).toBe(1); expect(await fileExists(archivePath(wsId))).toBe(false); @@ -2605,6 +2620,9 @@ describe("HistoryService", () => { const activeAssistant = chatRows.find((msg) => msg.id === "assistant-active"); expect(activeAssistant).toBeDefined(); expect(activeAssistant?.metadata?.contextUsage).toBeUndefined(); + // Exactly the deleted archive rows (msg-0, msg-1), not the retained + // chat rows whose cut never committed. + expect(reportedDeletions).toEqual([0, 1]); } finally { serializeSpy.mockRestore(); } @@ -2766,10 +2784,16 @@ describe("HistoryService", () => { return realRm(...args); }); try { - const truncateResult = await service.truncateHistory(wsId, 1.0); + // Committed archive deletions must still be reported despite the Err + // (clearHistory forwards this callback for the destructive callers). + const reportedDeletions: number[] = []; + const truncateResult = await service.truncateHistory(wsId, 1.0, undefined, (seqs) => + reportedDeletions.push(...seqs) + ); expect(truncateResult.success).toBe(false); expect(await fs.readFile(chatPath(wsId), "utf-8")).toBe(chatBefore); expect(await fileExists(archivePath(wsId))).toBe(false); + expect(reportedDeletions).toEqual([0, 1, 2]); } finally { rmSpy.mockRestore(); } diff --git a/src/node/services/historyService.ts b/src/node/services/historyService.ts index 6fe50e6c90..7e8ff649dc 100644 --- a/src/node/services/historyService.ts +++ b/src/node/services/historyService.ts @@ -1976,6 +1976,10 @@ export class HistoryService { * committed step first removes provider-eligible rows from the * active window. Fires even when a later step fails, so callers can * invalidate cached usage for mutations an Err result leaves on disk. + * @param onRowsDeleted Invoked with each committed step's deleted + * historySequence numbers the moment that step commits. Fires even + * when a later step fails, so callers can emit renderer deletions + * for rows an Err result leaves removed from disk. * @returns Result with deleted historySequence numbers plus whether the cut * reached the active provider-context window (at or past the latest * durable boundary). Callers use that flag to decide whether cached @@ -1984,7 +1988,8 @@ export class HistoryService { async truncateHistory( workspaceId: string, percentage: number, - onActiveContextTruncated?: () => void + onActiveContextTruncated?: () => void, + onRowsDeleted?: (historySequences: number[]) => void ): Promise> { return this.fileLocks.withLock(workspaceId, async () => { let contextChangeNotified = false; @@ -2001,6 +2006,22 @@ export class HistoryService { log.error("truncateHistory context-change callback failed", { workspaceId, error }); } }; + const sequencesOf = (msgs: MuxMessage[]): number[] => + msgs + .map((msg) => msg.metadata?.historySequence) + .filter((s): s is number => isNonNegativeInteger(s)); + const notifyRowsDeleted = (msgs: MuxMessage[]) => { + const historySequences = sequencesOf(msgs); + if (historySequences.length === 0) { + return; + } + try { + onRowsDeleted?.(historySequences); + } catch (error) { + // Same invariant as above: committed deletions must stand. + log.error("truncateHistory rows-deleted callback failed", { workspaceId, error }); + } + }; try { const historyPath = this.getChatHistoryPath(workspaceId); const archivePath = this.getChatArchivePath(workspaceId); @@ -2009,20 +2030,21 @@ export class HistoryService { if (percentage >= 1.0) { // Need sequence numbers for return value before deleting const archivedMessages = await this.readArchivedHistory(workspaceId); - const messages = [...archivedMessages, ...(await this.readChatHistory(workspaceId))]; - const deletedSequences = messages - .map((msg) => msg.metadata?.historySequence) - .filter((s): s is number => isNonNegativeInteger(s)); + const chatMessages = await this.readChatHistory(workspaceId); + const messages = [...archivedMessages, ...chatMessages]; + const deletedSequences = sequencesOf(messages); // Archive first: normally it holds only sealed pre-boundary rows, // so if the second rm fails the active provider window is still // intact. A malformed boundary-less archive IS window content, so // notify at commit; the caller must not trust a later Err. await fs.rm(archivePath, { force: true }); + notifyRowsDeleted(archivedMessages); if (cutChangesActiveWindow(messages, archivedMessages.length)) { notifyActiveContextTruncated(); } await fs.rm(historyPath, { force: true }); + notifyRowsDeleted(chatMessages); notifyActiveContextTruncated(); // Reset sequence counter when clearing history @@ -2079,16 +2101,15 @@ export class HistoryService { // with the same commit-time notifications. if (removeCount >= messages.length) { await fs.rm(archivePath, { force: true }); + notifyRowsDeleted(archivedMessages); if (cutChangesActiveWindow(messages, archivedMessages.length)) { notifyActiveContextTruncated(); } await fs.rm(historyPath, { force: true }); + notifyRowsDeleted(chatMessages); notifyActiveContextTruncated(); this.sequenceCounters.set(workspaceId, 0); - const deletedSequences = messages - .map((msg) => msg.metadata?.historySequence) - .filter((s): s is number => isNonNegativeInteger(s)); - return Ok({ deletedSequences, activeContextTruncated: true }); + return Ok({ deletedSequences: sequencesOf(messages), activeContextTruncated: true }); } const activeContextTruncated = cutChangesActiveWindow(messages, removeCount); @@ -2112,9 +2133,7 @@ export class HistoryService { }; const remainingMessages = messages.slice(removeCount).map(sanitizeRetained); const deletedMessages = messages.slice(0, removeCount); - const deletedSequences = deletedMessages - .map((msg) => msg.metadata?.historySequence) - .filter((s): s is number => isNonNegativeInteger(s)); + const deletedSequences = sequencesOf(deletedMessages); // Rewrite each file in place instead of collapsing the archive into // chat.jsonl: every step is either an atomic single-file write or a @@ -2167,12 +2186,14 @@ export class HistoryService { archivePath, this.serializeHistoryEntries(retainedArchive, workspaceId) ); + notifyRowsDeleted(deletedMessages); } else { // Cut consumes the whole archive: every archive row is a cut // target, so deleting the archive before committing the chat cut // can only remove rows the cut targets. if (archivedMessages.length > 0) { await fs.rm(archivePath, { force: true }); + notifyRowsDeleted(archivedMessages); windowChanged = archiveDeleteChangesWindow; if (archiveDeleteChangesWindow) { notifyActiveContextTruncated(); @@ -2185,6 +2206,7 @@ export class HistoryService { historyPath, this.serializeHistoryEntries(retainedChat, workspaceId) ); + notifyRowsDeleted(chatMessages.slice(0, removeCount - archivedMessages.length)); } } catch (error) { if (needsPreSanitize && !windowChanged && originalChat !== null) { @@ -2245,9 +2267,15 @@ export class HistoryService { async clearHistory( workspaceId: string, - onActiveContextTruncated?: () => void + onActiveContextTruncated?: () => void, + onRowsDeleted?: (historySequences: number[]) => void ): Promise> { - const result = await this.truncateHistory(workspaceId, 1.0, onActiveContextTruncated); + const result = await this.truncateHistory( + workspaceId, + 1.0, + onActiveContextTruncated, + onRowsDeleted + ); if (!result.success) { return Err(result.error); } diff --git a/src/node/services/workspaceService.test.ts b/src/node/services/workspaceService.test.ts index 3405fcf904..00a9f22508 100644 --- a/src/node/services/workspaceService.test.ts +++ b/src/node/services/workspaceService.test.ts @@ -4571,9 +4571,12 @@ describe("WorkspaceService truncateHistory goal acknowledgment", () => { expect((await historyService.deleteMessage(workspaceId, "boundary-1")).success).toBe(true); const clearUsageState = mock(() => undefined); + const emittedChatEvents: unknown[] = []; const session = { isBusy: mock(() => false), - emitChatEvent: mock(() => undefined), + emitChatEvent: mock((event: unknown) => { + emittedChatEvents.push(event); + }), clearUsageState, clearFileState: mock(() => undefined), } as unknown as AgentSession; @@ -4584,7 +4587,8 @@ describe("WorkspaceService truncateHistory goal acknowledgment", () => { // Fail the chat cut (serialize call 2) after the whole-archive delete // commits: truncation returns Err, but window content is already gone, - // so the session's usage must still be invalidated. + // so the session's usage must still be invalidated and the committed + // archive deletions must reach the renderer (no ghost rows). const internals = historyService as unknown as { serializeHistoryEntries: (messages: MuxMessage[], workspaceId: string) => string; }; @@ -4603,6 +4607,14 @@ describe("WorkspaceService truncateHistory goal acknowledgment", () => { const result = await workspaceService.truncateHistory(workspaceId, 0.5); expect(result.success).toBe(false); expect(clearUsageState).toHaveBeenCalledTimes(1); + const deleteEvents = emittedChatEvents.filter( + (event): event is { type: string; historySequences: number[] } => + typeof event === "object" && + event !== null && + (event as { type?: string }).type === "delete" + ); + expect(deleteEvents).toHaveLength(1); + expect(deleteEvents[0].historySequences).toEqual([0, 1]); } finally { serializeSpy.mockRestore(); } diff --git a/src/node/services/workspaceService.ts b/src/node/services/workspaceService.ts index 409640a9dc..4af10edf6a 100644 --- a/src/node/services/workspaceService.ts +++ b/src/node/services/workspaceService.ts @@ -3490,10 +3490,12 @@ export class WorkspaceService extends EventEmitter { telemetryService: this.telemetryService, initStateManager: this.initStateManager, workspaceGoalService: this.workspaceGoalService, - clearHistoryForHardRestart: ({ monitorHistoryLockHeld }) => { + clearHistoryForHardRestart: ({ monitorHistoryLockHeld, onRowsDeleted }) => { const clear = () => - this.historyService.clearHistory(workspaceId, () => - this.sessions.get(workspaceId)?.clearUsageState() + this.historyService.clearHistory( + workspaceId, + () => this.sessions.get(workspaceId)?.clearUsageState(), + onRowsDeleted ); const options = { discardUnacceptedOnSuccess: true }; return monitorHistoryLockHeld @@ -9676,26 +9678,27 @@ export class WorkspaceService extends EventEmitter { // success-only guard would leave stale usage alive across both windows. // A cut confined to sealed pre-boundary rows never fires the callback, // keeping the still-valid usage seedable. + const committedDeletedSequences: number[] = []; const truncate = () => - this.historyService.truncateHistory(workspaceId, effectivePercentage, () => - this.sessions.get(workspaceId)?.clearUsageState() + this.historyService.truncateHistory( + workspaceId, + effectivePercentage, + () => this.sessions.get(workspaceId)?.clearUsageState(), + (historySequences) => committedDeletedSequences.push(...historySequences) ); - const truncateResult = - effectivePercentage > 0 - ? await this.clearHistoryWithRetiredBashMonitorWakes(workspaceId, truncate, { - discardUnacceptedOnSuccess: isFullClear, - }) - : await truncate(); - if (!truncateResult.success) { - return Err(truncateResult.error); - } - - const deletedSequences = truncateResult.data.deletedSequences; - if (deletedSequences.length > 0) { + // Emit deletions for every committed step even when the truncation or a + // wrapper step fails afterwards: rows already removed from disk would + // otherwise linger in the renderer as ghosts that no retry can delete + // (their sequences no longer exist to be reported). + const emitCommittedDeletions = () => { + if (committedDeletedSequences.length === 0) { + return; + } const deleteMessage: DeleteMessage = { type: "delete", - historySequences: deletedSequences, + historySequences: [...committedDeletedSequences], }; + committedDeletedSequences.length = 0; // Emit through the session so ORPC subscriptions receive the event if (session) { session.emitChatEvent(deleteMessage); @@ -9703,6 +9706,22 @@ export class WorkspaceService extends EventEmitter { // Fallback to direct emit (legacy path) this.emit("chat", { workspaceId, message: deleteMessage }); } + }; + let truncateResult: Awaited>; + try { + truncateResult = + effectivePercentage > 0 + ? await this.clearHistoryWithRetiredBashMonitorWakes(workspaceId, truncate, { + discardUnacceptedOnSuccess: isFullClear, + }) + : await truncate(); + } catch (error) { + emitCommittedDeletions(); + throw error; + } + emitCommittedDeletions(); + if (!truncateResult.success) { + return Err(truncateResult.error); } // On full clear, also delete plan file and clear file change tracking @@ -9814,9 +9833,28 @@ export class WorkspaceService extends EventEmitter { const replaceMode = options?.mode ?? "destructive"; + // Same ghost-row invariant as truncateHistory: emit deletions for every + // committed clear step even when a later step fails, or rows already + // removed from disk linger in the renderer with no way to delete them. + const committedDeletedSequences: number[] = []; + const emitCommittedDeletions = () => { + if (committedDeletedSequences.length === 0) { + return; + } + const deleteMessage: DeleteMessage = { + type: "delete", + historySequences: [...committedDeletedSequences], + }; + committedDeletedSequences.length = 0; + const emitSession = this.sessions.get(workspaceId); + if (emitSession) { + emitSession.emitChatEvent(deleteMessage); + } else { + this.emit("chat", { workspaceId, message: deleteMessage }); + } + }; try { let messageToAppend = summaryMessage; - let deletedSequences: number[] = []; if (replaceMode === "append-compaction-boundary") { assert( @@ -9878,12 +9916,15 @@ export class WorkspaceService extends EventEmitter { const clearResult = await this.clearHistoryWithRetiredBashMonitorWakes( workspaceId, () => - this.historyService.clearHistory(workspaceId, () => - this.sessions.get(workspaceId)?.clearUsageState() + this.historyService.clearHistory( + workspaceId, + () => this.sessions.get(workspaceId)?.clearUsageState(), + (historySequences) => committedDeletedSequences.push(...historySequences) ), { discardUnacceptedOnSuccess: true } ); if (!clearResult.success) { + emitCommittedDeletions(); return Err(`Failed to clear history: ${clearResult.error}`); } this.timelineRecorder.record(workspaceId, { @@ -9891,28 +9932,18 @@ export class WorkspaceService extends EventEmitter { source: { system: "chat" }, status: "completed", }); - deletedSequences = clearResult.data; } const appendResult = await this.historyService.appendToHistory(workspaceId, messageToAppend); if (!appendResult.success) { + emitCommittedDeletions(); return Err(`Failed to append summary message: ${appendResult.error}`); } // Emit through the session so ORPC subscriptions receive the events const session = this.sessions.get(workspaceId); session?.clearUsageState(); - if (deletedSequences.length > 0) { - const deleteMessage: DeleteMessage = { - type: "delete", - historySequences: deletedSequences, - }; - if (session) { - session.emitChatEvent(deleteMessage); - } else { - this.emit("chat", { workspaceId, message: deleteMessage }); - } - } + emitCommittedDeletions(); // Add type: "message" for discriminated union (MuxMessage doesn't have it) const typedSummaryMessage = { ...messageToAppend, type: "message" as const }; @@ -9935,6 +9966,7 @@ export class WorkspaceService extends EventEmitter { return Ok(undefined); } catch (error) { + emitCommittedDeletions(); const message = getErrorMessage(error); return Err(`Failed to replace history: ${message}`); } From 170a0d90f0c42cade2c26bdb6dae844dc1659f2e Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Sat, 8 Aug 2026 00:36:54 +0000 Subject: [PATCH 28/36] fix: make the archived-edit duplicated-prefix state restart-safe by stripping usage while the hazard exists The copied prefix is written with usage snapshots stripped while the archive still exists, so a failed or crashed archive removal leaves nothing stale for a fresh session to seed. Once the removal commits the hazard is gone, and a restore write brings the usage back because it is exact for the restored prefix and an edited near-limit prefix must stay monitored on the next send. --- src/node/services/historyService.test.ts | 73 ++++++++++++++++++++++++ src/node/services/historyService.ts | 63 +++++++++++++++----- 2 files changed, 121 insertions(+), 15 deletions(-) diff --git a/src/node/services/historyService.test.ts b/src/node/services/historyService.test.ts index c47e74497e..fdfcf5c4c2 100644 --- a/src/node/services/historyService.test.ts +++ b/src/node/services/historyService.test.ts @@ -1980,6 +1980,79 @@ describe("HistoryService", () => { } }); + it("restores copied-prefix usage once the archived edit fully commits", async () => { + // Success path: the archive is gone, so the duplicated-prefix hazard + // no longer exists and the restored prefix's usage (exact for that + // context) must survive so the next send stays monitored. + await service.appendToHistory(wsId, createMuxMessage("msg-0", "user", "first prompt")); + await service.appendToHistory( + wsId, + createMuxMessage("assistant-archived", "assistant", "archived reply", { + model: "openai:gpt-4o", + contextUsage: { inputTokens: 95_000, outputTokens: 100, totalTokens: 95_100 }, + }) + ); + await service.appendToHistory(wsId, boundaryMessage("boundary-1", 1)); + await service.appendToHistory(wsId, createMuxMessage("post-0", "user", "after")); + + const truncateResult = await service.truncateAfterMessage(wsId, "assistant-archived", { + keepTargetMessage: true, + }); + expect(truncateResult.success).toBe(true); + expect(await fileExists(archivePath(wsId))).toBe(false); + const chatRows = await readJsonlFile(chatPath(wsId)); + const copiedAssistant = chatRows.find((msg) => msg.id === "assistant-archived"); + expect(copiedAssistant?.metadata?.contextUsage).toMatchObject({ inputTokens: 95_000 }); + }); + + it("strips usage from the copied prefix so a failed archived edit is restart-safe", async () => { + // If archive removal fails after the chat.jsonl commit, the duplicated + // prefix persists across restarts; a fresh session must find no usage + // to seed on the copied rows. + await service.appendToHistory( + wsId, + createMuxMessage("msg-0", "user", "first prompt", { timestamp: Date.now() - 6_000 }) + ); + await service.appendToHistory( + wsId, + createMuxMessage("assistant-archived", "assistant", "archived reply", { + timestamp: Date.now() - 5_000, + model: "openai:gpt-4o", + contextUsage: { inputTokens: 95_000, outputTokens: 100, totalTokens: 95_100 }, + contextProviderMetadata: { anthropic: {} }, + }) + ); + await service.appendToHistory(wsId, boundaryMessage("boundary-1", 1)); + await service.appendToHistory(wsId, createMuxMessage("post-0", "user", "after")); + + const realRm = fs.rm; + const rmSpy = spyOn(fs, "rm").mockImplementation((...args: Parameters) => { + if (args[0] === archivePath(wsId)) { + return Promise.reject( + Object.assign(new Error("EACCES: permission denied"), { code: "EACCES" }) + ); + } + return realRm(...args); + }); + try { + const truncateResult = await service.truncateAfterMessage(wsId, "assistant-archived", { + keepTargetMessage: true, + }); + expect(truncateResult.success).toBe(false); + // Archive retained (removal failed), copied prefix committed with + // usage stripped and other metadata intact. + expect(await fileExists(archivePath(wsId))).toBe(true); + const chatRows = await readJsonlFile(chatPath(wsId)); + const copiedAssistant = chatRows.find((msg) => msg.id === "assistant-archived"); + expect(copiedAssistant).toBeDefined(); + expect(copiedAssistant?.metadata?.contextUsage).toBeUndefined(); + expect(copiedAssistant?.metadata?.contextProviderMetadata).toBeUndefined(); + expect(copiedAssistant?.metadata?.model).toBe("openai:gpt-4o"); + } finally { + rmSpy.mockRestore(); + } + }); + it("does not notify context rewrite when the edit target is missing", async () => { await appendNumberedMessages(service, wsId, 2); diff --git a/src/node/services/historyService.ts b/src/node/services/historyService.ts index 7e8ff649dc..67f2d67eef 100644 --- a/src/node/services/historyService.ts +++ b/src/node/services/historyService.ts @@ -75,6 +75,20 @@ function cutChangesActiveWindow(messages: MuxMessage[], cutEnd: number): boolean ); } +function stripContextUsage(msg: MuxMessage): MuxMessage { + if (msg.metadata?.contextUsage === undefined) { + return msg; + } + return { + ...msg, + metadata: { + ...msg.metadata, + contextUsage: undefined, + contextProviderMetadata: undefined, + }, + }; +} + function getCompactionMetadataToPreserve( workspaceId: string, existingMessage: MuxMessage, @@ -1921,14 +1935,44 @@ export class HistoryService { 0, keepTargetMessage ? messageIndex + 1 : messageIndex ); + const historyPath = this.getChatHistoryPath(workspaceId); + const copiedPrefixHasUsage = truncatedMessages.some( + (msg) => msg.metadata?.contextUsage !== undefined + ); + // Write the copied prefix with usage snapshots stripped while the + // archive still exists: if the removal below fails or the process dies + // first, the duplicated-prefix state persists across restarts, where a + // fresh session would seed the copied assistant's old contextUsage and + // understate the archive-plus-prefix payload. Once the removal + // commits, the hazard is gone and the restore write below brings the + // usage back, because it is exact for the restored prefix context and + // an edited near-limit prefix must stay monitored on the next send. await writeFileAtomic( - this.getChatHistoryPath(workspaceId), - this.serializeHistoryEntries(truncatedMessages, workspaceId) + historyPath, + this.serializeHistoryEntries( + copiedPrefixHasUsage ? truncatedMessages.map(stripContextUsage) : truncatedMessages, + workspaceId + ) ); // The archive removal below can still fail after this commit. notifyContextRewritten(); await fs.rm(this.getChatArchivePath(workspaceId), { force: true }); + if (copiedPrefixHasUsage) { + try { + await writeFileAtomic( + historyPath, + this.serializeHistoryEntries(truncatedMessages, workspaceId) + ); + } catch (error) { + // Safe-but-blind state: one unmonitored send, reseeded by the next + // provider response. Not worth failing the committed truncation. + log.warn("Failed to restore copied-prefix usage after archived edit", { + workspaceId, + error, + }); + } + } // chat.jsonl may contain sealed epochs again — allow the lazy check to re-run. this.sealedRotationChecked.delete(workspaceId); @@ -2118,19 +2162,8 @@ export class HistoryService { // (including the removed prefix). Persisting it would reseed stale // auto-compaction pressure, even across app restarts. Strip it; the // next provider response reports fresh usage. - const sanitizeRetained = (msg: MuxMessage): MuxMessage => { - if (!activeContextTruncated || msg.metadata?.contextUsage === undefined) { - return msg; - } - return { - ...msg, - metadata: { - ...msg.metadata, - contextUsage: undefined, - contextProviderMetadata: undefined, - }, - }; - }; + const sanitizeRetained = (msg: MuxMessage): MuxMessage => + activeContextTruncated ? stripContextUsage(msg) : msg; const remainingMessages = messages.slice(removeCount).map(sanitizeRetained); const deletedMessages = messages.slice(0, removeCount); const deletedSequences = sequencesOf(deletedMessages); From 2a5274d7f60593588d5b9ab26d6025fb4c1fa10d Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Sat, 8 Aug 2026 00:45:55 +0000 Subject: [PATCH 29/36] fix: re-enable usage seeding when a committed active-file edit fails after the rewrite The edit-truncation commit callback now reports whether the retained rows' persisted usage stays valid. Active-file suffix cuts create no duplicated prefix, so seeding re-enables at commit and a near-limit prefix stays monitored despite the Err; the archived branch keeps suppression until full success. --- .../agentSession.autoCompaction.test.ts | 14 ++++++++++ src/node/services/agentSession.ts | 15 +++++++---- src/node/services/historyService.ts | 27 ++++++++++++------- 3 files changed, 42 insertions(+), 14 deletions(-) diff --git a/src/node/services/agentSession.autoCompaction.test.ts b/src/node/services/agentSession.autoCompaction.test.ts index 07e79b7cbd..674d6cc230 100644 --- a/src/node/services/agentSession.autoCompaction.test.ts +++ b/src/node/services/agentSession.autoCompaction.test.ts @@ -1016,6 +1016,7 @@ describe("AgentSession on-send auto-compaction snapshot deferral", () => { createMuxMessage("assistant-1", "assistant", "first reply", { timestamp: Date.now() - 4_000, model: "openai:gpt-4o", + contextUsage: { inputTokens: 95_000, outputTokens: 100, totalTokens: 95_100 }, }), createMuxMessage("user-2", "user", "second prompt", { timestamp: Date.now() - 3_000 }), ]; @@ -1055,6 +1056,19 @@ describe("AgentSession on-send auto-compaction snapshot deferral", () => { seqSpy.mockRestore(); } + // The committed suffix cut created no duplicated prefix, so the retained + // assistant-1 usage stays valid and the next send must seed from it: a + // near-limit prefix stays monitored despite the Err. + const seenUsages = installUsageCapturingMonitor(session); + const result = await session.sendMessage("follow-up send", { + model: "openai:gpt-4o", + agentId: "exec", + }); + expect(result.success).toBe(true); + expect(seenUsages).toHaveLength(1); + const seeded = seenUsages[0] as AutoCompactionUsageState | undefined; + expect(seeded?.lastContextUsage).toBeDefined(); + session.dispose(); }); diff --git a/src/node/services/agentSession.ts b/src/node/services/agentSession.ts index 9a8547741c..c7dd5c19ff 100644 --- a/src/node/services/agentSession.ts +++ b/src/node/services/agentSession.ts @@ -2739,14 +2739,19 @@ export class AgentSession { // Invalidate usage the moment the rewrite commits, not on success: the // truncation can fail after chat.jsonl changed (archive removal or the // sequence-floor read), and stale usage from the removed suffix must - // not survive that. Seeding stays suppressed at the commit point - // because a partial failure can leave the archive alongside a - // duplicated prefix, where retained-row snapshots no longer measure - // the real payload. + // not survive that. Seeding re-enables at the commit point only when + // the retained rows' persisted usage stays valid (active-file suffix + // cut); the archived branch's partial failure can leave the archive + // alongside a duplicated prefix, where retained-row snapshots no + // longer measure the real payload, so it stays suppressed until full + // success. const truncateResult = await this.historyService.truncateAfterMessage( this.workspaceId, truncateTargetId, - { onContextRewritten: () => this.clearUsageState() } + { + onContextRewritten: (info) => + this.clearUsageState({ reenableHistorySeeding: info.retainedUsageValid }), + } ); if (truncateResult.success) { // Fully committed edits cut a suffix and leave both files diff --git a/src/node/services/historyService.ts b/src/node/services/historyService.ts index 67f2d67eef..7ed09d5208 100644 --- a/src/node/services/historyService.ts +++ b/src/node/services/historyService.ts @@ -1814,16 +1814,23 @@ export class HistoryService { * * options.onContextRewritten fires the moment the chat.jsonl rewrite * commits, even when a later step fails, so callers can invalidate cached - * usage for mutations an Err result leaves on disk. + * usage for mutations an Err result leaves on disk. retainedUsageValid is + * true when the committed rewrite leaves the retained rows' persisted + * usage snapshots valid (an active-file suffix cut); it is false for the + * archived branch, whose copied prefix duplicates archive rows until the + * archive removal commits. */ async truncateAfterMessage( workspaceId: string, messageId: string, - options?: { keepTargetMessage?: boolean; onContextRewritten?: () => void } + options?: { + keepTargetMessage?: boolean; + onContextRewritten?: (info: { retainedUsageValid: boolean }) => void; + } ): Promise> { - const notifyContextRewritten = () => { + const notifyContextRewritten = (info: { retainedUsageValid: boolean }) => { try { - options?.onContextRewritten?.(); + options?.onContextRewritten?.(info); } catch (error) { // A notification failure must not turn a committed rewrite into Err. log.error("truncateAfterMessage context-rewritten callback failed", { @@ -1866,8 +1873,9 @@ export class HistoryService { // Atomic write prevents corruption if app crashes mid-write await writeFileAtomic(historyPath, historyEntries); - // The archive read below can still fail after this commit. - notifyContextRewritten(); + // The archive read below can still fail after this commit, but the + // suffix cut leaves the retained rows' persisted usage valid. + notifyContextRewritten({ retainedUsageValid: true }); // Update sequence counter to continue from where we truncated. // Self-healing read path: skip malformed persisted historySequence values. @@ -1921,7 +1929,7 @@ export class HistoryService { workspaceId: string, messageId: string, keepTargetMessage: boolean, - notifyContextRewritten: () => void + notifyContextRewritten: (info: { retainedUsageValid: boolean }) => void ): Promise> { try { const archiveMessages = await this.readArchivedHistory(workspaceId); @@ -1955,8 +1963,9 @@ export class HistoryService { workspaceId ) ); - // The archive removal below can still fail after this commit. - notifyContextRewritten(); + // The archive removal below can still fail after this commit, leaving + // the duplicated-prefix hazard the stripped write above guards. + notifyContextRewritten({ retainedUsageValid: false }); await fs.rm(this.getChatArchivePath(workspaceId), { force: true }); if (copiedPrefixHasUsage) { try { From 1899e9fe98c83d0358ae1772c53e5314eb731aca Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Sat, 8 Aug 2026 07:42:39 +0000 Subject: [PATCH 30/36] fix: simplify stale usage invalidation after history rewrites --- src/node/services/agentSession.ts | 95 +-- src/node/services/historyService.test.ts | 893 ++------------------- src/node/services/historyService.ts | 371 ++------- src/node/services/workspaceService.test.ts | 332 +------- src/node/services/workspaceService.ts | 115 +-- 5 files changed, 217 insertions(+), 1589 deletions(-) diff --git a/src/node/services/agentSession.ts b/src/node/services/agentSession.ts index c7dd5c19ff..30dd754601 100644 --- a/src/node/services/agentSession.ts +++ b/src/node/services/agentSession.ts @@ -388,7 +388,6 @@ interface AgentSessionOptions { /** Destructive clear coordinator used by exec hard restart. */ clearHistoryForHardRestart?: (options: { monitorHistoryLockHeld: boolean; - onRowsDeleted?: (historySequences: number[]) => void; }) => Promise>; /** When true, skip terminating background processes on dispose/compaction (for bench/CI) */ keepBackgroundProcesses?: boolean; @@ -424,7 +423,6 @@ export class AgentSession { private readonly workspaceGoalService?: WorkspaceGoalService; private readonly clearHistoryForHardRestart?: (options: { monitorHistoryLockHeld: boolean; - onRowsDeleted?: (historySequences: number[]) => void; }) => Promise>; private readonly keepBackgroundProcesses: boolean; private readonly onPostCompactionStateChange?: () => void; @@ -474,7 +472,6 @@ export class AgentSession { /** Latest context-usage snapshot used for on-send compaction checks. */ private lastUsageState?: AutoCompactionUsageState; - private usageSeedingSuppressed = false; /** Prevent duplicate mid-stream compaction interrupts while we are already transitioning. */ private midStreamCompactionPending = false; @@ -2736,29 +2733,12 @@ export class AgentSession { // when the edit target is outside the active context window. const truncateTargetId = await this.getEditTruncateTargetId(editMessageId); - // Invalidate usage the moment the rewrite commits, not on success: the - // truncation can fail after chat.jsonl changed (archive removal or the - // sequence-floor read), and stale usage from the removed suffix must - // not survive that. Seeding re-enables at the commit point only when - // the retained rows' persisted usage stays valid (active-file suffix - // cut); the archived branch's partial failure can leave the archive - // alongside a duplicated prefix, where retained-row snapshots no - // longer measure the real payload, so it stays suppressed until full - // success. + this.clearUsageState(); const truncateResult = await this.historyService.truncateAfterMessage( this.workspaceId, - truncateTargetId, - { - onContextRewritten: (info) => - this.clearUsageState({ reenableHistorySeeding: info.retainedUsageValid }), - } + truncateTargetId ); - if (truncateResult.success) { - // Fully committed edits cut a suffix and leave both files - // consistent; the retained prefix's persisted usage is valid, so - // re-enable history seeding even if a prior rewrite suppressed it. - this.clearUsageState({ reenableHistorySeeding: true }); - } else { + if (!truncateResult.success) { const isMissingEditTarget = truncateResult.error.includes("Message with ID") && truncateResult.error.includes("not found in history"); @@ -3410,8 +3390,6 @@ export class AgentSession { return; } - this.usageSeedingSuppressed = false; - const totalTokens = params.usage.totalTokens ?? this.lastUsageState?.totalTokens; if (params.live) { this.lastUsageState = { @@ -3441,23 +3419,9 @@ export class AgentSession { }; } - /** - * Invalidate cached context usage after the active provider context is - * rewritten (boundary append, truncation, history replacement): stale usage - * would make the next send auto-compact the already-rewritten context. - * History seeding is also suppressed until the provider reports fresh usage, - * because boundary-less rewrites (partial /clear) retain rows whose persisted - * contextUsage still counts removed tokens; re-seeding those would restore - * the same stale value. - * - * Pass reenableHistorySeeding for suffix-only rewrites (message edits): the - * retained prefix becomes the active context and its persisted contextUsage - * is valid, so seeding is re-enabled even when an earlier rewrite (e.g. a - * context reset whose boundary the edit just truncated away) suppressed it. - */ - clearUsageState(options?: { reenableHistorySeeding?: boolean }): void { + /** Prevent cached usage from auto-compacting a rewritten context. */ + clearUsageState(): void { this.lastUsageState = undefined; - this.usageSeedingSuppressed = options?.reenableHistorySeeding !== true; } /** @@ -3544,7 +3508,7 @@ export class AgentSession { * `lastUsageState` is still undefined. */ private async seedUsageStateFromHistory(): Promise { - if (this.lastUsageState !== undefined || this.usageSeedingSuppressed) { + if (this.lastUsageState !== undefined) { return; } @@ -4625,35 +4589,13 @@ export class AgentSession { }); } - // Same ghost-row invariant as WorkspaceService.truncateHistory: emit - // deletions for every committed clear step even when a later step fails. - const committedDeletedSequences: number[] = []; - const onRowsDeleted = (historySequences: number[]) => { - committedDeletedSequences.push(...historySequences); - }; - const emitCommittedDeletions = () => { - if (committedDeletedSequences.length === 0) { - return; - } - const deleteMessage: DeleteMessage = { - type: "delete", - historySequences: [...committedDeletedSequences], - }; - committedDeletedSequences.length = 0; - this.emitChatEvent(deleteMessage); - }; + this.clearUsageState(); const clearResult = this.clearHistoryForHardRestart ? await this.clearHistoryForHardRestart({ monitorHistoryLockHeld: context.monitorHistoryLockHeld === true, - onRowsDeleted, }) - : await this.historyService.clearHistory( - this.workspaceId, - () => this.clearUsageState(), - onRowsDeleted - ); + : await this.historyService.clearHistory(this.workspaceId); if (!clearResult.success) { - emitCommittedDeletions(); log.warn("Failed to clear history for exec subagent hard restart", { workspaceId: this.workspaceId, error: clearResult.error, @@ -4661,8 +4603,6 @@ export class AgentSession { return false; } - this.clearUsageState(); - // This clear bypasses WorkspaceService.replaceHistory, so announce it on the chat funnel the // timeline already consumes: a log that cannot explain missing history defeats its purpose. this.emitChatEvent({ @@ -4671,7 +4611,14 @@ export class AgentSession { reason: "exec sub-agent hard restart", }); - emitCommittedDeletions(); + const deletedSequences = clearResult.data; + if (deletedSequences.length > 0) { + const deleteMessage: DeleteMessage = { + type: "delete", + historySequences: deletedSequences, + }; + this.emitChatEvent(deleteMessage); + } const cloneForAppend = (msg: MuxMessage): MuxMessage => { const metadataCopy = msg.metadata ? { ...msg.metadata } : undefined; @@ -5256,10 +5203,7 @@ export class AgentSession { // Compaction collapses history to a boundary summary, so prior context-usage snapshots // are stale. Clear them to prevent immediate re-trigger loops on the follow-up turn. - // Seeding stays enabled: the boundary read starts at the just-written summary row, - // whose contextUsage is a fresh post-compaction estimate the follow-up needs for - // checkBeforeSend (a large system prompt or summary can stay near the threshold). - this.clearUsageState({ reenableHistorySeeding: true }); + this.clearUsageState(); if (completedCompactionRequest?.source === "auto-compaction") { this.emitChatEvent({ @@ -6052,11 +5996,6 @@ export class AgentSession { if (!rollbackResult.success) { throw new Error(`Failed to rollback heartbeat reset boundary: ${rollbackResult.error}`); } - // Rollback restored the pre-reset provider context, so the invalidation - // from appendHeartbeatContextResetBoundary no longer applies. Re-enable - // seeding so the queued turn recovers the restored history's usage and - // on-send compaction still fires for a near-limit context. - this.usageSeedingSuppressed = false; this.onPostCompactionStateChange?.(); } else { await this.clearPendingFollowUpFromSummary(lastMessage); diff --git a/src/node/services/historyService.test.ts b/src/node/services/historyService.test.ts index fdfcf5c4c2..c1713ac309 100644 --- a/src/node/services/historyService.test.ts +++ b/src/node/services/historyService.test.ts @@ -1,12 +1,12 @@ -import { describe, it, expect, beforeEach, afterEach, spyOn } from "bun:test"; +import { describe, it, expect, beforeEach, afterEach } from "bun:test"; import { CONTEXT_BOUNDARY_KINDS } from "@/common/constants/contextBoundary"; import { HistoryService } from "./historyService"; -import { Config } from "@/node/config"; +import type { Config } from "@/node/config"; +import { createTestHistoryService } from "./testHistoryService"; import { createMuxMessage, type MuxMessage } from "@/common/types/message"; import assert from "node:assert"; import * as fs from "fs/promises"; import * as path from "path"; -import * as os from "os"; /** Collect all messages via iterateFullHistory (replaces removed getFullHistory). */ async function collectFullHistory(service: HistoryService, workspaceId: string) { @@ -48,25 +48,17 @@ async function appendNumberedMessages( describe("HistoryService", () => { let service: HistoryService; let config: Config; - let tempDir: string; + let cleanup: () => Promise; beforeEach(async () => { - // Create a temporary directory for test files - tempDir = path.join(os.tmpdir(), `mux-test-${Date.now()}-${Math.random()}`); - await fs.mkdir(tempDir, { recursive: true }); - - // Create a Config with the temp directory - config = new Config(tempDir); - service = new HistoryService(config); + const testService = await createTestHistoryService(); + service = testService.historyService; + config = testService.config; + cleanup = testService.cleanup; }); afterEach(async () => { - // Clean up temp directory - try { - await fs.rm(tempDir, { recursive: true, force: true }); - } catch { - // Ignore cleanup errors - } + await cleanup(); }); describe("getHistory", () => { @@ -1763,15 +1755,6 @@ describe("HistoryService", () => { return path.join(config.getSessionDir(workspaceId), "chat-archive.jsonl"); } - async function fileExists(filePath: string): Promise { - try { - await fs.access(filePath); - return true; - } catch { - return false; - } - } - it("rotates the sealed prefix into the archive when a boundary is appended", async () => { await appendNumberedMessages(service, wsId, 3); // seq 0..2 await service.appendToHistory(wsId, boundaryMessage("boundary-1", 1)); // seq 3 @@ -1945,163 +1928,6 @@ describe("HistoryService", () => { expect(msg.metadata?.historySequence).toBe(2); }); - it("notifies context rewrite when the archived-edit cut fails after committing chat.jsonl", async () => { - // Pre-boundary edit: chat.jsonl is rewritten first, then the archive is - // removed. An archive-removal failure returns Err with the provider - // context already changed, so the caller must still hear about it. - await appendNumberedMessages(service, wsId, 3); - await service.appendToHistory(wsId, boundaryMessage("boundary-1", 1)); - await service.appendToHistory(wsId, createMuxMessage("post-0", "user", "after")); - - const realRm = fs.rm; - const rmSpy = spyOn(fs, "rm").mockImplementation((...args: Parameters) => { - if (args[0] === archivePath(wsId)) { - return Promise.reject( - Object.assign(new Error("EACCES: permission denied"), { code: "EACCES" }) - ); - } - return realRm(...args); - }); - try { - let notified = 0; - const truncateResult = await service.truncateAfterMessage(wsId, "msg-1", { - keepTargetMessage: true, - onContextRewritten: () => { - notified += 1; - }, - }); - expect(truncateResult.success).toBe(false); - expect(notified).toBe(1); - // The committed rewrite stands: chat.jsonl holds the collapsed prefix. - const chatRows = await readJsonlFile(chatPath(wsId)); - expect(chatRows.map((msg) => msg.id)).toEqual(["msg-0", "msg-1"]); - } finally { - rmSpy.mockRestore(); - } - }); - - it("restores copied-prefix usage once the archived edit fully commits", async () => { - // Success path: the archive is gone, so the duplicated-prefix hazard - // no longer exists and the restored prefix's usage (exact for that - // context) must survive so the next send stays monitored. - await service.appendToHistory(wsId, createMuxMessage("msg-0", "user", "first prompt")); - await service.appendToHistory( - wsId, - createMuxMessage("assistant-archived", "assistant", "archived reply", { - model: "openai:gpt-4o", - contextUsage: { inputTokens: 95_000, outputTokens: 100, totalTokens: 95_100 }, - }) - ); - await service.appendToHistory(wsId, boundaryMessage("boundary-1", 1)); - await service.appendToHistory(wsId, createMuxMessage("post-0", "user", "after")); - - const truncateResult = await service.truncateAfterMessage(wsId, "assistant-archived", { - keepTargetMessage: true, - }); - expect(truncateResult.success).toBe(true); - expect(await fileExists(archivePath(wsId))).toBe(false); - const chatRows = await readJsonlFile(chatPath(wsId)); - const copiedAssistant = chatRows.find((msg) => msg.id === "assistant-archived"); - expect(copiedAssistant?.metadata?.contextUsage).toMatchObject({ inputTokens: 95_000 }); - }); - - it("strips usage from the copied prefix so a failed archived edit is restart-safe", async () => { - // If archive removal fails after the chat.jsonl commit, the duplicated - // prefix persists across restarts; a fresh session must find no usage - // to seed on the copied rows. - await service.appendToHistory( - wsId, - createMuxMessage("msg-0", "user", "first prompt", { timestamp: Date.now() - 6_000 }) - ); - await service.appendToHistory( - wsId, - createMuxMessage("assistant-archived", "assistant", "archived reply", { - timestamp: Date.now() - 5_000, - model: "openai:gpt-4o", - contextUsage: { inputTokens: 95_000, outputTokens: 100, totalTokens: 95_100 }, - contextProviderMetadata: { anthropic: {} }, - }) - ); - await service.appendToHistory(wsId, boundaryMessage("boundary-1", 1)); - await service.appendToHistory(wsId, createMuxMessage("post-0", "user", "after")); - - const realRm = fs.rm; - const rmSpy = spyOn(fs, "rm").mockImplementation((...args: Parameters) => { - if (args[0] === archivePath(wsId)) { - return Promise.reject( - Object.assign(new Error("EACCES: permission denied"), { code: "EACCES" }) - ); - } - return realRm(...args); - }); - try { - const truncateResult = await service.truncateAfterMessage(wsId, "assistant-archived", { - keepTargetMessage: true, - }); - expect(truncateResult.success).toBe(false); - // Archive retained (removal failed), copied prefix committed with - // usage stripped and other metadata intact. - expect(await fileExists(archivePath(wsId))).toBe(true); - const chatRows = await readJsonlFile(chatPath(wsId)); - const copiedAssistant = chatRows.find((msg) => msg.id === "assistant-archived"); - expect(copiedAssistant).toBeDefined(); - expect(copiedAssistant?.metadata?.contextUsage).toBeUndefined(); - expect(copiedAssistant?.metadata?.contextProviderMetadata).toBeUndefined(); - expect(copiedAssistant?.metadata?.model).toBe("openai:gpt-4o"); - } finally { - rmSpy.mockRestore(); - } - }); - - it("does not notify context rewrite when the edit target is missing", async () => { - await appendNumberedMessages(service, wsId, 2); - - let notified = 0; - const truncateResult = await service.truncateAfterMessage(wsId, "nonexistent", { - onContextRewritten: () => { - notified += 1; - }, - }); - expect(truncateResult.success).toBe(false); - expect(notified).toBe(0); - }); - - it("notifies context rewrite when the active-edit cut fails after committing chat.jsonl", async () => { - // The sequence-floor read of the archive tail runs after the atomic - // rewrite; its failure returns Err with the cut already committed. - await appendNumberedMessages(service, wsId, 3); - - const internals = service as unknown as { - getArchiveTailMaxSequence: (workspaceId: string) => Promise; - }; - const realSeq = internals.getArchiveTailMaxSequence.bind(service); - // Earlier reads also consult the sequence floor, so reject only the - // call made after the rewrite committed (msg-1 gone from chat.jsonl). - const seqSpy = spyOn(internals, "getArchiveTailMaxSequence").mockImplementation( - async (workspaceId: string) => { - const chat = await fs.readFile(chatPath(wsId), "utf-8").catch(() => ""); - if (!chat.includes('"msg-1"')) { - throw new Error("injected sequence-floor read failure"); - } - return realSeq(workspaceId); - } - ); - try { - let notified = 0; - const truncateResult = await service.truncateAfterMessage(wsId, "msg-1", { - onContextRewritten: () => { - notified += 1; - }, - }); - expect(truncateResult.success).toBe(false); - expect(notified).toBe(1); - const chatRows = await readJsonlFile(chatPath(wsId)); - expect(chatRows.map((msg) => msg.id)).toEqual(["msg-0"]); - } finally { - seqSpy.mockRestore(); - } - }); - it("never reuses archived sequences after truncating the whole active epoch", async () => { await appendNumberedMessages(service, wsId, 3); // msg-0..2, seq 0..2 → archived await service.appendToHistory(wsId, boundaryMessage("boundary-1", 1)); // seq 3 @@ -2185,714 +2011,85 @@ describe("HistoryService", () => { expect(msg.metadata?.historySequence).toBe(3); }); - it("strips stale contextUsage from rows retained by partial truncation", async () => { - // Filler prefix makes the newest assistant row survive a 50% cut. - await appendNumberedMessages(service, wsId, 8); - await service.appendToHistory( - wsId, - createMuxMessage("assistant-usage", "assistant", "reply", { - contextUsage: { inputTokens: 95_000, outputTokens: 100, totalTokens: 95_100 }, - contextProviderMetadata: { anthropic: {} }, - model: "openai:gpt-4o", - }) - ); - - let notified = 0; - const reportedDeletions: number[] = []; - const truncateResult = await service.truncateHistory( - wsId, - 0.5, - () => { - notified += 1; - }, - (historySequences) => reportedDeletions.push(...historySequences) - ); - expect(truncateResult.success).toBe(true); - expect(notified).toBe(1); - if (truncateResult.success) { - expect(truncateResult.data.deletedSequences.length).toBeGreaterThan(0); - expect(truncateResult.data.activeContextTruncated).toBe(true); - // Commit-time reports cover exactly the sequences the result returns. - expect(reportedDeletions).toEqual(truncateResult.data.deletedSequences); - } - - const remaining = await service.getHistoryFromLatestBoundary(wsId); - expect(remaining.success).toBe(true); - if (remaining.success) { - const retainedAssistant = remaining.data.find((msg) => msg.id === "assistant-usage"); - expect(retainedAssistant).toBeDefined(); - expect(retainedAssistant?.metadata?.contextUsage).toBeUndefined(); - expect(retainedAssistant?.metadata?.contextProviderMetadata).toBeUndefined(); - // Only usage snapshots are sanitized; the rest of the row survives. - expect(retainedAssistant?.metadata?.model).toBe("openai:gpt-4o"); - } - }); - - it("preserves contextUsage when the cut stays before the latest boundary", async () => { - // Heavy sealed prefix, then a boundary, then a light active epoch whose - // usage snapshot must survive a cut confined to pre-boundary rows. - await appendNumberedMessages(service, wsId, 12); + it("keeps the archive intact on a no-op percentage truncation", async () => { + await appendNumberedMessages(service, wsId, 3); await service.appendToHistory(wsId, boundaryMessage("boundary-1", 1)); - await service.appendToHistory( - wsId, - createMuxMessage("assistant-active", "assistant", "active reply", { - contextUsage: { inputTokens: 95_000, outputTokens: 100, totalTokens: 95_100 }, - model: "openai:gpt-4o", - }) - ); - let notified = 0; - const truncateResult = await service.truncateHistory(wsId, 0.25, () => { - notified += 1; - }); - expect(truncateResult.success).toBe(true); - expect(notified).toBe(0); - if (truncateResult.success) { - expect(truncateResult.data.deletedSequences.length).toBeGreaterThan(0); - expect(truncateResult.data.activeContextTruncated).toBe(false); - } - - const remaining = await service.getHistoryFromLatestBoundary(wsId); - expect(remaining.success).toBe(true); - if (remaining.success) { - const activeAssistant = remaining.data.find((msg) => msg.id === "assistant-active"); - expect(activeAssistant?.metadata?.contextUsage).toMatchObject({ inputTokens: 95_000 }); - } - }); - - it("preserves contextUsage when the cut ends at a reset boundary", async () => { - // Reset boundaries are provider-invisible: the provider window starts - // after them, so deleting the marker (and nothing later) leaves the - // active context and its usage snapshots unchanged. - await service.appendToHistory( - wsId, - createMuxMessage("reset-boundary", "assistant", "", { - contextBoundaryKind: CONTEXT_BOUNDARY_KINDS.RESET, - }) - ); - await service.appendToHistory(wsId, createMuxMessage("user-active", "user", "active prompt")); - await service.appendToHistory( - wsId, - createMuxMessage("assistant-active", "assistant", "active reply", { - contextUsage: { inputTokens: 95_000, outputTokens: 100, totalTokens: 95_100 }, - model: "openai:gpt-4o", - }) - ); - - const truncateResult = await service.truncateHistory(wsId, 0.1); - expect(truncateResult.success).toBe(true); - if (truncateResult.success) { - expect(truncateResult.data.deletedSequences.length).toBe(1); - expect(truncateResult.data.activeContextTruncated).toBe(false); - } - - const remaining = await service.getHistoryFromLatestBoundary(wsId); - expect(remaining.success).toBe(true); - if (remaining.success) { - expect(remaining.data.find((msg) => msg.id === "reset-boundary")).toBeUndefined(); - const activeAssistant = remaining.data.find((msg) => msg.id === "assistant-active"); - expect(activeAssistant?.metadata?.contextUsage).toMatchObject({ inputTokens: 95_000 }); - } - }); - - it("preserves contextUsage when the cut removes only provider-ineligible active rows", async () => { - // The reasoning-only assistant turn after the reset is never replayed to - // the provider, so removing it (plus the sealed prefix and the marker) - // leaves the provider request and its usage snapshot unchanged. - await appendNumberedMessages(service, wsId, 12); - await service.appendToHistory( - wsId, - createMuxMessage("reset-boundary", "assistant", "", { - contextBoundaryKind: CONTEXT_BOUNDARY_KINDS.RESET, - }) - ); - await service.appendToHistory(wsId, { - ...createMuxMessage("assistant-reasoning-only", "assistant", ""), - parts: [{ type: "reasoning", text: `internal deliberation ${"x".repeat(2_000)}` }], - }); - await service.appendToHistory(wsId, createMuxMessage("user-active", "user", "prompt")); - await service.appendToHistory( - wsId, - createMuxMessage("assistant-active", "assistant", "active reply", { - contextUsage: { inputTokens: 95_000, outputTokens: 100, totalTokens: 95_100 }, - model: "openai:gpt-4o", - }) - ); + const chatBefore = await fs.readFile(chatPath(wsId), "utf-8"); + const archiveBefore = await fs.readFile(archivePath(wsId), "utf-8"); - // Half the token mass sits in the sealed prefix plus the large - // reasoning row, so a 50% cut ends inside the reasoning row. - const truncateResult = await service.truncateHistory(wsId, 0.5); + const truncateResult = await service.truncateHistory(wsId, 0); expect(truncateResult.success).toBe(true); if (truncateResult.success) { - expect(truncateResult.data.activeContextTruncated).toBe(false); + expect(truncateResult.data).toEqual([]); } - const remaining = await service.getHistoryFromLatestBoundary(wsId); - expect(remaining.success).toBe(true); - if (remaining.success) { - expect(remaining.data.find((msg) => msg.id === "assistant-reasoning-only")).toBeUndefined(); - const activeAssistant = remaining.data.find((msg) => msg.id === "assistant-active"); - expect(activeAssistant?.metadata?.contextUsage).toMatchObject({ inputTokens: 95_000 }); - } + // No-op truncation must not collapse the archive back into chat.jsonl. + expect(await fs.readFile(chatPath(wsId), "utf-8")).toBe(chatBefore); + expect(await fs.readFile(archivePath(wsId), "utf-8")).toBe(archiveBefore); }); - it("preserves contextUsage when the cut removes only workflow display rows", async () => { - // Workflow trigger/run-card rows are filtered out before request - // assembly, so removing them leaves the provider context unchanged. - await appendNumberedMessages(service, wsId, 12); - await service.appendToHistory( - wsId, - createMuxMessage("reset-boundary", "assistant", "", { - contextBoundaryKind: CONTEXT_BOUNDARY_KINDS.RESET, - }) - ); - await service.appendToHistory( - wsId, - createMuxMessage( - "workflow-display", - "user", - `workflow trigger display ${"x".repeat(2_000)}`, - { muxMetadata: { type: "workflow-trigger-display", rawCommand: "/wf", runId: "run-1" } } - ) - ); - await service.appendToHistory(wsId, createMuxMessage("user-active", "user", "prompt")); + it("does not reseed usage from before a partial prefix truncation", async () => { + await appendNumberedMessages(service, wsId, 8); await service.appendToHistory( wsId, - createMuxMessage("assistant-active", "assistant", "active reply", { + createMuxMessage("assistant-usage", "assistant", "reply", { contextUsage: { inputTokens: 95_000, outputTokens: 100, totalTokens: 95_100 }, + contextProviderMetadata: { openai: {} }, model: "openai:gpt-4o", }) ); - - const truncateResult = await service.truncateHistory(wsId, 0.5); - expect(truncateResult.success).toBe(true); - if (truncateResult.success) { - expect(truncateResult.data.activeContextTruncated).toBe(false); - } - - const remaining = await service.getHistoryFromLatestBoundary(wsId); - expect(remaining.success).toBe(true); - if (remaining.success) { - expect(remaining.data.find((msg) => msg.id === "workflow-display")).toBeUndefined(); - const activeAssistant = remaining.data.find((msg) => msg.id === "assistant-active"); - expect(activeAssistant?.metadata?.contextUsage).toMatchObject({ inputTokens: 95_000 }); - } - }); - - it("strips contextUsage when the cut removes an eligible post-reset row", async () => { - // Mirror of the ineligible case: a replayable user turn in the same - // position must still invalidate usage when removed. - await appendNumberedMessages(service, wsId, 12); await service.appendToHistory( wsId, - createMuxMessage("reset-boundary", "assistant", "", { - contextBoundaryKind: CONTEXT_BOUNDARY_KINDS.RESET, - }) - ); - await service.appendToHistory( - wsId, - createMuxMessage("user-removed", "user", `large replayable prompt ${"x".repeat(2_000)}`) - ); - await service.appendToHistory(wsId, createMuxMessage("user-active", "user", "prompt")); - await service.appendToHistory( - wsId, - createMuxMessage("assistant-active", "assistant", "active reply", { - contextUsage: { inputTokens: 95_000, outputTokens: 100, totalTokens: 95_100 }, + createMuxMessage("assistant-provider-metadata", "assistant", "reply", { + contextProviderMetadata: { openai: {} }, model: "openai:gpt-4o", }) ); const truncateResult = await service.truncateHistory(wsId, 0.5); expect(truncateResult.success).toBe(true); - if (truncateResult.success) { - expect(truncateResult.data.activeContextTruncated).toBe(true); - } - - const remaining = await service.getHistoryFromLatestBoundary(wsId); - expect(remaining.success).toBe(true); - if (remaining.success) { - expect(remaining.data.find((msg) => msg.id === "user-removed")).toBeUndefined(); - const activeAssistant = remaining.data.find((msg) => msg.id === "assistant-active"); - expect(activeAssistant?.metadata?.contextUsage).toBeUndefined(); - } - }); - - it("strips contextUsage when the cut removes a compaction boundary", async () => { - // Mirror of the reset case: compaction boundaries carry the summary the - // provider sees, so deleting the boundary row changes the active context. - await service.appendToHistory(wsId, boundaryMessage("boundary-1", 1)); - await service.appendToHistory(wsId, createMuxMessage("user-active", "user", "active prompt")); - await service.appendToHistory( - wsId, - createMuxMessage("assistant-active", "assistant", "active reply", { - contextUsage: { inputTokens: 95_000, outputTokens: 100, totalTokens: 95_100 }, - model: "openai:gpt-4o", - }) - ); - - const truncateResult = await service.truncateHistory(wsId, 0.1); - expect(truncateResult.success).toBe(true); - if (truncateResult.success) { - expect(truncateResult.data.deletedSequences.length).toBe(1); - expect(truncateResult.data.activeContextTruncated).toBe(true); - } - const remaining = await service.getHistoryFromLatestBoundary(wsId); + const restarted = new HistoryService(config); + const remaining = await restarted.getHistoryFromLatestBoundary(wsId); expect(remaining.success).toBe(true); if (remaining.success) { - const activeAssistant = remaining.data.find((msg) => msg.id === "assistant-active"); - expect(activeAssistant).toBeDefined(); - expect(activeAssistant?.metadata?.contextUsage).toBeUndefined(); - } - }); - - it("rewrites the archive in place when the cut stays inside it", async () => { - await appendNumberedMessages(service, wsId, 12); - await service.appendToHistory(wsId, boundaryMessage("boundary-1", 1)); - await service.appendToHistory( - wsId, - createMuxMessage("assistant-active", "assistant", "active reply") - ); - - const chatBefore = await fs.readFile(chatPath(wsId), "utf-8"); - const archiveRowsBefore = (await readJsonlFile(archivePath(wsId))).length; - - const truncateResult = await service.truncateHistory(wsId, 0.25); - expect(truncateResult.success).toBe(true); - if (truncateResult.success) { - expect(truncateResult.data.deletedSequences.length).toBeGreaterThan(0); - - // The archive shrinks by exactly the cut rows; chat.jsonl (the active - // window) is untouched, so no crash between steps can lose rows. - const archiveRows = await readJsonlFile(archivePath(wsId)); - expect(archiveRows.length).toBe( - archiveRowsBefore - truncateResult.data.deletedSequences.length + const retainedAssistant = remaining.data.find( + (message) => message.id === "assistant-usage" ); - expect(await fs.readFile(chatPath(wsId), "utf-8")).toBe(chatBefore); - } - }); - - it("leaves history untouched when deleting a fully-cut archive fails", async () => { - // Small archive + heavy chat prefix so the cut consumes the whole - // archive and reaches into chat.jsonl. - await appendNumberedMessages(service, wsId, 2); - await service.appendToHistory(wsId, boundaryMessage("boundary-1", 1)); - await service.appendToHistory( - wsId, - createMuxMessage("user-heavy", "user", `heavy prompt ${"x".repeat(3_000)}`) - ); - await service.appendToHistory( - wsId, - createMuxMessage("assistant-active", "assistant", "active reply") - ); - - const chatBefore = await fs.readFile(chatPath(wsId), "utf-8"); - const archiveBefore = await fs.readFile(archivePath(wsId), "utf-8"); - - const realRm = fs.rm; - const rmSpy = spyOn(fs, "rm").mockImplementation((...args: Parameters) => { - if (args[0] === archivePath(wsId)) { - return Promise.reject( - Object.assign(new Error("EACCES: permission denied"), { code: "EACCES" }) - ); - } - return realRm(...args); - }); - try { - const truncateResult = await service.truncateHistory(wsId, 0.5); - expect(truncateResult.success).toBe(false); - // A failed truncation must not have mutated either history file, so - // the caller's success-only usage invalidation stays correct. - expect(await fs.readFile(chatPath(wsId), "utf-8")).toBe(chatBefore); - expect(await fs.readFile(archivePath(wsId), "utf-8")).toBe(archiveBefore); - } finally { - rmSpy.mockRestore(); - } - }); - - it("only deletes fully-cut archive rows when the chat commit fails", async () => { - await appendNumberedMessages(service, wsId, 2); - await service.appendToHistory(wsId, boundaryMessage("boundary-1", 1)); - await service.appendToHistory( - wsId, - createMuxMessage("user-heavy", "user", `heavy prompt ${"x".repeat(3_000)}`) - ); - await service.appendToHistory( - wsId, - createMuxMessage("assistant-active", "assistant", "active reply") - ); - - // Abort between the archive delete and the final chat.jsonl commit - // (the cut serializes the retained rows). Every archive row was a cut - // target, so the failure must leave every chat.jsonl row (the active - // window and all retained rows) in place. - const internals = service as unknown as { - serializeHistoryEntries: (messages: MuxMessage[], workspaceId: string) => string; - }; - const realSerialize = internals.serializeHistoryEntries.bind(service); - const serializeSpy = spyOn(internals, "serializeHistoryEntries").mockImplementation( - (messages: MuxMessage[], workspaceId: string) => { - if (messages.some((msg) => msg.id === "assistant-active")) { - throw new Error("injected serialize failure"); - } - return realSerialize(messages, workspaceId); - } - ); - try { - const truncateResult = await service.truncateHistory(wsId, 0.5); - expect(truncateResult.success).toBe(false); - const chatRows = await readJsonlFile(chatPath(wsId)); - const chatIds = new Set(chatRows.map((msg) => msg.id)); - expect(chatIds.has("boundary-1")).toBe(true); - expect(chatIds.has("user-heavy")).toBe(true); - expect(chatIds.has("assistant-active")).toBe(true); - expect(await fileExists(archivePath(wsId))).toBe(false); - } finally { - serializeSpy.mockRestore(); - } - }); - - it("restores chat usage when an archive-confined cut fails before committing", async () => { - // Malformed boundary-less state: rotation created the archive, then the - // boundary row was deleted, so the whole file pair is one active window. - await appendNumberedMessages(service, wsId, 12); - await service.appendToHistory(wsId, boundaryMessage("boundary-1", 1)); - await service.appendToHistory( - wsId, - createMuxMessage("assistant-active", "assistant", "active reply", { - contextUsage: { inputTokens: 95_000, outputTokens: 100, totalTokens: 95_100 }, - model: "openai:gpt-4o", - }) - ); - const deleteResult = await service.deleteMessage(wsId, "boundary-1"); - expect(deleteResult.success).toBe(true); - - const chatBefore = await fs.readFile(chatPath(wsId), "utf-8"); - const archiveBefore = await fs.readFile(archivePath(wsId), "utf-8"); - - // Fail the archive rewrite (the only serialize whose rows are archive - // rows). No window-changing step committed, so the pre-cut sanitization - // must be rolled back and the retained usage stays seedable. - const internals = service as unknown as { - serializeHistoryEntries: (messages: MuxMessage[], workspaceId: string) => string; - }; - const realSerialize = internals.serializeHistoryEntries.bind(service); - const serializeSpy = spyOn(internals, "serializeHistoryEntries").mockImplementation( - (messages: MuxMessage[], workspaceId: string) => { - if (messages.some((msg) => msg.id === "msg-5")) { - throw new Error("injected archive serialize failure"); - } - return realSerialize(messages, workspaceId); - } - ); - try { - const truncateResult = await service.truncateHistory(wsId, 0.25); - expect(truncateResult.success).toBe(false); - expect(await fs.readFile(archivePath(wsId), "utf-8")).toBe(archiveBefore); - expect(await fs.readFile(chatPath(wsId), "utf-8")).toBe(chatBefore); - } finally { - serializeSpy.mockRestore(); - } - }); - - it("restores chat usage when a whole-archive cut fails before committing", async () => { - // Same malformed boundary-less state, but the cut consumes the archive. - await appendNumberedMessages(service, wsId, 2); - await service.appendToHistory(wsId, boundaryMessage("boundary-1", 1)); - await service.appendToHistory( - wsId, - createMuxMessage("user-heavy", "user", `heavy prompt ${"x".repeat(3_000)}`) - ); - await service.appendToHistory( - wsId, - createMuxMessage("assistant-active", "assistant", "active reply", { - contextUsage: { inputTokens: 95_000, outputTokens: 100, totalTokens: 95_100 }, - model: "openai:gpt-4o", - }) - ); - const deleteResult = await service.deleteMessage(wsId, "boundary-1"); - expect(deleteResult.success).toBe(true); - - const chatBefore = await fs.readFile(chatPath(wsId), "utf-8"); - const archiveBefore = await fs.readFile(archivePath(wsId), "utf-8"); - - const realRm = fs.rm; - const rmSpy = spyOn(fs, "rm").mockImplementation((...args: Parameters) => { - if (args[0] === archivePath(wsId)) { - return Promise.reject( - Object.assign(new Error("EACCES: permission denied"), { code: "EACCES" }) - ); - } - return realRm(...args); - }); - try { - const truncateResult = await service.truncateHistory(wsId, 0.5); - expect(truncateResult.success).toBe(false); - expect(await fs.readFile(archivePath(wsId), "utf-8")).toBe(archiveBefore); - expect(await fs.readFile(chatPath(wsId), "utf-8")).toBe(chatBefore); - } finally { - rmSpy.mockRestore(); - } - }); - - it("keeps usage stripped when the chat cut fails after the archive delete", async () => { - // The window already changed (archive rows deleted), so the failure - // must NOT restore usage: a restart pairing the shrunken window with - // pre-cut usage would reseed a spurious auto-compaction. - await appendNumberedMessages(service, wsId, 2); - await service.appendToHistory(wsId, boundaryMessage("boundary-1", 1)); - await service.appendToHistory( - wsId, - createMuxMessage("user-heavy", "user", `heavy prompt ${"x".repeat(3_000)}`) - ); - await service.appendToHistory( - wsId, - createMuxMessage("assistant-active", "assistant", "active reply", { - contextUsage: { inputTokens: 95_000, outputTokens: 100, totalTokens: 95_100 }, - model: "openai:gpt-4o", - }) - ); - const deleteResult = await service.deleteMessage(wsId, "boundary-1"); - expect(deleteResult.success).toBe(true); - - // Serialize call 1 is the pre-cut sanitization; call 2 is the chat cut. - const internals = service as unknown as { - serializeHistoryEntries: (messages: MuxMessage[], workspaceId: string) => string; - }; - const realSerialize = internals.serializeHistoryEntries.bind(service); - let serializeCalls = 0; - const serializeSpy = spyOn(internals, "serializeHistoryEntries").mockImplementation( - (messages: MuxMessage[], workspaceId: string) => { - serializeCalls += 1; - if (serializeCalls === 2) { - throw new Error("injected chat cut serialize failure"); - } - return realSerialize(messages, workspaceId); - } - ); - try { - // The archive delete removed window content, so the caller must be - // notified at commit time despite the Err result, and the committed - // archive deletions must be reported so the renderer can drop them. - let notified = 0; - const reportedDeletions: number[] = []; - const truncateResult = await service.truncateHistory( - wsId, - 0.5, - () => { - notified += 1; - }, - (historySequences) => reportedDeletions.push(...historySequences) + expect(retainedAssistant).toBeDefined(); + expect(retainedAssistant?.metadata?.contextUsage).toBeUndefined(); + expect(retainedAssistant?.metadata?.contextProviderMetadata).toBeUndefined(); + const providerMetadataOnly = remaining.data.find( + (message) => message.id === "assistant-provider-metadata" ); - expect(truncateResult.success).toBe(false); - expect(notified).toBe(1); - expect(await fileExists(archivePath(wsId))).toBe(false); - const chatRows = await readJsonlFile(chatPath(wsId)); - // Chat cut not applied: rows survive, but usage stays stripped. - expect(chatRows.some((msg) => msg.id === "user-heavy")).toBe(true); - const activeAssistant = chatRows.find((msg) => msg.id === "assistant-active"); - expect(activeAssistant).toBeDefined(); - expect(activeAssistant?.metadata?.contextUsage).toBeUndefined(); - // Exactly the deleted archive rows (msg-0, msg-1), not the retained - // chat rows whose cut never committed. - expect(reportedDeletions).toEqual([0, 1]); - } finally { - serializeSpy.mockRestore(); + expect(providerMetadataOnly).toBeDefined(); + expect(providerMetadataOnly?.metadata?.contextProviderMetadata).toBeUndefined(); } }); - it("restores chat usage when the chat cut fails after deleting a fully sealed archive", async () => { - // Normal rotated layout: the boundary is the first chat.jsonl row, so - // every archive row is sealed pre-boundary history and deleting the - // archive leaves the provider window unchanged. The failed chat cut - // must leave the original chat bytes (and their usage) untouched. - await appendNumberedMessages(service, wsId, 2); - await service.appendToHistory(wsId, boundaryMessage("boundary-1", 1)); - await service.appendToHistory( - wsId, - createMuxMessage("user-heavy", "user", `heavy prompt ${"x".repeat(3_000)}`) - ); - await service.appendToHistory( - wsId, - createMuxMessage("assistant-active", "assistant", "active reply", { - contextUsage: { inputTokens: 95_000, outputTokens: 100, totalTokens: 95_100 }, - model: "openai:gpt-4o", - }) - ); - - const chatBefore = await fs.readFile(chatPath(wsId), "utf-8"); - - // Fail the chat cut (the serialize of the retained rows). - const internals = service as unknown as { - serializeHistoryEntries: (messages: MuxMessage[], workspaceId: string) => string; - }; - const realSerialize = internals.serializeHistoryEntries.bind(service); - const serializeSpy = spyOn(internals, "serializeHistoryEntries").mockImplementation( - (messages: MuxMessage[], workspaceId: string) => { - if (messages.some((msg) => msg.id === "assistant-active")) { - throw new Error("injected chat cut serialize failure"); - } - return realSerialize(messages, workspaceId); - } - ); - try { - // Sealed rows leaving cannot change the window, and the chat cut - // rolled back, so the caller must NOT be told to drop usage. - let notified = 0; - const truncateResult = await service.truncateHistory(wsId, 0.5, () => { - notified += 1; - }); - expect(truncateResult.success).toBe(false); - expect(notified).toBe(0); - // Every archive row was a cut target, so its deletion stands. - expect(await fileExists(archivePath(wsId))).toBe(false); - expect(await fs.readFile(chatPath(wsId), "utf-8")).toBe(chatBefore); - } finally { - serializeSpy.mockRestore(); - } - }); - - it("never pre-sanitizes chat usage when only the chat cut changes the window", async () => { - // Normal rotated layout: the boundary is the first chat.jsonl row, so - // the archive delete is not window-changing and the final chat cut - // strips usage in the same atomic write. A separate sanitize write - // before the cut would create a crash window that strands an - // unchanged near-limit context with no seedable usage. - await appendNumberedMessages(service, wsId, 2); + it("preserves active usage when truncation removes only sealed rows", async () => { + await appendNumberedMessages(service, wsId, 8); await service.appendToHistory(wsId, boundaryMessage("boundary-1", 1)); await service.appendToHistory( wsId, - createMuxMessage("user-heavy", "user", `heavy prompt ${"x".repeat(3_000)}`) - ); - await service.appendToHistory( - wsId, - createMuxMessage("assistant-active", "assistant", "active reply", { + createMuxMessage("active-usage", "assistant", "reply", { contextUsage: { inputTokens: 95_000, outputTokens: 100, totalTokens: 95_100 }, model: "openai:gpt-4o", }) ); - const chatBefore = await fs.readFile(chatPath(wsId), "utf-8"); - - // Probe chat.jsonl at the instant the archive delete runs: this is the - // state a crash between any earlier write and the cut would leave. - const probe: { chatAtArchiveDelete: string | null } = { chatAtArchiveDelete: null }; - const realRm = fs.rm; - const rmSpy = spyOn(fs, "rm").mockImplementation( - async (...args: Parameters) => { - if (args[0] === archivePath(wsId)) { - probe.chatAtArchiveDelete = await fs.readFile(chatPath(wsId), "utf-8"); - } - return realRm(...args); - } - ); - try { - const truncateResult = await service.truncateHistory(wsId, 0.5); - expect(truncateResult.success).toBe(true); - if (truncateResult.success) { - expect(truncateResult.data.activeContextTruncated).toBe(true); - } - expect(probe.chatAtArchiveDelete).toBe(chatBefore); - // The committed cut still strips usage from retained rows. - const chatRows = await readJsonlFile(chatPath(wsId)); - const activeAssistant = chatRows.find((msg) => msg.id === "assistant-active"); - expect(activeAssistant).toBeDefined(); - expect(activeAssistant?.metadata?.contextUsage).toBeUndefined(); - expect(await fileExists(archivePath(wsId))).toBe(false); - } finally { - rmSpy.mockRestore(); - } - }); - - it("keeps the active file intact when a remove-all cut fails on the archive delete", async () => { - await appendNumberedMessages(service, wsId, 2); - await service.appendToHistory(wsId, boundaryMessage("boundary-1", 1)); - await service.appendToHistory( - wsId, - createMuxMessage("assistant-active", "assistant", "active reply") - ); - - const chatBefore = await fs.readFile(chatPath(wsId), "utf-8"); + expect((await service.truncateHistory(wsId, 0.2)).success).toBe(true); - // A 99% cut with similar-sized rows removes every row without taking the - // percentage >= 1.0 fast path; the remove-all branch must also delete - // archive-first so this failure cannot orphan the active window. - const realRm = fs.rm; - const rmSpy = spyOn(fs, "rm").mockImplementation((...args: Parameters) => { - if (args[0] === archivePath(wsId)) { - return Promise.reject( - Object.assign(new Error("EACCES: permission denied"), { code: "EACCES" }) - ); - } - return realRm(...args); - }); - try { - const truncateResult = await service.truncateHistory(wsId, 0.99); - expect(truncateResult.success).toBe(false); - expect(await fs.readFile(chatPath(wsId), "utf-8")).toBe(chatBefore); - } finally { - rmSpy.mockRestore(); + const active = await service.getHistoryFromLatestBoundary(wsId); + expect(active.success).toBe(true); + if (active.success) { + expect( + active.data.find((message) => message.id === "active-usage")?.metadata?.contextUsage + ).toBeDefined(); } }); - it("keeps the active file intact when full clear fails on the second delete", async () => { - await appendNumberedMessages(service, wsId, 3); - await service.appendToHistory(wsId, boundaryMessage("boundary-1", 1)); - await service.appendToHistory( - wsId, - createMuxMessage("assistant-active", "assistant", "active reply") - ); - - const chatBefore = await fs.readFile(chatPath(wsId), "utf-8"); - - // Full clear removes the archive first: if deleting chat.jsonl then - // fails, the active provider window is still intact and Err is correct. - const realRm = fs.rm; - const rmSpy = spyOn(fs, "rm").mockImplementation((...args: Parameters) => { - if (args[0] === chatPath(wsId)) { - return Promise.reject( - Object.assign(new Error("EACCES: permission denied"), { code: "EACCES" }) - ); - } - return realRm(...args); - }); - try { - // Committed archive deletions must still be reported despite the Err - // (clearHistory forwards this callback for the destructive callers). - const reportedDeletions: number[] = []; - const truncateResult = await service.truncateHistory(wsId, 1.0, undefined, (seqs) => - reportedDeletions.push(...seqs) - ); - expect(truncateResult.success).toBe(false); - expect(await fs.readFile(chatPath(wsId), "utf-8")).toBe(chatBefore); - expect(await fileExists(archivePath(wsId))).toBe(false); - expect(reportedDeletions).toEqual([0, 1, 2]); - } finally { - rmSpy.mockRestore(); - } - }); - - it("keeps the archive intact on a no-op percentage truncation", async () => { - await appendNumberedMessages(service, wsId, 3); - await service.appendToHistory(wsId, boundaryMessage("boundary-1", 1)); - - const chatBefore = await fs.readFile(chatPath(wsId), "utf-8"); - const archiveBefore = await fs.readFile(archivePath(wsId), "utf-8"); - - const truncateResult = await service.truncateHistory(wsId, 0); - expect(truncateResult.success).toBe(true); - if (truncateResult.success) { - expect(truncateResult.data).toEqual({ - deletedSequences: [], - activeContextTruncated: false, - }); - } - - // No-op truncation must not collapse the archive back into chat.jsonl. - expect(await fs.readFile(chatPath(wsId), "utf-8")).toBe(chatBefore); - expect(await fs.readFile(archivePath(wsId), "utf-8")).toBe(archiveBefore); - }); - it("hasHistory sees archive-only workspaces", async () => { await appendNumberedMessages(service, wsId, 1); await service.appendToHistory(wsId, boundaryMessage("boundary-1", 1)); diff --git a/src/node/services/historyService.ts b/src/node/services/historyService.ts index 7ed09d5208..c1ea965d7c 100644 --- a/src/node/services/historyService.ts +++ b/src/node/services/historyService.ts @@ -22,11 +22,9 @@ import { CONTEXT_BOUNDARY_KINDS } from "@/common/constants/contextBoundary"; import { findLatestContextBoundaryIndex, getContextBoundaryKind, - hasProviderEligibleMessages, isDurableCompactedMarker, isDurableContextBoundaryMarker, } from "@/common/utils/messages/compactionBoundary"; -import { filterWorkflowDisplayOnlyMessages } from "@/common/utils/workflowRunMessages"; import { CHAT_FILE_NAME, CHAT_ARCHIVE_FILE_NAME } from "@/common/constants/paths"; import { isRefusalFinishReason } from "@/common/utils/messages/refusalFinishReason"; import { getErrorMessage } from "@/common/utils/errors"; @@ -45,44 +43,26 @@ function hasDurableCompactionBoundary(metadata: MuxMetadata | undefined): boolea return isPositiveInteger(metadata.compactionEpoch); } -// Compaction boundaries are provider-visible (the row carries the summary), -// so the active window starts AT the boundary; reset boundaries are -// provider-invisible markers, so the window starts AFTER them. -function activeProviderWindowStart(messages: MuxMessage[]): number { - const latestBoundaryIndex = findLatestContextBoundaryIndex(messages); - if (latestBoundaryIndex < 0) { - return 0; +function prefixCutChangesActiveContext(messages: MuxMessage[], removeCount: number): boolean { + const boundaryIndex = findLatestContextBoundaryIndex(messages); + if (boundaryIndex < 0) { + return removeCount > 0; } - return getContextBoundaryKind(messages[latestBoundaryIndex]) === CONTEXT_BOUNDARY_KINDS.RESET - ? latestBoundaryIndex + 1 - : latestBoundaryIndex; + const activeStart = + getContextBoundaryKind(messages[boundaryIndex]) === CONTEXT_BOUNDARY_KINDS.RESET + ? boundaryIndex + 1 + : boundaryIndex; + return removeCount > activeStart; } -// A prefix cut changes the active provider context only when it removes a -// provider-replayable row inside the provider window. Rows inside the window -// that requests never replay (workflow display-only rows, reasoning-only -// assistant turns) leave the request, and therefore the usage snapshots -// measured from it, unchanged when removed. Deliberate asymmetry: Anthropic -// with thinking enabled replays reasoning-only rows (preserveReasoningOnly), -// but the next send's provider is unknowable here, so we preserve usage. -// That can only overestimate (compact early); flagging truncated would strip -// valid usage for every other provider and bypass required on-send -// compaction, the failure this feature exists to prevent. -function cutChangesActiveWindow(messages: MuxMessage[], cutEnd: number): boolean { - const start = activeProviderWindowStart(messages); - return hasProviderEligibleMessages( - filterWorkflowDisplayOnlyMessages(messages.slice(Math.min(start, cutEnd), cutEnd)) - ); -} - -function stripContextUsage(msg: MuxMessage): MuxMessage { - if (msg.metadata?.contextUsage === undefined) { - return msg; +function stripContextUsage(message: MuxMessage): MuxMessage { + if (!message.metadata) { + return message; } return { - ...msg, + ...message, metadata: { - ...msg.metadata, + ...message.metadata, contextUsage: undefined, contextProviderMetadata: undefined, }, @@ -1811,34 +1791,12 @@ export class HistoryService { * * By default this removes the target message and all subsequent messages. Callers can retain the * target message when branching a new workspace from a specific reply. - * - * options.onContextRewritten fires the moment the chat.jsonl rewrite - * commits, even when a later step fails, so callers can invalidate cached - * usage for mutations an Err result leaves on disk. retainedUsageValid is - * true when the committed rewrite leaves the retained rows' persisted - * usage snapshots valid (an active-file suffix cut); it is false for the - * archived branch, whose copied prefix duplicates archive rows until the - * archive removal commits. */ async truncateAfterMessage( workspaceId: string, messageId: string, - options?: { - keepTargetMessage?: boolean; - onContextRewritten?: (info: { retainedUsageValid: boolean }) => void; - } + options?: { keepTargetMessage?: boolean } ): Promise> { - const notifyContextRewritten = (info: { retainedUsageValid: boolean }) => { - try { - options?.onContextRewritten?.(info); - } catch (error) { - // A notification failure must not turn a committed rewrite into Err. - log.error("truncateAfterMessage context-rewritten callback failed", { - workspaceId, - error, - }); - } - }; return this.fileLocks.withLock(workspaceId, async () => { try { // Structural rewrite requires full file content @@ -1855,8 +1813,7 @@ export class HistoryService { return this.truncateAfterArchivedMessageUnlocked( workspaceId, messageId, - keepTargetMessage, - notifyContextRewritten + keepTargetMessage ); } @@ -1871,11 +1828,10 @@ export class HistoryService { const historyPath = this.getChatHistoryPath(workspaceId); const historyEntries = this.serializeHistoryEntries(truncatedMessages, workspaceId); + const archiveMaxSeq = await this.getArchiveTailMaxSequence(workspaceId); + // Atomic write prevents corruption if app crashes mid-write await writeFileAtomic(historyPath, historyEntries); - // The archive read below can still fail after this commit, but the - // suffix cut leaves the retained rows' persisted usage valid. - notifyContextRewritten({ retainedUsageValid: true }); // Update sequence counter to continue from where we truncated. // Self-healing read path: skip malformed persisted historySequence values. @@ -1903,7 +1859,6 @@ export class HistoryService { // truncation. When the truncation empties the active file, floor the // counter with the archive max so new appends can never reuse archived // sequence numbers. - const archiveMaxSeq = await this.getArchiveTailMaxSequence(workspaceId); const nextSeq = Math.max(maxTruncatedSeq, archiveMaxSeq) + 1; assert( isNonNegativeInteger(nextSeq), @@ -1928,8 +1883,7 @@ export class HistoryService { private async truncateAfterArchivedMessageUnlocked( workspaceId: string, messageId: string, - keepTargetMessage: boolean, - notifyContextRewritten: (info: { retainedUsageValid: boolean }) => void + keepTargetMessage: boolean ): Promise> { try { const archiveMessages = await this.readArchivedHistory(workspaceId); @@ -1943,44 +1897,25 @@ export class HistoryService { 0, keepTargetMessage ? messageIndex + 1 : messageIndex ); - const historyPath = this.getChatHistoryPath(workspaceId); - const copiedPrefixHasUsage = truncatedMessages.some( - (msg) => msg.metadata?.contextUsage !== undefined - ); - // Write the copied prefix with usage snapshots stripped while the - // archive still exists: if the removal below fails or the process dies - // first, the duplicated-prefix state persists across restarts, where a - // fresh session would seed the copied assistant's old contextUsage and - // understate the archive-plus-prefix payload. Once the removal - // commits, the hazard is gone and the restore write below brings the - // usage back, because it is exact for the restored prefix context and - // an edited near-limit prefix must stay monitored on the next send. + const historyPath = this.getChatHistoryPath(workspaceId); + const archivePath = this.getChatArchivePath(workspaceId); + await fs.rm(historyPath, { force: true }); await writeFileAtomic( - historyPath, - this.serializeHistoryEntries( - copiedPrefixHasUsage ? truncatedMessages.map(stripContextUsage) : truncatedMessages, - workspaceId - ) + archivePath, + this.serializeHistoryEntries(truncatedMessages.map(stripContextUsage), workspaceId) ); - // The archive removal below can still fail after this commit, leaving - // the duplicated-prefix hazard the stripped write above guards. - notifyContextRewritten({ retainedUsageValid: false }); - await fs.rm(this.getChatArchivePath(workspaceId), { force: true }); - if (copiedPrefixHasUsage) { - try { - await writeFileAtomic( - historyPath, - this.serializeHistoryEntries(truncatedMessages, workspaceId) - ); - } catch (error) { - // Safe-but-blind state: one unmonitored send, reseeded by the next - // provider response. Not worth failing the committed truncation. - log.warn("Failed to restore copied-prefix usage after archived edit", { - workspaceId, - error, - }); - } + await fs.rename(archivePath, historyPath); + try { + await writeFileAtomic( + historyPath, + this.serializeHistoryEntries(truncatedMessages, workspaceId) + ); + } catch (error) { + log.warn("Failed to restore usage after archived history truncation", { + workspaceId, + error, + }); } // chat.jsonl may contain sealed epochs again — allow the lazy check to re-run. this.sealedRotationChecked.delete(workspaceId); @@ -2025,56 +1960,13 @@ export class HistoryService { * Truncate history by removing approximately the given percentage of tokens from the beginning * @param workspaceId The workspace ID * @param percentage Percentage to truncate (0.0 to 1.0). 1.0 = delete all - * @param onActiveContextTruncated Invoked at most once, the moment a - * committed step first removes provider-eligible rows from the - * active window. Fires even when a later step fails, so callers can - * invalidate cached usage for mutations an Err result leaves on disk. - * @param onRowsDeleted Invoked with each committed step's deleted - * historySequence numbers the moment that step commits. Fires even - * when a later step fails, so callers can emit renderer deletions - * for rows an Err result leaves removed from disk. - * @returns Result with deleted historySequence numbers plus whether the cut - * reached the active provider-context window (at or past the latest - * durable boundary). Callers use that flag to decide whether cached - * context usage is stale. + * @returns Result containing array of deleted historySequence numbers */ async truncateHistory( workspaceId: string, - percentage: number, - onActiveContextTruncated?: () => void, - onRowsDeleted?: (historySequences: number[]) => void - ): Promise> { + percentage: number + ): Promise> { return this.fileLocks.withLock(workspaceId, async () => { - let contextChangeNotified = false; - const notifyActiveContextTruncated = () => { - if (contextChangeNotified) { - return; - } - contextChangeNotified = true; - try { - onActiveContextTruncated?.(); - } catch (error) { - // A notification failure must not corrupt an already-committed - // rewrite (the catch below would roll back a successful cut). - log.error("truncateHistory context-change callback failed", { workspaceId, error }); - } - }; - const sequencesOf = (msgs: MuxMessage[]): number[] => - msgs - .map((msg) => msg.metadata?.historySequence) - .filter((s): s is number => isNonNegativeInteger(s)); - const notifyRowsDeleted = (msgs: MuxMessage[]) => { - const historySequences = sequencesOf(msgs); - if (historySequences.length === 0) { - return; - } - try { - onRowsDeleted?.(historySequences); - } catch (error) { - // Same invariant as above: committed deletions must stand. - log.error("truncateHistory rows-deleted callback failed", { workspaceId, error }); - } - }; try { const historyPath = this.getChatHistoryPath(workspaceId); const archivePath = this.getChatArchivePath(workspaceId); @@ -2082,27 +1974,20 @@ export class HistoryService { // Fast path: 100% truncation = delete entire history (active + sealed archive) if (percentage >= 1.0) { // Need sequence numbers for return value before deleting - const archivedMessages = await this.readArchivedHistory(workspaceId); - const chatMessages = await this.readChatHistory(workspaceId); - const messages = [...archivedMessages, ...chatMessages]; - const deletedSequences = sequencesOf(messages); - - // Archive first: normally it holds only sealed pre-boundary rows, - // so if the second rm fails the active provider window is still - // intact. A malformed boundary-less archive IS window content, so - // notify at commit; the caller must not trust a later Err. - await fs.rm(archivePath, { force: true }); - notifyRowsDeleted(archivedMessages); - if (cutChangesActiveWindow(messages, archivedMessages.length)) { - notifyActiveContextTruncated(); - } + const messages = [ + ...(await this.readArchivedHistory(workspaceId)), + ...(await this.readChatHistory(workspaceId)), + ]; + const deletedSequences = messages + .map((msg) => msg.metadata?.historySequence) + .filter((s): s is number => isNonNegativeInteger(s)); + await fs.rm(historyPath, { force: true }); - notifyRowsDeleted(chatMessages); - notifyActiveContextTruncated(); + await fs.rm(archivePath, { force: true }); // Reset sequence counter when clearing history this.sequenceCounters.set(workspaceId, 0); - return Ok({ deletedSequences, activeContextTruncated: true }); + return Ok(deletedSequences); } // Structural rewrite requires full history content (oldest rows live in @@ -2112,7 +1997,7 @@ export class HistoryService { const chatMessages = await this.readChatHistory(workspaceId); const messages = [...archivedMessages, ...chatMessages]; if (messages.length === 0) { - return Ok({ deletedSequences: [], activeContextTruncated: false }); // Nothing to truncate + return Ok([]); // Nothing to truncate } // Get tokenizer for counting (use a default model) @@ -2146,128 +2031,51 @@ export class HistoryService { // rewrite anything — collapsing the archive back into chat.jsonl would // undo rotation and put lifetime history back on the hot path. if (removeCount === 0) { - return Ok({ deletedSequences: [], activeContextTruncated: false }); + return Ok([]); } - // If we're removing all messages, use fast path. Archive first for - // the same failure-ordering reason as the full-clear branch above, - // with the same commit-time notifications. + // If we're removing all messages, use fast path if (removeCount >= messages.length) { - await fs.rm(archivePath, { force: true }); - notifyRowsDeleted(archivedMessages); - if (cutChangesActiveWindow(messages, archivedMessages.length)) { - notifyActiveContextTruncated(); - } await fs.rm(historyPath, { force: true }); - notifyRowsDeleted(chatMessages); - notifyActiveContextTruncated(); + await fs.rm(archivePath, { force: true }); this.sequenceCounters.set(workspaceId, 0); - return Ok({ deletedSequences: sequencesOf(messages), activeContextTruncated: true }); + const deletedSequences = messages + .map((msg) => msg.metadata?.historySequence) + .filter((s): s is number => isNonNegativeInteger(s)); + return Ok(deletedSequences); } - const activeContextTruncated = cutChangesActiveWindow(messages, removeCount); - - // Retained rows' contextUsage measured the pre-truncation context - // (including the removed prefix). Persisting it would reseed stale - // auto-compaction pressure, even across app restarts. Strip it; the - // next provider response reports fresh usage. - const sanitizeRetained = (msg: MuxMessage): MuxMessage => - activeContextTruncated ? stripContextUsage(msg) : msg; - const remainingMessages = messages.slice(removeCount).map(sanitizeRetained); + const activeContextChanged = prefixCutChangesActiveContext(messages, removeCount); + const sanitize = activeContextChanged + ? stripContextUsage + : (message: MuxMessage) => message; + const remainingMessages = messages.slice(removeCount).map(sanitize); const deletedMessages = messages.slice(0, removeCount); - const deletedSequences = sequencesOf(deletedMessages); - - // Rewrite each file in place instead of collapsing the archive into - // chat.jsonl: every step is either an atomic single-file write or a - // deletion of rows the cut removes anyway, so no failure or crash can - // lose retained rows or leave duplicates behind. - // - // Deleting the whole archive commits a window change only when the - // window extends into it (boundary inside the archive, or none at - // all). A normally rotated archive holds only sealed pre-boundary - // rows, so its deletion leaves the provider context unchanged and - // must not block the usage rollback in the catch below. - const archiveDeleteChangesWindow = cutChangesActiveWindow( - messages, - archivedMessages.length - ); - // Sanitize chat.jsonl BEFORE the cut only when the archive step that - // runs first is itself window-changing (an archive-confined cut - // removing window rows, or a whole-archive delete of window rows; - // both need a boundary-less or boundary-in-archive layout). Then a - // crash between sanitize and cut leaves the window unchanged with - // usage stripped: one unmonitored send, either repaired by the next - // provider response or surfaced as a provider context-length error, - // the same states as histories predating usage snapshots. The - // inverse ordering's crash window pairs a changed window with stale - // usage, which a restart reseeds into a spurious auto-compaction - // that nothing repairs. In normal rotated layouts the only - // window-changing step is the final chat cut, which strips usage in - // the same atomic write, so no separate sanitize write exists for a - // crash to strand. Runtime failures before any window-changing - // commit roll the sanitize back byte-exactly, returning Err with - // both files as they were. - const needsPreSanitize = - removeCount < archivedMessages.length - ? activeContextTruncated - : archiveDeleteChangesWindow; - let originalChat: string | null = null; - if (needsPreSanitize) { - originalChat = await fs.readFile(historyPath, "utf-8").catch(() => null); + const deletedSequences = deletedMessages + .map((msg) => msg.metadata?.historySequence) + .filter((s): s is number => isNonNegativeInteger(s)); + const remainingArchiveCount = Math.max(0, archivedMessages.length - removeCount); + const remainingArchive = remainingMessages.slice(0, remainingArchiveCount); + const remainingChat = remainingMessages.slice(remainingArchiveCount); + + if (activeContextChanged && archivedMessages.length > 0) { await writeFileAtomic( historyPath, - this.serializeHistoryEntries(chatMessages.map(sanitizeRetained), workspaceId) + this.serializeHistoryEntries(chatMessages.map(stripContextUsage), workspaceId) ); } - let windowChanged = false; - try { - if (removeCount < archivedMessages.length) { - // Cut confined to the archive: one atomic rewrite applies it. - const retainedArchive = archivedMessages.slice(removeCount).map(sanitizeRetained); - await writeFileAtomic( - archivePath, - this.serializeHistoryEntries(retainedArchive, workspaceId) - ); - notifyRowsDeleted(deletedMessages); - } else { - // Cut consumes the whole archive: every archive row is a cut - // target, so deleting the archive before committing the chat cut - // can only remove rows the cut targets. - if (archivedMessages.length > 0) { - await fs.rm(archivePath, { force: true }); - notifyRowsDeleted(archivedMessages); - windowChanged = archiveDeleteChangesWindow; - if (archiveDeleteChangesWindow) { - notifyActiveContextTruncated(); - } - } - const retainedChat = chatMessages - .slice(removeCount - archivedMessages.length) - .map(sanitizeRetained); - await writeFileAtomic( - historyPath, - this.serializeHistoryEntries(retainedChat, workspaceId) - ); - notifyRowsDeleted(chatMessages.slice(0, removeCount - archivedMessages.length)); - } - } catch (error) { - if (needsPreSanitize && !windowChanged && originalChat !== null) { - try { - await writeFileAtomic(historyPath, originalChat); - } catch (rollbackError) { - // Window unchanged with usage stripped: over-strips until the - // next provider response, never bypasses required compaction. - log.error("Failed to restore chat usage after truncation cut failure", { - workspaceId, - error: rollbackError, - }); - } - } - throw error; - } - if (activeContextTruncated) { - notifyActiveContextTruncated(); + if (remainingArchive.length > 0) { + await writeFileAtomic( + archivePath, + this.serializeHistoryEntries(remainingArchive, workspaceId) + ); + } else { + await fs.rm(archivePath, { force: true }); } + await writeFileAtomic( + historyPath, + this.serializeHistoryEntries(remainingChat, workspaceId) + ); this.sealedRotationChecked.delete(workspaceId); // Update sequence counter to continue from where we are. @@ -2299,7 +2107,7 @@ export class HistoryService { ); this.sequenceCounters.set(workspaceId, nextSeq); - return Ok({ deletedSequences, activeContextTruncated }); + return Ok(deletedSequences); } catch (error) { const message = getErrorMessage(error); return Err(`Failed to truncate history: ${message}`); @@ -2307,21 +2115,12 @@ export class HistoryService { }); } - async clearHistory( - workspaceId: string, - onActiveContextTruncated?: () => void, - onRowsDeleted?: (historySequences: number[]) => void - ): Promise> { - const result = await this.truncateHistory( - workspaceId, - 1.0, - onActiveContextTruncated, - onRowsDeleted - ); + async clearHistory(workspaceId: string): Promise> { + const result = await this.truncateHistory(workspaceId, 1.0); if (!result.success) { return Err(result.error); } - return Ok(result.data.deletedSequences); + return Ok(result.data); } /** diff --git a/src/node/services/workspaceService.test.ts b/src/node/services/workspaceService.test.ts index 00a9f22508..07effd4c71 100644 --- a/src/node/services/workspaceService.test.ts +++ b/src/node/services/workspaceService.test.ts @@ -42,7 +42,7 @@ import type { DesktopSessionManager } from "@/node/services/desktop/DesktopSessi import type { WorktreeArchiveSnapshot } from "@/common/schemas/project"; import type { BashToolResult } from "@/common/types/tools"; import type { WorkspaceChatMessage } from "@/common/orpc/types"; -import { createMuxMessage, type MuxMessage } from "@/common/types/message"; +import { createMuxMessage } from "@/common/types/message"; import { buildStagedAttachmentNotice } from "@/browser/features/ChatInput/stagedAttachments"; import { WORKFLOW_RUN_CARD_DISPLAY_METADATA_TYPE, @@ -3905,9 +3905,7 @@ describe("WorkspaceService truncateHistory goal acknowledgment", () => { bashMonitorRecoveryPromise: Promise; }; internal.bashMonitorRecoveryPromise = recovery.promise; - const truncateSpy = spyOn(historyService, "truncateHistory").mockResolvedValue( - Ok({ deletedSequences: [], activeContextTruncated: true }) - ); + const truncateSpy = spyOn(historyService, "truncateHistory").mockResolvedValue(Ok([])); try { const clearPromise = workspaceService.truncateHistory(workspaceId, 1.0); @@ -4278,7 +4276,7 @@ describe("WorkspaceService truncateHistory goal acknowledgment", () => { } }); - test("start-here replacement clears stale usage so the next send does not auto-compact", async () => { + test("start-here replacement does not auto-compact the next send from stale usage", async () => { const { config, historyService, workspaceService, cleanup } = await createServices(); const workspaceId = "start-here-clears-usage-state"; const streamMessage = mock((..._args: unknown[]) => Promise.resolve(Ok(undefined))); @@ -4298,32 +4296,22 @@ describe("WorkspaceService truncateHistory goal acknowledgment", () => { projectPath: "/tmp/start-here-usage-project", runtimeConfig: { type: "local" }, }); - expect( - ( - await historyService.appendToHistory( - workspaceId, - createMuxMessage("pre-start-here-user", "user", "long conversation", {}) - ) - ).success - ).toBe(true); - expect( - ( - await historyService.appendToHistory( - workspaceId, - createMuxMessage("pre-start-here-assistant", "assistant", "long reply", { - model: "openai:gpt-4o", - contextUsage: { inputTokens: 95_000, outputTokens: 200, totalTokens: 95_200 }, - }) - ) - ).success - ).toBe(true); + await historyService.appendToHistory( + workspaceId, + createMuxMessage("pre-start-here-user", "user", "long conversation", {}) + ); + await historyService.appendToHistory( + workspaceId, + createMuxMessage("pre-start-here-assistant", "assistant", "long reply", { + model: "openai:gpt-4o", + contextUsage: { inputTokens: 95_000, outputTokens: 200, totalTokens: 95_200 }, + }) + ); (workspaceService as unknown as { sessions: Map }).sessions.set( workspaceId, harness.session ); - // In-memory snapshot the previous stream left behind: 95k exceeds 70% of - // gpt-4o's 128k window, so a send with this state would auto-compact. (harness.session as unknown as { lastUsageState?: AutoCompactionUsageState }).lastUsageState = { lastContextUsage: createDisplayUsage( @@ -4332,20 +4320,25 @@ describe("WorkspaceService truncateHistory goal acknowledgment", () => { ), }; - const replaceResult = await workspaceService.replaceHistory( - workspaceId, - createMuxMessage("start-here-summary", "assistant", "Start Here summary", { - compacted: "user", - }), - { mode: "append-compaction-boundary" } - ); - expect(replaceResult.success).toBe(true); - - const sendResult = await harness.session.sendMessage("follow-up after start here", { - model: "openai:gpt-4o", - agentId: "exec", - }); - expect(sendResult.success).toBe(true); + expect( + ( + await workspaceService.replaceHistory( + workspaceId, + createMuxMessage("start-here-summary", "assistant", "Start Here summary", { + compacted: "user", + }), + { mode: "append-compaction-boundary" } + ) + ).success + ).toBe(true); + expect( + ( + await harness.session.sendMessage("follow-up after start here", { + model: "openai:gpt-4o", + agentId: "exec", + }) + ).success + ).toBe(true); const activeWindow = await historyService.getHistoryFromLatestBoundary(workspaceId); expect(activeWindow.success).toBe(true); @@ -4355,8 +4348,7 @@ describe("WorkspaceService truncateHistory goal acknowledgment", () => { (message) => message.metadata?.muxMetadata?.type === "compaction-request" ) ).toHaveLength(0); - const followUp = activeMessages.find((message) => message.role === "user"); - expect(followUp?.parts[0]).toMatchObject({ + expect(activeMessages.find((message) => message.role === "user")?.parts[0]).toMatchObject({ type: "text", text: "follow-up after start here", }); @@ -4367,262 +4359,6 @@ describe("WorkspaceService truncateHistory goal acknowledgment", () => { } }); - test("context reset clears stale session usage state", async () => { - const { config, historyService, workspaceService, cleanup } = await createServices(); - const workspaceId = "context-reset-clears-usage-state"; - try { - await config.addWorkspace("/tmp/context-reset-usage-project", { - id: workspaceId, - name: workspaceId, - projectName: "context-reset-usage-project", - projectPath: "/tmp/context-reset-usage-project", - runtimeConfig: { type: "local" }, - }); - expect( - ( - await historyService.appendToHistory( - workspaceId, - createMuxMessage("pre-reset-user", "user", "before reset", {}) - ) - ).success - ).toBe(true); - - const clearUsageState = mock(() => undefined); - const session = { - isBusy: mock(() => false), - hasQueuedMessages: mock(() => false), - isPreparingTurn: mock(() => false), - hasPendingAutoRetry: mock(() => false), - emitChatEvent: mock(() => undefined), - clearUsageState, - clearFileState: mock(() => undefined), - } as unknown as AgentSession; - (workspaceService as unknown as { sessions: Map }).sessions.set( - workspaceId, - session - ); - - expect(await workspaceService.resetContext(workspaceId)).toEqual({ - success: true, - data: "reset", - }); - - expect(clearUsageState).toHaveBeenCalledTimes(1); - } finally { - await cleanup(); - } - }); - - test("full history clear clears stale session usage state", async () => { - const { config, historyService, workspaceService, cleanup } = await createServices(); - const workspaceId = "full-clear-clears-usage-state"; - try { - await config.addWorkspace("/tmp/full-clear-usage-project", { - id: workspaceId, - name: workspaceId, - projectName: "full-clear-usage-project", - projectPath: "/tmp/full-clear-usage-project", - runtimeConfig: { type: "local" }, - }); - expect( - ( - await historyService.appendToHistory( - workspaceId, - createMuxMessage("pre-clear-user", "user", "before clear", {}) - ) - ).success - ).toBe(true); - - const clearUsageState = mock(() => undefined); - const session = { - isBusy: mock(() => false), - emitChatEvent: mock(() => undefined), - clearUsageState, - clearFileState: mock(() => undefined), - } as unknown as AgentSession; - (workspaceService as unknown as { sessions: Map }).sessions.set( - workspaceId, - session - ); - - // A zero-percentage truncation rewrites nothing, so usage must survive. - expect((await workspaceService.truncateHistory(workspaceId, 0)).success).toBe(true); - expect(clearUsageState).toHaveBeenCalledTimes(0); - - expect((await workspaceService.truncateHistory(workspaceId, 1.0)).success).toBe(true); - expect(clearUsageState).toHaveBeenCalledTimes(1); - } finally { - await cleanup(); - } - }); - - test("usage is cleared when wake restoration fails after a committed truncation", async () => { - const { config, historyService, workspaceService, cleanup } = await createServices(); - const workspaceId = "truncate-usage-clear-before-wake-restore"; - try { - await config.addWorkspace("/tmp/truncate-wake-restore-project", { - id: workspaceId, - name: workspaceId, - projectName: "truncate-wake-restore-project", - projectPath: "/tmp/truncate-wake-restore-project", - runtimeConfig: { type: "local" }, - }); - for (let i = 0; i < 6; i++) { - expect( - ( - await historyService.appendToHistory( - workspaceId, - createMuxMessage(`msg-${i}`, "user", `message ${i} with some padding text`, {}) - ) - ).success - ).toBe(true); - } - - const clearUsageState = mock(() => undefined); - const session = { - isBusy: mock(() => false), - emitChatEvent: mock(() => undefined), - clearUsageState, - clearFileState: mock(() => undefined), - } as unknown as AgentSession; - (workspaceService as unknown as { sessions: Map }).sessions.set( - workspaceId, - session - ); - - const wakeStore = ( - workspaceService as unknown as { bashMonitorWakeStore: BashMonitorWakeStore } - ).bashMonitorWakeStore; - const restoreSpy = spyOn(wakeStore, "restorePendingSnapshots").mockImplementation(() => - Promise.reject(new Error("injected wake restore failure")) - ); - - // Partial truncation commits the rewrite, then wake restoration throws. - let thrown: unknown; - try { - await workspaceService.truncateHistory(workspaceId, 0.5); - } catch (error) { - thrown = error; - } - expect(thrown).toBeInstanceOf(Error); - expect((thrown as Error).message).toBe("injected wake restore failure"); - expect(clearUsageState).toHaveBeenCalledTimes(1); - restoreSpy.mockRestore(); - } finally { - await cleanup(); - } - }); - - test("session usage is cleared when the cut fails after deleting window content", async () => { - const { config, historyService, workspaceService, cleanup } = await createServices(); - const workspaceId = "truncate-usage-clear-on-partial-commit"; - try { - await config.addWorkspace("/tmp/truncate-partial-commit-project", { - id: workspaceId, - name: workspaceId, - projectName: "truncate-partial-commit-project", - projectPath: "/tmp/truncate-partial-commit-project", - runtimeConfig: { type: "local" }, - }); - // Boundary-less rotated layout: the boundary append seals a prefix into - // the archive, then deleting the boundary row makes the archive rows - // active-window content. - for (let i = 0; i < 2; i++) { - expect( - ( - await historyService.appendToHistory( - workspaceId, - createMuxMessage(`msg-${i}`, "user", `message ${i}`) - ) - ).success - ).toBe(true); - } - expect( - ( - await historyService.appendToHistory( - workspaceId, - createMuxMessage("boundary-1", "assistant", "Summary 1", { - compactionBoundary: true, - compacted: "user", - compactionEpoch: 1, - }) - ) - ).success - ).toBe(true); - expect( - ( - await historyService.appendToHistory( - workspaceId, - createMuxMessage("user-heavy", "user", `heavy prompt ${"x".repeat(3_000)}`) - ) - ).success - ).toBe(true); - expect( - ( - await historyService.appendToHistory( - workspaceId, - createMuxMessage("assistant-active", "assistant", "active reply", { - contextUsage: { inputTokens: 95_000, outputTokens: 100, totalTokens: 95_100 }, - model: "openai:gpt-4o", - }) - ) - ).success - ).toBe(true); - expect((await historyService.deleteMessage(workspaceId, "boundary-1")).success).toBe(true); - - const clearUsageState = mock(() => undefined); - const emittedChatEvents: unknown[] = []; - const session = { - isBusy: mock(() => false), - emitChatEvent: mock((event: unknown) => { - emittedChatEvents.push(event); - }), - clearUsageState, - clearFileState: mock(() => undefined), - } as unknown as AgentSession; - (workspaceService as unknown as { sessions: Map }).sessions.set( - workspaceId, - session - ); - - // Fail the chat cut (serialize call 2) after the whole-archive delete - // commits: truncation returns Err, but window content is already gone, - // so the session's usage must still be invalidated and the committed - // archive deletions must reach the renderer (no ghost rows). - const internals = historyService as unknown as { - serializeHistoryEntries: (messages: MuxMessage[], workspaceId: string) => string; - }; - const realSerialize = internals.serializeHistoryEntries.bind(historyService); - let serializeCalls = 0; - const serializeSpy = spyOn(internals, "serializeHistoryEntries").mockImplementation( - (messages: MuxMessage[], wsId: string) => { - serializeCalls += 1; - if (serializeCalls === 2) { - throw new Error("injected chat cut serialize failure"); - } - return realSerialize(messages, wsId); - } - ); - try { - const result = await workspaceService.truncateHistory(workspaceId, 0.5); - expect(result.success).toBe(false); - expect(clearUsageState).toHaveBeenCalledTimes(1); - const deleteEvents = emittedChatEvents.filter( - (event): event is { type: string; historySequences: number[] } => - typeof event === "object" && - event !== null && - (event as { type?: string }).type === "delete" - ); - expect(deleteEvents).toHaveLength(1); - expect(deleteEvents[0].historySequences).toEqual([0, 1]); - } finally { - serializeSpy.mockRestore(); - } - } finally { - await cleanup(); - } - }); - test("context reset is a no-op when repeated without provider-eligible messages", async () => { const { config, historyService, workspaceService, cleanup } = await createServices(); const workspaceId = "context-reset-noop"; diff --git a/src/node/services/workspaceService.ts b/src/node/services/workspaceService.ts index 4af10edf6a..41c083ee6e 100644 --- a/src/node/services/workspaceService.ts +++ b/src/node/services/workspaceService.ts @@ -3490,13 +3490,8 @@ export class WorkspaceService extends EventEmitter { telemetryService: this.telemetryService, initStateManager: this.initStateManager, workspaceGoalService: this.workspaceGoalService, - clearHistoryForHardRestart: ({ monitorHistoryLockHeld, onRowsDeleted }) => { - const clear = () => - this.historyService.clearHistory( - workspaceId, - () => this.sessions.get(workspaceId)?.clearUsageState(), - onRowsDeleted - ); + clearHistoryForHardRestart: ({ monitorHistoryLockHeld }) => { + const clear = () => this.historyService.clearHistory(workspaceId); const options = { discardUnacceptedOnSuccess: true }; return monitorHistoryLockHeld ? this.clearHistoryWithRetiredBashMonitorWakesUnlocked(workspaceId, clear, options) @@ -9671,34 +9666,26 @@ export class WorkspaceService extends EventEmitter { const effectivePercentage = percentage ?? 1.0; const isFullClear = effectivePercentage >= 1.0; - // Invalidate usage the moment a window-changing step commits, via the - // truncation callback: the cut can fail AFTER deleting provider-eligible - // archive rows (boundary-less rotated layout), and later wrapper steps - // (monitor-wake restoration) can fail after chat.jsonl has changed, so a - // success-only guard would leave stale usage alive across both windows. - // A cut confined to sealed pre-boundary rows never fires the callback, - // keeping the still-valid usage seedable. - const committedDeletedSequences: number[] = []; - const truncate = () => - this.historyService.truncateHistory( - workspaceId, - effectivePercentage, - () => this.sessions.get(workspaceId)?.clearUsageState(), - (historySequences) => committedDeletedSequences.push(...historySequences) - ); - // Emit deletions for every committed step even when the truncation or a - // wrapper step fails afterwards: rows already removed from disk would - // otherwise linger in the renderer as ghosts that no retry can delete - // (their sequences no longer exist to be reported). - const emitCommittedDeletions = () => { - if (committedDeletedSequences.length === 0) { - return; - } + if (effectivePercentage > 0) { + session?.clearUsageState(); + } + const truncate = () => this.historyService.truncateHistory(workspaceId, effectivePercentage); + const truncateResult = + effectivePercentage > 0 + ? await this.clearHistoryWithRetiredBashMonitorWakes(workspaceId, truncate, { + discardUnacceptedOnSuccess: isFullClear, + }) + : await truncate(); + if (!truncateResult.success) { + return Err(truncateResult.error); + } + + const deletedSequences = truncateResult.data; + if (deletedSequences.length > 0) { const deleteMessage: DeleteMessage = { type: "delete", - historySequences: [...committedDeletedSequences], + historySequences: deletedSequences, }; - committedDeletedSequences.length = 0; // Emit through the session so ORPC subscriptions receive the event if (session) { session.emitChatEvent(deleteMessage); @@ -9706,22 +9693,6 @@ export class WorkspaceService extends EventEmitter { // Fallback to direct emit (legacy path) this.emit("chat", { workspaceId, message: deleteMessage }); } - }; - let truncateResult: Awaited>; - try { - truncateResult = - effectivePercentage > 0 - ? await this.clearHistoryWithRetiredBashMonitorWakes(workspaceId, truncate, { - discardUnacceptedOnSuccess: isFullClear, - }) - : await truncate(); - } catch (error) { - emitCommittedDeletions(); - throw error; - } - emitCommittedDeletions(); - if (!truncateResult.success) { - return Err(truncateResult.error); } // On full clear, also delete plan file and clear file change tracking @@ -9833,28 +9804,9 @@ export class WorkspaceService extends EventEmitter { const replaceMode = options?.mode ?? "destructive"; - // Same ghost-row invariant as truncateHistory: emit deletions for every - // committed clear step even when a later step fails, or rows already - // removed from disk linger in the renderer with no way to delete them. - const committedDeletedSequences: number[] = []; - const emitCommittedDeletions = () => { - if (committedDeletedSequences.length === 0) { - return; - } - const deleteMessage: DeleteMessage = { - type: "delete", - historySequences: [...committedDeletedSequences], - }; - committedDeletedSequences.length = 0; - const emitSession = this.sessions.get(workspaceId); - if (emitSession) { - emitSession.emitChatEvent(deleteMessage); - } else { - this.emit("chat", { workspaceId, message: deleteMessage }); - } - }; try { let messageToAppend = summaryMessage; + let deletedSequences: number[] = []; if (replaceMode === "append-compaction-boundary") { assert( @@ -9913,18 +9865,13 @@ export class WorkspaceService extends EventEmitter { `replaceHistory received unsupported replace mode: ${String(replaceMode)}` ); + this.sessions.get(workspaceId)?.clearUsageState(); const clearResult = await this.clearHistoryWithRetiredBashMonitorWakes( workspaceId, - () => - this.historyService.clearHistory( - workspaceId, - () => this.sessions.get(workspaceId)?.clearUsageState(), - (historySequences) => committedDeletedSequences.push(...historySequences) - ), + () => this.historyService.clearHistory(workspaceId), { discardUnacceptedOnSuccess: true } ); if (!clearResult.success) { - emitCommittedDeletions(); return Err(`Failed to clear history: ${clearResult.error}`); } this.timelineRecorder.record(workspaceId, { @@ -9932,18 +9879,29 @@ export class WorkspaceService extends EventEmitter { source: { system: "chat" }, status: "completed", }); + deletedSequences = clearResult.data; } const appendResult = await this.historyService.appendToHistory(workspaceId, messageToAppend); if (!appendResult.success) { - emitCommittedDeletions(); return Err(`Failed to append summary message: ${appendResult.error}`); } + this.sessions.get(workspaceId)?.clearUsageState(); + // Emit through the session so ORPC subscriptions receive the events const session = this.sessions.get(workspaceId); - session?.clearUsageState(); - emitCommittedDeletions(); + if (deletedSequences.length > 0) { + const deleteMessage: DeleteMessage = { + type: "delete", + historySequences: deletedSequences, + }; + if (session) { + session.emitChatEvent(deleteMessage); + } else { + this.emit("chat", { workspaceId, message: deleteMessage }); + } + } // Add type: "message" for discriminated union (MuxMessage doesn't have it) const typedSummaryMessage = { ...messageToAppend, type: "message" as const }; @@ -9966,7 +9924,6 @@ export class WorkspaceService extends EventEmitter { return Ok(undefined); } catch (error) { - emitCommittedDeletions(); const message = getErrorMessage(error); return Err(`Failed to replace history: ${message}`); } From ac9a3258238748e8ebec584eb0ee2f4d310a96da Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Sat, 8 Aug 2026 07:43:15 +0000 Subject: [PATCH 31/36] tests: remove unrelated review-driven coverage --- .github/workflows/pr.yml | 3 - .../agentSession.autoCompaction.test.ts | 478 +----------------- 2 files changed, 1 insertion(+), 480 deletions(-) diff --git a/.github/workflows/pr.yml b/.github/workflows/pr.yml index 7b8ee35aa8..e0d0e1d364 100644 --- a/.github/workflows/pr.yml +++ b/.github/workflows/pr.yml @@ -228,9 +228,6 @@ jobs: ! -path 'src/browser/features/RightSidebar/Memory/MemoryTab.test.tsx' \ -print0 ) - # Bun-only DOM isolation guards live outside src/ and are excluded - # from Jest, so include them here explicitly. - unit_files+=("tests/ui/domIsolation.test.ts") bun test --max-concurrency=1 --coverage --coverage-reporter=lcov "${unit_files[@]}" - uses: codecov/codecov-action@671740ac38dd9b0130fbe1cec585b89eea48d3de # v5.5.2 with: diff --git a/src/node/services/agentSession.autoCompaction.test.ts b/src/node/services/agentSession.autoCompaction.test.ts index 674d6cc230..785e6cb7d6 100644 --- a/src/node/services/agentSession.autoCompaction.test.ts +++ b/src/node/services/agentSession.autoCompaction.test.ts @@ -1,5 +1,4 @@ -import { afterEach, describe, expect, mock, spyOn, test } from "bun:test"; -import * as fs from "fs/promises"; +import { afterEach, describe, expect, mock, test } from "bun:test"; import { EventEmitter } from "events"; import type { @@ -13,7 +12,6 @@ import { type MuxMessage, } from "@/common/types/message"; import { GOAL_CONTINUATION_KIND } from "@/constants/goals"; -import type { AutoCompactionUsageState } from "@/common/utils/compaction/autoCompactionCheck"; import { Ok, Err } from "@/common/types/result"; import type { Config } from "@/node/config"; import type { AIService } from "@/node/services/aiService"; @@ -745,94 +743,6 @@ describe("AgentSession on-send auto-compaction snapshot deferral", () => { session.dispose(); }); - // Boundary-less history retaining a high-usage assistant row, as after a - // partial /clear that removed only an older prefix. - async function seedBoundarylessHighUsageHistory(workspaceId: string) { - const { historyService, config, cleanup } = await createTestHistoryService(); - historyCleanup = cleanup; - - const appendUser = await historyService.appendToHistory( - workspaceId, - createMuxMessage("user-retained", "user", "retained prompt", { - timestamp: Date.now() - 2_000, - }) - ); - expect(appendUser.success).toBe(true); - - const appendAssistant = await historyService.appendToHistory( - workspaceId, - createMuxMessage("assistant-retained", "assistant", "retained reply", { - timestamp: Date.now() - 1_000, - model: "openai:gpt-4o", - contextUsage: { inputTokens: 95_000, outputTokens: 100, totalTokens: 95_100 }, - }) - ); - expect(appendAssistant.success).toBe(true); - - return { historyService, config }; - } - - function installUsageCapturingMonitor(session: AgentSession): unknown[] { - const seenUsages: unknown[] = []; - (session as unknown as { compactionMonitor: CompactionMonitor }).compactionMonitor = { - checkBeforeSend: mock((params: { usage?: unknown }) => { - seenUsages.push(params.usage); - return { - shouldShowWarning: false, - shouldForceCompact: false, - usagePercentage: 0, - thresholdPercentage: 85, - }; - }), - checkMidStream: mock(() => false), - resetForNewStream: mock(() => undefined), - setThreshold: mock(() => undefined), - getThreshold: mock(() => 0.85), - } as unknown as CompactionMonitor; - return seenUsages; - } - - test("history seeding restores persisted usage when no rewrite occurred", async () => { - const workspaceId = "ws-usage-seeding-baseline"; - const { historyService, config } = await seedBoundarylessHighUsageHistory(workspaceId); - - const harness = await createAgentSessionHarness({ workspaceId, historyService, config }); - const seenUsages = installUsageCapturingMonitor(harness.session); - - const result = await harness.session.sendMessage("restart send", { - model: "openai:gpt-4o", - agentId: "exec", - }); - expect(result.success).toBe(true); - expect(seenUsages).toHaveLength(1); - const seeded = seenUsages[0] as AutoCompactionUsageState | undefined; - expect(seeded?.lastContextUsage).toBeDefined(); - - harness.session.dispose(); - }); - - test("clearUsageState suppresses history seeding until fresh provider usage arrives", async () => { - const workspaceId = "ws-usage-seeding-suppressed"; - const { historyService, config } = await seedBoundarylessHighUsageHistory(workspaceId); - - const harness = await createAgentSessionHarness({ workspaceId, historyService, config }); - const seenUsages = installUsageCapturingMonitor(harness.session); - - // Same fixture as the baseline test, but a rewrite invalidated usage: - // the retained row's contextUsage still counts removed tokens and must not reseed. - harness.session.clearUsageState(); - - const result = await harness.session.sendMessage("send after partial clear", { - model: "openai:gpt-4o", - agentId: "exec", - }); - expect(result.success).toBe(true); - expect(seenUsages).toHaveLength(1); - expect(seenUsages[0]).toBeUndefined(); - - harness.session.dispose(); - }); - test("seeds on-send compaction usage from the active compaction epoch only", async () => { const workspaceId = "ws-auto-compaction-seed-active-epoch"; @@ -944,392 +854,6 @@ describe("AgentSession on-send auto-compaction snapshot deferral", () => { session.dispose(); }); - test("heartbeat context reset clears stale usage before its follow-up dispatches", async () => { - const workspaceId = "ws-heartbeat-reset-clears-usage"; - const { session } = await createSessionHarness({ workspaceId }); - - const sessionState = session as unknown as { lastUsageState?: AutoCompactionUsageState }; - sessionState.lastUsageState = { totalTokens: 95_000 }; - - const result = await session.appendHeartbeatContextResetBoundary({ - boundaryText: "Heartbeat context reset boundary", - pendingFollowUp: { text: "heartbeat follow-up", model: "openai:gpt-4o", agentId: "exec" }, - }); - - expect(result.success).toBe(true); - expect(sessionState.lastUsageState).toBeUndefined(); - - session.dispose(); - }); - - test("edit truncation keeps history seeding available for the retained prefix", async () => { - const workspaceId = "ws-edit-truncation-preserves-seeding"; - const { session, historyService } = await createSessionHarness({ workspaceId }); - - // Retained prefix: assistant row whose usage is valid post-edit. - // Suffix after the edit target gets truncated away. - const rows = [ - createMuxMessage("user-1", "user", "first prompt", { timestamp: Date.now() - 5_000 }), - createMuxMessage("assistant-1", "assistant", "first reply", { - timestamp: Date.now() - 4_000, - model: "openai:gpt-4o", - contextUsage: { inputTokens: 95_000, outputTokens: 100, totalTokens: 95_100 }, - }), - createMuxMessage("user-2", "user", "second prompt", { timestamp: Date.now() - 3_000 }), - createMuxMessage("assistant-2", "assistant", "second reply", { - timestamp: Date.now() - 2_000, - model: "openai:gpt-4o", - }), - ]; - for (const row of rows) { - expect((await historyService.appendToHistory(workspaceId, row)).success).toBe(true); - } - - const editResult = await session.sendMessage("second prompt, edited", { - model: "openai:gpt-4o", - agentId: "exec", - editMessageId: "user-2", - }); - expect(editResult.success).toBe(true); - - // The edit stream reported no usage (e.g. failed early); the next normal - // send must still seed from the retained prefix so compaction stays armed. - const seenUsages = installUsageCapturingMonitor(session); - const result = await session.sendMessage("follow-up send", { - model: "openai:gpt-4o", - agentId: "exec", - }); - expect(result.success).toBe(true); - expect(seenUsages).toHaveLength(1); - const seeded = seenUsages[0] as AutoCompactionUsageState | undefined; - expect(seeded?.lastContextUsage).toBeDefined(); - - session.dispose(); - }); - - test("edit truncation clears usage even when the cut fails after committing", async () => { - const workspaceId = "ws-edit-truncation-partial-commit"; - const { session, historyService } = await createSessionHarness({ workspaceId }); - - const rows = [ - createMuxMessage("user-1", "user", "first prompt", { timestamp: Date.now() - 5_000 }), - createMuxMessage("assistant-1", "assistant", "first reply", { - timestamp: Date.now() - 4_000, - model: "openai:gpt-4o", - contextUsage: { inputTokens: 95_000, outputTokens: 100, totalTokens: 95_100 }, - }), - createMuxMessage("user-2", "user", "second prompt", { timestamp: Date.now() - 3_000 }), - ]; - for (const row of rows) { - expect((await historyService.appendToHistory(workspaceId, row)).success).toBe(true); - } - - // Live pre-edit usage that the removed suffix produced. - const sessionState = session as unknown as { lastUsageState?: AutoCompactionUsageState }; - sessionState.lastUsageState = { totalTokens: 95_000 }; - - // Fail the sequence-floor read that runs after the rewrite commits, so - // truncation returns Err with chat.jsonl already cut. - const internals = historyService as unknown as { - getArchiveTailMaxSequence: (workspaceId: string) => Promise; - }; - const realSeq = internals.getArchiveTailMaxSequence.bind(historyService); - const seqSpy = spyOn(internals, "getArchiveTailMaxSequence").mockImplementation( - async (wsId: string) => { - const window = await historyService.getHistoryFromLatestBoundary(workspaceId); - const stillHasTarget = window.success && window.data.some((msg) => msg.id === "user-2"); - if (!stillHasTarget) { - throw new Error("injected sequence-floor read failure"); - } - return realSeq(wsId); - } - ); - try { - const editResult = await session.sendMessage("second prompt, edited", { - model: "openai:gpt-4o", - agentId: "exec", - editMessageId: "user-2", - }); - expect(editResult.success).toBe(false); - expect(sessionState.lastUsageState).toBeUndefined(); - } finally { - seqSpy.mockRestore(); - } - - // The committed suffix cut created no duplicated prefix, so the retained - // assistant-1 usage stays valid and the next send must seed from it: a - // near-limit prefix stays monitored despite the Err. - const seenUsages = installUsageCapturingMonitor(session); - const result = await session.sendMessage("follow-up send", { - model: "openai:gpt-4o", - agentId: "exec", - }); - expect(result.success).toBe(true); - expect(seenUsages).toHaveLength(1); - const seeded = seenUsages[0] as AutoCompactionUsageState | undefined; - expect(seeded?.lastContextUsage).toBeDefined(); - - session.dispose(); - }); - - test("archived edit partial failure keeps seeding suppressed", async () => { - const workspaceId = "ws-archived-edit-partial-failure"; - const { session, historyService } = await createSessionHarness({ workspaceId }); - - const rows = [ - createMuxMessage("user-1", "user", "first prompt", { timestamp: Date.now() - 6_000 }), - createMuxMessage("assistant-1", "assistant", "first reply", { - timestamp: Date.now() - 5_000, - model: "openai:gpt-4o", - contextUsage: { inputTokens: 95_000, outputTokens: 100, totalTokens: 95_100 }, - }), - createMuxMessage("user-2", "user", "second prompt", { timestamp: Date.now() - 4_000 }), - // Boundary append seals the prefix into the archive. - createMuxMessage("boundary-1", "assistant", "Summary 1", { - timestamp: Date.now() - 3_000, - compactionBoundary: true, - compacted: "user", - compactionEpoch: 1, - }), - ]; - for (const row of rows) { - expect((await historyService.appendToHistory(workspaceId, row)).success).toBe(true); - } - - // Fail the archive removal so the pre-boundary edit commits chat.jsonl - // (the copied prefix) but returns Err with the archive still present. - const realRm = fs.rm; - const rmSpy = spyOn(fs, "rm").mockImplementation((...args: Parameters) => { - const target = String(args[0]); - if (target.includes(workspaceId) && target.includes("chat-archive")) { - return Promise.reject( - Object.assign(new Error("EACCES: permission denied"), { code: "EACCES" }) - ); - } - return realRm(...args); - }); - try { - const editResult = await session.sendMessage("second prompt, edited", { - model: "openai:gpt-4o", - agentId: "exec", - editMessageId: "user-2", - }); - expect(editResult.success).toBe(false); - } finally { - rmSpy.mockRestore(); - } - - // The archive now coexists with a duplicated prefix whose usage snapshot - // no longer measures the real payload; seeding must stay suppressed - // until fresh provider usage arrives. - const seenUsages = installUsageCapturingMonitor(session); - const result = await session.sendMessage("follow-up send", { - model: "openai:gpt-4o", - agentId: "exec", - }); - expect(result.success).toBe(true); - expect(seenUsages).toHaveLength(1); - expect(seenUsages[0]).toBeUndefined(); - - session.dispose(); - }); - - test("editing a pre-reset turn re-enables seeding for the restored prefix", async () => { - const workspaceId = "ws-pre-reset-edit-reenables-seeding"; - const { session, historyService } = await createSessionHarness({ workspaceId }); - - const rows = [ - createMuxMessage("user-1", "user", "first prompt", { timestamp: Date.now() - 5_000 }), - createMuxMessage("assistant-1", "assistant", "first reply", { - timestamp: Date.now() - 4_000, - model: "openai:gpt-4o", - contextUsage: { inputTokens: 95_000, outputTokens: 100, totalTokens: 95_100 }, - }), - createMuxMessage("user-2", "user", "second prompt", { timestamp: Date.now() - 3_000 }), - createMuxMessage("reset-boundary", "assistant", "context reset", { - timestamp: Date.now() - 2_000, - compacted: "user", - compactionBoundary: true, - compactionEpoch: 1, - }), - ]; - for (const row of rows) { - expect((await historyService.appendToHistory(workspaceId, row)).success).toBe(true); - } - - // The reset suppressed seeding (resetContext() calls clearUsageState()). - session.clearUsageState(); - - // Editing a pre-reset turn truncates the boundary away and restores the - // near-limit prefix as the active context. - const editResult = await session.sendMessage("second prompt, edited", { - model: "openai:gpt-4o", - agentId: "exec", - editMessageId: "user-2", - }); - expect(editResult.success).toBe(true); - - // The edit stream reported no usage; the next send must still seed from - // the restored assistant-1 row despite the earlier reset suppression. - const seenUsages = installUsageCapturingMonitor(session); - const result = await session.sendMessage("post-edit send", { - model: "openai:gpt-4o", - agentId: "exec", - }); - expect(result.success).toBe(true); - expect(seenUsages).toHaveLength(1); - const seeded = seenUsages[0] as AutoCompactionUsageState | undefined; - expect(seeded?.lastContextUsage).toBeDefined(); - - session.dispose(); - }); - - test("compaction completion keeps seeding enabled for the fresh boundary estimate", async () => { - const workspaceId = "ws-compaction-completion-keeps-seeding"; - - // Real per-test config: compaction persists pending post-compaction state - // under getSessionDir, which must not leak into other tests' sessions. - const { historyService, config, cleanup } = await createTestHistoryService(); - historyCleanup = cleanup; - - // Near-limit context so the real monitor converts the send into an - // on-send compaction (gpt-4o window 128K, default threshold 85%). - const appendUser = await historyService.appendToHistory( - workspaceId, - createMuxMessage("user-1", "user", "prompt", { timestamp: Date.now() - 2_000 }) - ); - expect(appendUser.success).toBe(true); - const appendAssistant = await historyService.appendToHistory( - workspaceId, - createMuxMessage("assistant-1", "assistant", "reply", { - timestamp: Date.now() - 1_000, - model: "openai:gpt-4o", - contextUsage: { inputTokens: 120_000, outputTokens: 100, totalTokens: 120_100 }, - }) - ); - expect(appendAssistant.success).toBe(true); - - const aiEmitter = new EventEmitter(); - let streamCallCount = 0; - const streamMessage = mock((_request: unknown) => { - streamCallCount += 1; - if (streamCallCount === 1) { - // The compaction stream: a prose summary whose post-compaction - // estimate is small (1_200 system + 800 summary tokens). - aiEmitter.emit("stream-end", { - type: "stream-end", - workspaceId, - messageId: "assistant-compaction-summary", - parts: [{ type: "text", text: "A concise prose summary of the conversation." }], - metadata: { - model: "openai:gpt-4o", - usage: { inputTokens: 120_000, outputTokens: 800, totalTokens: 120_800 }, - systemMessageTokens: 1_200, - }, - }); - } - // The follow-up stream (call 2) reports no usage. - return Promise.resolve(Ok(undefined)); - }); - - const aiService = Object.assign(aiEmitter, { - isStreaming: mock((_workspaceId: string) => false), - stopStream: mock((_workspaceId: string) => Promise.resolve(Ok(undefined))), - // Post-compaction attachment building reads workspace metadata; a - // failure result makes it skip the plan reference and continue. - getWorkspaceMetadata: mock((_workspaceId: string) => - Promise.resolve(Err("no metadata in test")) - ), - streamMessage: streamMessage as unknown as ( - ...args: Parameters - ) => Promise, - }) as unknown as AIService; - - const session = new AgentSession({ - workspaceId, - config, - historyService, - aiService, - initStateManager: new EventEmitter() as unknown as InitStateManager, - backgroundProcessManager: { - cleanup: mock((_workspaceId: string) => Promise.resolve()), - setMessageQueued: mock(() => undefined), - } as unknown as BackgroundProcessManager, - }); - - const result = await session.sendMessage("original request", { - model: "openai:gpt-4o", - agentId: "exec", - }); - expect(result.success).toBe(true); - - // Wait for compaction handling to collapse history and dispatch the follow-up. - const deadline = Date.now() + 1_500; - while (streamCallCount < 2 && Date.now() < deadline) { - await new Promise((resolve) => setTimeout(resolve, 10)); - } - expect(streamCallCount).toBe(2); - - // The follow-up reported no usage, so the next send must seed the boundary - // summary's fresh estimate rather than staying suppressed. - const seenUsages = installUsageCapturingMonitor(session); - const postResult = await session.sendMessage("post-compaction send", { - model: "openai:gpt-4o", - agentId: "exec", - }); - expect(postResult.success).toBe(true); - expect(seenUsages).toHaveLength(1); - const seeded = seenUsages[0] as AutoCompactionUsageState | undefined; - expect(seeded?.lastContextUsage).toBeDefined(); - expect(seeded?.totalTokens).toBe(2_000); - - session.dispose(); - }); - - test("heartbeat reset rollback re-enables usage seeding for the queued turn", async () => { - const workspaceId = "ws-heartbeat-rollback-reenables-seeding"; - const { session, historyService } = await createSessionHarness({ workspaceId }); - - const appendAssistant = await historyService.appendToHistory( - workspaceId, - createMuxMessage("assistant-near-limit", "assistant", "reply", { - timestamp: Date.now() - 1_000, - model: "openai:gpt-4o", - contextUsage: { inputTokens: 95_000, outputTokens: 100, totalTokens: 95_100 }, - }) - ); - expect(appendAssistant.success).toBe(true); - - const appendBoundary = await session.appendHeartbeatContextResetBoundary({ - boundaryText: "Heartbeat context reset boundary", - pendingFollowUp: { - text: "heartbeat follow-up", - model: "openai:gpt-4o", - agentId: "exec", - dispatchOptions: { requireIdle: true }, - }, - }); - expect(appendBoundary.success).toBe(true); - - // User input queued after the boundary forces the idle-only follow-up to roll back. - session.queueMessage("queued user input", { model: "openai:gpt-4o", agentId: "exec" }); - - const dispatched = await session.dispatchPendingCompactionFollowUpIfNeeded(); - expect(dispatched).toBe(false); - - const seenUsages = installUsageCapturingMonitor(session); - const result = await session.sendMessage("post-rollback send", { - model: "openai:gpt-4o", - agentId: "exec", - }); - expect(result.success).toBe(true); - expect(seenUsages).toHaveLength(1); - const seeded = seenUsages[0] as AutoCompactionUsageState | undefined; - expect(seeded?.lastContextUsage).toBeDefined(); - - session.dispose(); - }); - test("surfaces nested dispatch failures after mid-stream compaction interrupt", async () => { const workspaceId = "ws-auto-compaction-mid-stream-dispatch-failure"; From 1db6e953361086934bd48ba65cccab21250847d8 Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Sat, 8 Aug 2026 08:17:45 +0000 Subject: [PATCH 32/36] fix: make history truncation failures recoverable --- src/node/services/historyService.test.ts | 46 +++++ src/node/services/historyService.ts | 210 +++++++++++++++++------ 2 files changed, 208 insertions(+), 48 deletions(-) diff --git a/src/node/services/historyService.test.ts b/src/node/services/historyService.test.ts index c1713ac309..5741eb081c 100644 --- a/src/node/services/historyService.test.ts +++ b/src/node/services/historyService.test.ts @@ -2068,6 +2068,52 @@ describe("HistoryService", () => { } }); + async function expectWorkflowDisplayTruncationPreservesUsage(withResetBoundary: boolean) { + if (withResetBoundary) { + await appendNumberedMessages(service, wsId, 12); + await service.appendToHistory( + wsId, + createMuxMessage("reset-boundary", "assistant", "", { + contextBoundaryKind: CONTEXT_BOUNDARY_KINDS.RESET, + }) + ); + } + await service.appendToHistory( + wsId, + createMuxMessage( + "workflow-display", + "user", + `workflow trigger display ${"x".repeat(2_000)}`, + { muxMetadata: { type: "workflow-trigger-display", rawCommand: "/wf", runId: "run-1" } } + ) + ); + await service.appendToHistory(wsId, createMuxMessage("user-active", "user", "prompt")); + await service.appendToHistory( + wsId, + createMuxMessage("assistant-active", "assistant", "active reply", { + contextUsage: { inputTokens: 95_000, outputTokens: 100, totalTokens: 95_100 }, + model: "openai:gpt-4o", + }) + ); + + expect((await service.truncateHistory(wsId, 0.5)).success).toBe(true); + + const active = await service.getHistoryFromLatestBoundary(wsId); + expect(active.success).toBe(true); + if (active.success) { + expect(active.data.find((message) => message.id === "workflow-display")).toBeUndefined(); + const retainedAssistant = active.data.find((message) => message.id === "assistant-active"); + expect(retainedAssistant).toBeDefined(); + expect(retainedAssistant?.metadata?.contextUsage).toMatchObject({ inputTokens: 95_000 }); + } + } + + it("preserves active usage when uncompacted truncation removes only workflow display rows", () => + expectWorkflowDisplayTruncationPreservesUsage(false)); + + it("preserves active usage when truncation removes only workflow display rows", () => + expectWorkflowDisplayTruncationPreservesUsage(true)); + it("preserves active usage when truncation removes only sealed rows", async () => { await appendNumberedMessages(service, wsId, 8); await service.appendToHistory(wsId, boundaryMessage("boundary-1", 1)); diff --git a/src/node/services/historyService.ts b/src/node/services/historyService.ts index c1ea965d7c..a6600cef29 100644 --- a/src/node/services/historyService.ts +++ b/src/node/services/historyService.ts @@ -22,9 +22,11 @@ import { CONTEXT_BOUNDARY_KINDS } from "@/common/constants/contextBoundary"; import { findLatestContextBoundaryIndex, getContextBoundaryKind, + hasProviderEligibleMessages, isDurableCompactedMarker, isDurableContextBoundaryMarker, } from "@/common/utils/messages/compactionBoundary"; +import { filterWorkflowDisplayOnlyMessages } from "@/common/utils/workflowRunMessages"; import { CHAT_FILE_NAME, CHAT_ARCHIVE_FILE_NAME } from "@/common/constants/paths"; import { isRefusalFinishReason } from "@/common/utils/messages/refusalFinishReason"; import { getErrorMessage } from "@/common/utils/errors"; @@ -45,14 +47,15 @@ function hasDurableCompactionBoundary(metadata: MuxMetadata | undefined): boolea function prefixCutChangesActiveContext(messages: MuxMessage[], removeCount: number): boolean { const boundaryIndex = findLatestContextBoundaryIndex(messages); - if (boundaryIndex < 0) { - return removeCount > 0; - } const activeStart = - getContextBoundaryKind(messages[boundaryIndex]) === CONTEXT_BOUNDARY_KINDS.RESET - ? boundaryIndex + 1 - : boundaryIndex; - return removeCount > activeStart; + boundaryIndex < 0 + ? 0 + : getContextBoundaryKind(messages[boundaryIndex]) === CONTEXT_BOUNDARY_KINDS.RESET + ? boundaryIndex + 1 + : boundaryIndex; + return hasProviderEligibleMessages( + filterWorkflowDisplayOnlyMessages(messages.slice(activeStart, removeCount)) + ); } function stripContextUsage(message: MuxMessage): MuxMessage { @@ -1082,18 +1085,18 @@ export class HistoryService { if (this.sealedRotationChecked.has(workspaceId)) { return; } - this.sealedRotationChecked.add(workspaceId); try { - // Cheap unlocked probe first so the common no-op case takes no lock. - const offset = await this.findLastBoundaryByteOffset(this.getChatHistoryPath(workspaceId)); - if (offset === null || offset === 0) { - return; - } - await this.fileLocks.withLock(workspaceId, () => - this.rotateSealedHistoryUnlocked(workspaceId) - ); + await this.fileLocks.withLock(workspaceId, async () => { + await fs.rm(`${this.getChatArchivePath(workspaceId)}.truncate`, { force: true }); + const offset = await this.findLastBoundaryByteOffset(this.getChatHistoryPath(workspaceId)); + if (offset !== null && offset !== 0) { + await this.rotateSealedHistoryUnlocked(workspaceId); + } + }); + this.sealedRotationChecked.add(workspaceId); } catch (error) { + this.sealedRotationChecked.delete(workspaceId); // Rotation is an optimization — reads remain correct on unrotated files. log.warn("Failed to rotate sealed chat history", { workspaceId, @@ -1900,15 +1903,30 @@ export class HistoryService { const historyPath = this.getChatHistoryPath(workspaceId); const archivePath = this.getChatArchivePath(workspaceId); - await fs.rm(historyPath, { force: true }); + const originalArchive = await fs.readFile(archivePath, "utf-8"); await writeFileAtomic( archivePath, this.serializeHistoryEntries(truncatedMessages.map(stripContextUsage), workspaceId) ); - await fs.rename(archivePath, historyPath); + try { + await fs.rm(historyPath, { force: true }); + } catch (error) { + await writeFileAtomic(archivePath, originalArchive); + throw error; + } + let retainedPath = historyPath; + try { + await fs.rename(archivePath, historyPath); + } catch (error) { + retainedPath = archivePath; + log.warn("Keeping archived truncation result in the archive file", { + workspaceId, + error, + }); + } try { await writeFileAtomic( - historyPath, + retainedPath, this.serializeHistoryEntries(truncatedMessages, workspaceId) ); } catch (error) { @@ -1971,23 +1989,95 @@ export class HistoryService { const historyPath = this.getChatHistoryPath(workspaceId); const archivePath = this.getChatArchivePath(workspaceId); - // Fast path: 100% truncation = delete entire history (active + sealed archive) - if (percentage >= 1.0) { - // Need sequence numbers for return value before deleting - const messages = [ - ...(await this.readArchivedHistory(workspaceId)), - ...(await this.readChatHistory(workspaceId)), - ]; + const readExistingFile = async (filePath: string): Promise => { + try { + return await fs.readFile(filePath, "utf-8"); + } catch (error) { + if (error && typeof error === "object" && "code" in error && error.code === "ENOENT") { + return null; + } + throw error; + } + }; + const archiveTombstonePath = `${archivePath}.truncate`; + const moveArchiveAside = async (): Promise => { + await fs.rm(archiveTombstonePath, { force: true }); + try { + await fs.rename(archivePath, archiveTombstonePath); + return true; + } catch (error) { + if (error && typeof error === "object" && "code" in error && error.code === "ENOENT") { + return false; + } + throw error; + } + }; + const restoreArchive = async (moved: boolean): Promise => { + if (moved) { + await fs.rename(archiveTombstonePath, archivePath); + } + }; + const discardArchiveTombstone = async (moved: boolean): Promise => { + if (!moved) { + return; + } + try { + await fs.rm(archiveTombstonePath, { force: true }); + } catch (error) { + this.sealedRotationChecked.delete(workspaceId); + log.warn("Failed to remove truncated history archive", { workspaceId, error }); + } + }; + + const removeAllMessages = async ( + archivedMessages: MuxMessage[], + chatMessages: MuxMessage[] + ): Promise => { + const messages = [...archivedMessages, ...chatMessages]; const deletedSequences = messages .map((msg) => msg.metadata?.historySequence) .filter((s): s is number => isNonNegativeInteger(s)); + const archiveRemovalChangesContext = prefixCutChangesActiveContext( + messages, + archivedMessages.length + ); + const originalChat = archiveRemovalChangesContext + ? await readExistingFile(historyPath) + : null; + if (archiveRemovalChangesContext) { + await writeFileAtomic( + historyPath, + this.serializeHistoryEntries(chatMessages.map(stripContextUsage), workspaceId) + ); + } - await fs.rm(historyPath, { force: true }); - await fs.rm(archivePath, { force: true }); - - // Reset sequence counter when clearing history + let archiveMoved = false; + try { + archiveMoved = await moveArchiveAside(); + await fs.rm(historyPath, { force: true }); + } catch (error) { + try { + await restoreArchive(archiveMoved); + if (originalChat !== null) { + await writeFileAtomic(historyPath, originalChat); + } + } catch (rollbackError) { + log.error("Failed to roll back history clear", { workspaceId, error: rollbackError }); + } + throw error; + } + await discardArchiveTombstone(archiveMoved); this.sequenceCounters.set(workspaceId, 0); - return Ok(deletedSequences); + return deletedSequences; + }; + + if (percentage >= 1.0) { + return Ok( + await removeAllMessages( + await this.readArchivedHistory(workspaceId), + await this.readChatHistory(workspaceId) + ) + ); } // Structural rewrite requires full history content (oldest rows live in @@ -2036,13 +2126,7 @@ export class HistoryService { // If we're removing all messages, use fast path if (removeCount >= messages.length) { - await fs.rm(historyPath, { force: true }); - await fs.rm(archivePath, { force: true }); - this.sequenceCounters.set(workspaceId, 0); - const deletedSequences = messages - .map((msg) => msg.metadata?.historySequence) - .filter((s): s is number => isNonNegativeInteger(s)); - return Ok(deletedSequences); + return Ok(await removeAllMessages(archivedMessages, chatMessages)); } const activeContextChanged = prefixCutChangesActiveContext(messages, removeCount); @@ -2058,24 +2142,54 @@ export class HistoryService { const remainingArchive = remainingMessages.slice(0, remainingArchiveCount); const remainingChat = remainingMessages.slice(remainingArchiveCount); - if (activeContextChanged && archivedMessages.length > 0) { + const archiveCutChangesContext = prefixCutChangesActiveContext( + messages, + Math.min(removeCount, archivedMessages.length) + ); + const originalChat = archiveCutChangesContext ? await readExistingFile(historyPath) : null; + if (archiveCutChangesContext) { await writeFileAtomic( historyPath, this.serializeHistoryEntries(chatMessages.map(stripContextUsage), workspaceId) ); } + if (remainingArchive.length > 0) { - await writeFileAtomic( - archivePath, - this.serializeHistoryEntries(remainingArchive, workspaceId) - ); + try { + await writeFileAtomic( + archivePath, + this.serializeHistoryEntries(remainingArchive, workspaceId) + ); + } catch (error) { + if (originalChat !== null) { + await writeFileAtomic(historyPath, originalChat); + } + throw error; + } } else { - await fs.rm(archivePath, { force: true }); + let archiveMoved = false; + try { + archiveMoved = await moveArchiveAside(); + await writeFileAtomic( + historyPath, + this.serializeHistoryEntries(remainingChat, workspaceId) + ); + } catch (error) { + try { + await restoreArchive(archiveMoved); + if (originalChat !== null) { + await writeFileAtomic(historyPath, originalChat); + } + } catch (rollbackError) { + log.error("Failed to roll back history truncation", { + workspaceId, + error: rollbackError, + }); + } + throw error; + } + await discardArchiveTombstone(archiveMoved); } - await writeFileAtomic( - historyPath, - this.serializeHistoryEntries(remainingChat, workspaceId) - ); this.sealedRotationChecked.delete(workspaceId); // Update sequence counter to continue from where we are. From f99fc2b4ec28e0dfcecd719d0856a3e4201b1b0c Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Sat, 8 Aug 2026 09:38:48 +0000 Subject: [PATCH 33/36] =?UTF-8?q?=F0=9F=A4=96=20fix:=20recover=20interrupt?= =?UTF-8?q?ed=20history=20truncations?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Use target-state hashes to distinguish committed and interrupted archive rewrites, recover legacy tombstones, and serialize logical reads with recovery. Generated with mux • Model: openai:gpt-5.6-sol • Thinking: xhigh --- src/node/services/historyService.test.ts | 78 +- src/node/services/historyService.ts | 1285 ++++++++++++---------- 2 files changed, 776 insertions(+), 587 deletions(-) diff --git a/src/node/services/historyService.test.ts b/src/node/services/historyService.test.ts index 5741eb081c..f927cf0d8d 100644 --- a/src/node/services/historyService.test.ts +++ b/src/node/services/historyService.test.ts @@ -5,6 +5,7 @@ import type { Config } from "@/node/config"; import { createTestHistoryService } from "./testHistoryService"; 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 path from "path"; @@ -1279,18 +1280,15 @@ describe("HistoryService", () => { const scanStarted = new Promise((resolve) => { markScanStarted = resolve; }); - const originalIterateFullHistory: HistoryService["iterateFullHistory"] = - service.iterateFullHistory.bind(service); - const blockingIterateFullHistory: HistoryService["iterateFullHistory"] = async ( - workspaceIdArg, - direction, - visitor - ) => { + const internal = service as unknown as { + iterateFullHistoryUnlocked: HistoryService["iterateFullHistory"]; + }; + const originalIterateFullHistory = internal.iterateFullHistoryUnlocked.bind(service); + internal.iterateFullHistoryUnlocked = async (workspaceIdArg, direction, visitor) => { markScanStarted(); await scanReleased; return originalIterateFullHistory(workspaceIdArg, direction, visitor); }; - service.iterateFullHistory = blockingIterateFullHistory; const scan = service.getMessagesForCompactionEpoch(workspaceId, { workspaceId, @@ -2136,6 +2134,70 @@ describe("HistoryService", () => { } }); + it("restores a markerless archive tombstone left by an older truncation", async () => { + await appendNumberedMessages(service, wsId, 3); + await service.appendToHistory(wsId, boundaryMessage("boundary-1", 1)); + await service.appendToHistory(wsId, createMuxMessage("post-0", "user", "after")); + await fs.rename(archivePath(wsId), `${archivePath(wsId)}.truncate`); + + const restarted = new HistoryService(config); + const full = await collectFullHistory(restarted, wsId); + expect(full.map((message) => message.id)).toEqual([ + "msg-0", + "msg-1", + "msg-2", + "boundary-1", + "post-0", + ]); + }); + + it("restores an interrupted archive tombstone when only the final chat matches", async () => { + await appendNumberedMessages(service, wsId, 3); + await service.appendToHistory(wsId, boundaryMessage("boundary-1", 1)); + await service.appendToHistory(wsId, createMuxMessage("post-0", "user", "after")); + const chatContents = await fs.readFile(chatPath(wsId), "utf-8"); + const hash = (contents: string) => createHash("sha256").update(contents).digest("hex"); + await fs.writeFile( + `${archivePath(wsId)}.truncate.json`, + JSON.stringify({ + phase: "prepared", + finalArchiveHash: hash("replacement archive\n"), + finalChatHash: hash(chatContents), + }) + ); + await fs.rename(archivePath(wsId), `${archivePath(wsId)}.truncate`); + + const restarted = new HistoryService(config); + const full = await collectFullHistory(restarted, wsId); + expect(full.map((message) => message.id)).toEqual([ + "msg-0", + "msg-1", + "msg-2", + "boundary-1", + "post-0", + ]); + }); + + it("does not restore a committed archive tombstone before appending", async () => { + await appendNumberedMessages(service, wsId, 3); + await service.appendToHistory(wsId, boundaryMessage("boundary-1", 1)); + await service.appendToHistory(wsId, createMuxMessage("post-0", "user", "after")); + await fs.writeFile( + `${archivePath(wsId)}.truncate.json`, + JSON.stringify({ finalArchiveHash: null, finalChatHash: null }) + ); + await fs.rename(archivePath(wsId), `${archivePath(wsId)}.truncate`); + await fs.rm(chatPath(wsId)); + + const restarted = new HistoryService(config); + const message = createMuxMessage("new-msg", "user", "fresh"); + expect((await restarted.appendToHistory(wsId, message)).success).toBe(true); + expect(message.metadata?.historySequence).toBe(0); + expect((await collectFullHistory(restarted, wsId)).map((item) => item.id)).toEqual([ + "new-msg", + ]); + }); + it("hasHistory sees archive-only workspaces", async () => { await appendNumberedMessages(service, wsId, 1); await service.appendToHistory(wsId, boundaryMessage("boundary-1", 1)); diff --git a/src/node/services/historyService.ts b/src/node/services/historyService.ts index a6600cef29..1fdcb6a99e 100644 --- a/src/node/services/historyService.ts +++ b/src/node/services/historyService.ts @@ -1,3 +1,4 @@ +import { createHash } from "node:crypto"; import * as fs from "fs/promises"; import * as path from "path"; import writeFileAtomic from "write-file-atomic"; @@ -202,6 +203,217 @@ export class HistoryService { return path.join(this.config.getSessionDir(workspaceId), this.CHAT_ARCHIVE_FILE); } + private getTruncateTransactionPath(workspaceId: string): string { + return `${this.getChatArchivePath(workspaceId)}.truncate.json`; + } + + private async readExistingFile(filePath: string): Promise { + try { + return await fs.readFile(filePath, "utf-8"); + } catch (error) { + if (error && typeof error === "object" && "code" in error && error.code === "ENOENT") { + return null; + } + throw error; + } + } + + private historyContentsHash(contents: string): string { + return createHash("sha256").update(contents).digest("hex"); + } + + private parseTruncateTransaction(contents: string): { + finalArchiveHash: string | null; + finalChatHash: string | null; + } | null { + try { + const parsed: unknown = JSON.parse(contents); + if (parsed === null || typeof parsed !== "object") { + return null; + } + const marker = parsed as Record; + const finalArchiveHash = marker.finalArchiveHash; + const finalChatHash = marker.finalChatHash; + if ( + (finalArchiveHash !== null && typeof finalArchiveHash !== "string") || + (finalChatHash !== null && typeof finalChatHash !== "string") + ) { + return null; + } + return { finalArchiveHash, finalChatHash }; + } catch { + return null; + } + } + + private historyContentsMatch(contents: string | null, hash: string | null): boolean { + return hash === null + ? contents === null + : contents !== null && this.historyContentsHash(contents) === hash; + } + + private async recoverTruncateTransactionUnlocked(workspaceId: string): Promise { + const archivePath = this.getChatArchivePath(workspaceId); + const archiveTombstonePath = `${archivePath}.truncate`; + const tombstoneExists = await fs.stat(archiveTombstonePath).then( + () => true, + (error: unknown) => { + if (error && typeof error === "object" && "code" in error && error.code === "ENOENT") { + return false; + } + throw error; + } + ); + const markerPath = this.getTruncateTransactionPath(workspaceId); + const markerContents = await this.readExistingFile(markerPath); + if (markerContents === null) { + if (!tombstoneExists) { + return false; + } + const archiveExists = (await this.readExistingFile(archivePath)) !== null; + if (archiveExists) { + await fs.rm(archiveTombstonePath); + } else { + await fs.rename(archiveTombstonePath, archivePath); + } + return false; + } + + const marker = this.parseTruncateTransaction(markerContents); + if (!tombstoneExists) { + await fs.rm(markerPath, { force: true }); + if (marker === null) { + return false; + } + const archiveContents = await this.readExistingFile(archivePath); + const chatContents = await this.readExistingFile(this.getChatHistoryPath(workspaceId)); + return ( + this.historyContentsMatch(archiveContents, marker.finalArchiveHash) && + this.historyContentsMatch(chatContents, marker.finalChatHash) + ); + } + + if (marker !== null) { + const archiveContents = await this.readExistingFile(archivePath); + const chatContents = await this.readExistingFile(this.getChatHistoryPath(workspaceId)); + const committed = + this.historyContentsMatch(archiveContents, marker.finalArchiveHash) && + this.historyContentsMatch(chatContents, marker.finalChatHash); + if (committed) { + await fs.rm(archiveTombstonePath); + await fs.rm(markerPath, { force: true }); + return true; + } + } + + await fs.rm(archivePath, { force: true }); + await fs.rename(archiveTombstonePath, archivePath); + await fs.rm(markerPath, { force: true }); + return false; + } + + private async withRecoveredHistoryLock( + workspaceId: string, + operation: () => Promise + ): Promise { + return this.fileLocks.withLock(workspaceId, async () => { + await this.recoverTruncateTransactionUnlocked(workspaceId); + return operation(); + }); + } + + private async withRecoveredHistoryResultLock( + workspaceId: string, + errorPrefix: string, + operation: () => Promise> + ): Promise> { + try { + return await this.withRecoveredHistoryLock(workspaceId, operation); + } catch (error) { + return Err(`${errorPrefix}: ${getErrorMessage(error)}`); + } + } + + private async rewriteHistoryFilesUnlocked( + workspaceId: string, + finalArchiveContents: string | null, + finalChatContents: string | null + ): Promise { + const archivePath = this.getChatArchivePath(workspaceId); + const archiveTombstonePath = `${archivePath}.truncate`; + const markerPath = this.getTruncateTransactionPath(workspaceId); + const archiveExists = await fs.stat(archivePath).then( + () => true, + (error: unknown) => { + if (error && typeof error === "object" && "code" in error && error.code === "ENOENT") { + return false; + } + throw error; + } + ); + if (!archiveExists) { + assert(finalArchiveContents === null, "cannot replace a missing history archive"); + if (finalChatContents === null) { + await fs.rm(this.getChatHistoryPath(workspaceId), { force: true }); + } else { + await writeFileAtomic(this.getChatHistoryPath(workspaceId), finalChatContents); + } + return; + } + + await writeFileAtomic( + markerPath, + JSON.stringify({ + finalArchiveHash: + finalArchiveContents === null ? null : this.historyContentsHash(finalArchiveContents), + finalChatHash: + finalChatContents === null ? null : this.historyContentsHash(finalChatContents), + }) + ); + try { + await fs.rename(archivePath, archiveTombstonePath); + } catch (error) { + await fs.rm(markerPath, { force: true }); + throw error; + } + + try { + if (finalArchiveContents !== null) { + await writeFileAtomic(archivePath, finalArchiveContents); + } + if (finalChatContents === null) { + await fs.rm(this.getChatHistoryPath(workspaceId), { force: true }); + } else { + await writeFileAtomic(this.getChatHistoryPath(workspaceId), finalChatContents); + } + } catch (error) { + let committed = false; + try { + committed = await this.recoverTruncateTransactionUnlocked(workspaceId); + } catch (recoveryError) { + log.error("Failed to recover history truncation after write failure", { + workspaceId, + error: recoveryError, + }); + } + if (!committed) { + throw error; + } + return; + } + + try { + await fs.rm(archiveTombstonePath); + await fs.rm(markerPath, { force: true }); + } catch (error) { + this.sealedRotationChecked.delete(workspaceId); + log.warn("History truncation cleanup deferred to the next operation", { + workspaceId, + error, + }); + } + } + private getPartialPath(workspaceId: string): string { return path.join(this.config.getSessionDir(workspaceId), this.PARTIAL_FILE); } @@ -726,6 +938,16 @@ export class HistoryService { workspaceId: string, direction: "forward" | "backward", visitor: (messages: MuxMessage[]) => boolean | void | Promise + ): Promise> { + return this.withRecoveredHistoryResultLock(workspaceId, "Failed to iterate history", () => + this.iterateFullHistoryUnlocked(workspaceId, direction, visitor) + ); + } + + private async iterateFullHistoryUnlocked( + workspaceId: string, + direction: "forward" | "backward", + visitor: (messages: MuxMessage[]) => boolean | void | Promise ): Promise> { const chatPath = this.getChatHistoryPath(workspaceId); const archivePath = this.getChatArchivePath(workspaceId); @@ -832,6 +1054,15 @@ export class HistoryService { "hasHistoryBeforeSequence requires a non-negative integer" ); + return this.withRecoveredHistoryLock(workspaceId, () => + this.hasHistoryBeforeSequenceUnlocked(workspaceId, beforeHistorySequence) + ); + } + + private async hasHistoryBeforeSequenceUnlocked( + workspaceId: string, + beforeHistorySequence: number + ): Promise { let hasOlder = false; const visitor = (messages: MuxMessage[]): boolean | void => { for (const message of messages) { @@ -847,8 +1078,6 @@ export class HistoryService { } }; - // Newest rows live in chat.jsonl; continue into the sealed archive only - // when the active file has no older rows. const completed = await this.iterateBackward(this.getChatHistoryPath(workspaceId), visitor); if (completed && !hasOlder) { await this.iterateBackward(this.getChatArchivePath(workspaceId), visitor); @@ -876,7 +1105,7 @@ export class HistoryService { "getHistoryBoundaryWindow requires beforeHistorySequence to be a non-negative integer" ); - try { + const operation = async (): Promise> => { // Scan boundaries newest→oldest and pick the first window that has rows older // than the cursor. Boundaries newer than the rotation point live in chat.jsonl; // older ones live in the sealed archive. @@ -906,7 +1135,10 @@ export class HistoryService { "window messages filtered by historySequence must include a sequence" ); - const hasOlder = await this.hasHistoryBeforeSequence(workspaceId, oldestWindowSequence); + const hasOlder = await this.hasHistoryBeforeSequenceUnlocked( + workspaceId, + oldestWindowSequence + ); return Ok({ messages: windowMessages, hasOlder }); } } @@ -931,8 +1163,15 @@ export class HistoryService { "pre-boundary messages filtered by historySequence must include a sequence" ); - const hasOlder = await this.hasHistoryBeforeSequence(workspaceId, oldestWindowSequence); + const hasOlder = await this.hasHistoryBeforeSequenceUnlocked( + workspaceId, + oldestWindowSequence + ); return Ok({ messages: preBoundaryMessages, hasOlder }); + }; + + try { + return await this.withRecoveredHistoryLock(workspaceId, operation); } catch (error) { const message = getErrorMessage(error); return Err(`Failed to read history boundary window: ${message}`); @@ -965,8 +1204,8 @@ export class HistoryService { // The just-compacted epoch can straddle chat-archive.jsonl and chat.jsonl after // sealed-history rotation, so scan the full logical history under the workspace // lock; otherwise a concurrent boundary rotation can move rows between files mid-scan. - const iteration = await this.fileLocks.withLock(workspaceId, () => - this.iterateFullHistory(workspaceId, "forward", (chunk) => { + const iteration = await this.withRecoveredHistoryLock(workspaceId, () => + this.iterateFullHistoryUnlocked(workspaceId, "forward", (chunk) => { for (const message of chunk) { const sequence = message.metadata?.historySequence; if (!isNonNegativeInteger(sequence)) continue; @@ -1017,10 +1256,10 @@ export class HistoryService { * that only needs the active compaction epoch. */ async getHistoryFromLatestBoundary(workspaceId: string, skip = 0): Promise> { - try { + const operation = async (): Promise> => { // One-time lazy migration: seal any pre-boundary prefix left in chat.jsonl // by older builds so this read (and every later one) stays O(active epoch). - await this.ensureSealedHistoryRotated(workspaceId); + await this.ensureSealedHistoryRotatedUnlocked(workspaceId); const chatPath = this.getChatHistoryPath(workspaceId); const archivePath = this.getChatArchivePath(workspaceId); @@ -1061,6 +1300,10 @@ export class HistoryService { const archived = await this.readArchivedHistory(workspaceId); const active = await this.readChatHistory(workspaceId); return Ok([...archived, ...active]); + }; + + try { + return await this.withRecoveredHistoryLock(workspaceId, operation); } catch (error) { const message = getErrorMessage(error); return Err(`Failed to read history from boundary: ${message}`); @@ -1081,19 +1324,16 @@ export class HistoryService { * lazily migrates files produced before rotation existed (or by crashes * between boundary write and rotation). */ - private async ensureSealedHistoryRotated(workspaceId: string): Promise { + private async ensureSealedHistoryRotatedUnlocked(workspaceId: string): Promise { if (this.sealedRotationChecked.has(workspaceId)) { return; } try { - await this.fileLocks.withLock(workspaceId, async () => { - await fs.rm(`${this.getChatArchivePath(workspaceId)}.truncate`, { force: true }); - const offset = await this.findLastBoundaryByteOffset(this.getChatHistoryPath(workspaceId)); - if (offset !== null && offset !== 0) { - await this.rotateSealedHistoryUnlocked(workspaceId); - } - }); + const offset = await this.findLastBoundaryByteOffset(this.getChatHistoryPath(workspaceId)); + if (offset !== null && offset !== 0) { + await this.rotateSealedHistoryUnlocked(workspaceId); + } this.sealedRotationChecked.add(workspaceId); } catch (error) { this.sealedRotationChecked.delete(workspaceId); @@ -1176,20 +1416,29 @@ export class HistoryService { * Continues into the sealed archive when the active epoch has fewer than N rows. */ async getLastMessages(workspaceId: string, n: number): Promise> { - try { - const messages = await this.readLastMessagesFromFile(this.getChatHistoryPath(workspaceId), n); - if (messages.length < n) { - const archived = await this.readLastMessagesFromFile( - this.getChatArchivePath(workspaceId), - n - messages.length - ); - return Ok([...archived, ...messages]); + return this.withRecoveredHistoryResultLock( + workspaceId, + `Failed to read last ${n} messages`, + async () => { + try { + const messages = await this.readLastMessagesFromFile( + this.getChatHistoryPath(workspaceId), + n + ); + if (messages.length < n) { + const archived = await this.readLastMessagesFromFile( + this.getChatArchivePath(workspaceId), + n - messages.length + ); + return Ok([...archived, ...messages]); + } + return Ok(messages); + } catch (error) { + const message = getErrorMessage(error); + return Err(`Failed to read last ${n} messages: ${message}`); + } } - return Ok(messages); - } catch (error) { - const message = getErrorMessage(error); - return Err(`Failed to read last ${n} messages: ${message}`); - } + ); } /** @@ -1197,20 +1446,22 @@ export class HistoryService { * Much cheaper than iterateFullHistory() when only an emptiness check is needed. */ async hasHistory(workspaceId: string): Promise { - for (const filePath of [ - this.getChatHistoryPath(workspaceId), - this.getChatArchivePath(workspaceId), - ]) { - try { - const stat = await fs.stat(filePath); - if (stat.size > 0) { - return true; + return this.withRecoveredHistoryLock(workspaceId, async () => { + for (const filePath of [ + this.getChatHistoryPath(workspaceId), + this.getChatArchivePath(workspaceId), + ]) { + try { + const stat = await fs.stat(filePath); + if (stat.size > 0) { + return true; + } + } catch { + // Missing file — keep checking. } - } catch { - // Missing file — keep checking. } - } - return false; + return false; + }); } /** @@ -1555,15 +1806,19 @@ export class HistoryService { } async appendToHistory(workspaceId: string, message: MuxMessage): Promise> { - return this.fileLocks.withLock(workspaceId, async () => { - const result = await this._appendToHistoryUnlocked(workspaceId, message); - if (result.success) { - // A new durable boundary seals the previous epoch — rotate it out of - // chat.jsonl so subsequent reads/rewrites stay O(active epoch). - await this.rotateAfterBoundaryWriteUnlocked(workspaceId, message); + return this.withRecoveredHistoryResultLock( + workspaceId, + "Failed to append history", + async () => { + const result = await this._appendToHistoryUnlocked(workspaceId, message); + if (result.success) { + // A new durable boundary seals the previous epoch — rotate it out of + // chat.jsonl so subsequent reads/rewrites stay O(active epoch). + await this.rotateAfterBoundaryWriteUnlocked(workspaceId, message); + } + return result; } - return result; - }); + ); } /** @@ -1575,76 +1830,80 @@ export class HistoryService { * never in the sealed archive. */ async updateHistory(workspaceId: string, message: MuxMessage): Promise> { - return this.fileLocks.withLock(workspaceId, async () => { - try { - const historyPath = this.getChatHistoryPath(workspaceId); - - // Read the active epoch — structural rewrite requires full file content - const messages = await this.readChatHistory(workspaceId); - const targetSequence = message.metadata?.historySequence; + return this.withRecoveredHistoryResultLock( + workspaceId, + "Failed to update history", + async () => { + try { + const historyPath = this.getChatHistoryPath(workspaceId); - if (targetSequence === undefined) { - return Err("Cannot update message without historySequence"); - } + // Read the active epoch — structural rewrite requires full file content + const messages = await this.readChatHistory(workspaceId); + const targetSequence = message.metadata?.historySequence; - assert( - isNonNegativeInteger(targetSequence), - "updateHistory requires historySequence to be a non-negative integer" - ); + if (targetSequence === undefined) { + return Err("Cannot update message without historySequence"); + } - // Find and replace the message with matching historySequence - let found = false; - let persistedMessage: MuxMessage | undefined; - for (let i = 0; i < messages.length; i++) { - if (messages[i].metadata?.historySequence === targetSequence) { - const existingMessage = messages[i]; - assert(existingMessage, "updateHistory matched message must exist"); - - // Preserve compaction boundary metadata during late in-place rewrites. - // Compaction may update an assistant row first, then a late stream rewrite can - // update that same historySequence and accidentally drop compaction markers. - const preservedCompactionMetadata = getCompactionMetadataToPreserve( - workspaceId, - existingMessage, - message - ); + assert( + isNonNegativeInteger(targetSequence), + "updateHistory requires historySequence to be a non-negative integer" + ); - // Preserve the historySequence, update everything else. - messages[i] = { - ...message, - metadata: { - ...message.metadata, - ...(preservedCompactionMetadata ?? {}), - historySequence: targetSequence, - }, - }; - persistedMessage = messages[i]; - found = true; - break; + // Find and replace the message with matching historySequence + let found = false; + let persistedMessage: MuxMessage | undefined; + for (let i = 0; i < messages.length; i++) { + if (messages[i].metadata?.historySequence === targetSequence) { + const existingMessage = messages[i]; + assert(existingMessage, "updateHistory matched message must exist"); + + // Preserve compaction boundary metadata during late in-place rewrites. + // Compaction may update an assistant row first, then a late stream rewrite can + // update that same historySequence and accidentally drop compaction markers. + const preservedCompactionMetadata = getCompactionMetadataToPreserve( + workspaceId, + existingMessage, + message + ); + + // Preserve the historySequence, update everything else. + messages[i] = { + ...message, + metadata: { + ...message.metadata, + ...(preservedCompactionMetadata ?? {}), + historySequence: targetSequence, + }, + }; + persistedMessage = messages[i]; + found = true; + break; + } } - } - if (!found || !persistedMessage) { - return Err(`No message found with historySequence ${targetSequence}`); - } + if (!found || !persistedMessage) { + return Err(`No message found with historySequence ${targetSequence}`); + } - // Rewrite entire file - const historyEntries = this.serializeHistoryEntries(messages, workspaceId); + // Rewrite entire file + const historyEntries = this.serializeHistoryEntries(messages, workspaceId); - // Atomic write prevents corruption if app crashes mid-write - await writeFileAtomic(historyPath, historyEntries); + // Atomic write prevents corruption if app crashes mid-write + await writeFileAtomic(historyPath, historyEntries); - // Compaction updates the streamed summary row in-place with boundary - // metadata — seal the previous epoch once that lands. Check the persisted - // row (not the incoming message) so preserved boundary metadata counts. - await this.rotateAfterBoundaryWriteUnlocked(workspaceId, persistedMessage); + // Compaction updates the streamed summary row in-place with boundary + // metadata — seal the previous epoch once that lands. Check the persisted + // row (not the incoming message) so preserved boundary metadata counts. + await this.rotateAfterBoundaryWriteUnlocked(workspaceId, persistedMessage); - return Ok(undefined); - } catch (error) { - const message = getErrorMessage(error); - return Err(`Failed to update history: ${message}`); + return Ok(undefined); + } catch (error) { + const message = getErrorMessage(error); + return Err(`Failed to update history: ${message}`); + } } - }); + ); } /** @@ -1656,55 +1915,59 @@ export class HistoryService { const ids = new Set(messageIds); assert(ids.size === messageIds.length, "deleteMessages requires unique message IDs"); - return this.fileLocks.withLock(workspaceId, async () => { - try { - const messages = await this.readChatHistory(workspaceId); - const foundIds = new Set( - messages.filter((message) => ids.has(message.id)).map((message) => message.id) - ); - const missingIds = messageIds.filter((messageId) => !foundIds.has(messageId)); - if (missingIds.length > 0) { - return Err(`Messages not found in active history: ${missingIds.join(", ")}`); - } + return this.withRecoveredHistoryResultLock( + workspaceId, + "Failed to delete messages", + async () => { + try { + const messages = await this.readChatHistory(workspaceId); + const foundIds = new Set( + messages.filter((message) => ids.has(message.id)).map((message) => message.id) + ); + const missingIds = messageIds.filter((messageId) => !foundIds.has(messageId)); + if (missingIds.length > 0) { + return Err(`Messages not found in active history: ${missingIds.join(", ")}`); + } - const filteredMessages = messages.filter((message) => !ids.has(message.id)); - await writeFileAtomic( - this.getChatHistoryPath(workspaceId), - this.serializeHistoryEntries(filteredMessages, workspaceId) - ); + const filteredMessages = messages.filter((message) => !ids.has(message.id)); + await writeFileAtomic( + this.getChatHistoryPath(workspaceId), + this.serializeHistoryEntries(filteredMessages, workspaceId) + ); - const maxSeq = filteredMessages.reduce((max, message) => { - const sequence = message.metadata?.historySequence; - if (sequence === undefined) return max; - if (!isNonNegativeInteger(sequence)) { - log.warn( - "Ignoring malformed persisted historySequence while updating sequence counter after batch delete", - { - workspaceId, - messageId: message.id, - historySequence: sequence, - } - ); - return max; + const maxSeq = filteredMessages.reduce((max, message) => { + const sequence = message.metadata?.historySequence; + if (sequence === undefined) return max; + if (!isNonNegativeInteger(sequence)) { + log.warn( + "Ignoring malformed persisted historySequence while updating sequence counter after batch delete", + { + workspaceId, + messageId: message.id, + historySequence: sequence, + } + ); + return max; + } + return sequence > max ? sequence : max; + }, -1); + const archiveMaxSeq = await this.getArchiveTailMaxSequence(workspaceId); + const nextSeq = Math.max(maxSeq, archiveMaxSeq) + 1; + assert( + isNonNegativeInteger(nextSeq), + "next history sequence counter after batch delete must be a non-negative integer" + ); + const currentCounter = this.sequenceCounters.get(workspaceId); + if (currentCounter === undefined || currentCounter < nextSeq) { + this.sequenceCounters.set(workspaceId, nextSeq); } - return sequence > max ? sequence : max; - }, -1); - const archiveMaxSeq = await this.getArchiveTailMaxSequence(workspaceId); - const nextSeq = Math.max(maxSeq, archiveMaxSeq) + 1; - assert( - isNonNegativeInteger(nextSeq), - "next history sequence counter after batch delete must be a non-negative integer" - ); - const currentCounter = this.sequenceCounters.get(workspaceId); - if (currentCounter === undefined || currentCounter < nextSeq) { - this.sequenceCounters.set(workspaceId, nextSeq); - } - return Ok(undefined); - } catch (error) { - return Err(`Failed to delete messages: ${getErrorMessage(error)}`); + return Ok(undefined); + } catch (error) { + return Err(`Failed to delete messages: ${getErrorMessage(error)}`); + } } - }); + ); } /** @@ -1714,79 +1977,83 @@ export class HistoryService { * messages may already have been appended. */ async deleteMessage(workspaceId: string, messageId: string): Promise> { - return this.fileLocks.withLock(workspaceId, async () => { - try { - // Structural rewrite requires full file content - const messages = await this.readChatHistory(workspaceId); - const filteredMessages = messages.filter((msg) => msg.id !== messageId); - - if (filteredMessages.length === messages.length) { - // Not in the active epoch — the row may live in the sealed archive - // (rare: cleanup paths almost always target recent rows). - const archiveMessages = await this.readArchivedHistory(workspaceId); - const filteredArchive = archiveMessages.filter((msg) => msg.id !== messageId); - if (filteredArchive.length === archiveMessages.length) { - return Err(`Message with ID ${messageId} not found in history`); + return this.withRecoveredHistoryResultLock( + workspaceId, + "Failed to delete message", + async () => { + try { + // Structural rewrite requires full file content + const messages = await this.readChatHistory(workspaceId); + const filteredMessages = messages.filter((msg) => msg.id !== messageId); + + if (filteredMessages.length === messages.length) { + // Not in the active epoch — the row may live in the sealed archive + // (rare: cleanup paths almost always target recent rows). + const archiveMessages = await this.readArchivedHistory(workspaceId); + const filteredArchive = archiveMessages.filter((msg) => msg.id !== messageId); + if (filteredArchive.length === archiveMessages.length) { + return Err(`Message with ID ${messageId} not found in history`); + } + + // Archived rows are strictly older than active rows, so deleting one + // can never affect the sequence counter. + await writeFileAtomic( + this.getChatArchivePath(workspaceId), + this.serializeHistoryEntries(filteredArchive, workspaceId) + ); + return Ok(undefined); } - // Archived rows are strictly older than active rows, so deleting one - // can never affect the sequence counter. - await writeFileAtomic( - this.getChatArchivePath(workspaceId), - this.serializeHistoryEntries(filteredArchive, workspaceId) - ); - return Ok(undefined); - } + const historyPath = this.getChatHistoryPath(workspaceId); + const historyEntries = this.serializeHistoryEntries(filteredMessages, workspaceId); - const historyPath = this.getChatHistoryPath(workspaceId); - const historyEntries = this.serializeHistoryEntries(filteredMessages, workspaceId); + // Atomic write prevents corruption if app crashes mid-write + await writeFileAtomic(historyPath, historyEntries); - // Atomic write prevents corruption if app crashes mid-write - await writeFileAtomic(historyPath, historyEntries); + // Keep the in-memory sequence counter monotonic. It's okay to reuse deleted sequence + // numbers on restart, but we must not regress within a running process. + const maxSeq = filteredMessages.reduce((max, msg) => { + const seq = msg.metadata?.historySequence; + if (seq === undefined) { + return max; + } - // Keep the in-memory sequence counter monotonic. It's okay to reuse deleted sequence - // numbers on restart, but we must not regress within a running process. - const maxSeq = filteredMessages.reduce((max, msg) => { - const seq = msg.metadata?.historySequence; - if (seq === undefined) { - return max; - } + if (!isNonNegativeInteger(seq)) { + log.warn( + "Ignoring malformed persisted historySequence while updating sequence counter after delete", + { + workspaceId, + messageId: msg.id, + historySequence: seq, + } + ); + return max; + } - if (!isNonNegativeInteger(seq)) { - log.warn( - "Ignoring malformed persisted historySequence while updating sequence counter after delete", - { - workspaceId, - messageId: msg.id, - historySequence: seq, - } - ); - return max; + return seq > max ? seq : max; + }, -1); + // 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 + // historySequence values on the next append. + const archiveMaxSeq = await this.getArchiveTailMaxSequence(workspaceId); + const nextSeq = Math.max(maxSeq, archiveMaxSeq) + 1; + assert( + isNonNegativeInteger(nextSeq), + "next history sequence counter after delete must be a non-negative integer" + ); + const currentCounter = this.sequenceCounters.get(workspaceId); + if (currentCounter === undefined || currentCounter < nextSeq) { + this.sequenceCounters.set(workspaceId, nextSeq); } - return seq > max ? seq : max; - }, -1); - // 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 - // historySequence values on the next append. - const archiveMaxSeq = await this.getArchiveTailMaxSequence(workspaceId); - const nextSeq = Math.max(maxSeq, archiveMaxSeq) + 1; - assert( - isNonNegativeInteger(nextSeq), - "next history sequence counter after delete must be a non-negative integer" - ); - const currentCounter = this.sequenceCounters.get(workspaceId); - if (currentCounter === undefined || currentCounter < nextSeq) { - this.sequenceCounters.set(workspaceId, nextSeq); + return Ok(undefined); + } catch (error) { + const message = getErrorMessage(error); + return Err(`Failed to delete message: ${message}`); } - - return Ok(undefined); - } catch (error) { - const message = getErrorMessage(error); - return Err(`Failed to delete message: ${message}`); } - }); + ); } /** @@ -1800,81 +2067,85 @@ export class HistoryService { messageId: string, options?: { keepTargetMessage?: boolean } ): Promise> { - return this.fileLocks.withLock(workspaceId, async () => { - try { - // Structural rewrite requires full file content - const messages = await this.readChatHistory(workspaceId); - const messageIndex = messages.findIndex((msg) => msg.id === messageId); - - const keepTargetMessage = options?.keepTargetMessage === true; - - if (messageIndex === -1) { - // 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 - // into chat.jsonl and drop the archive. - return this.truncateAfterArchivedMessageUnlocked( - workspaceId, - messageId, - keepTargetMessage - ); - } + return this.withRecoveredHistoryResultLock( + workspaceId, + "Failed to truncate history", + async () => { + try { + // Structural rewrite requires full file content + const messages = await this.readChatHistory(workspaceId); + const messageIndex = messages.findIndex((msg) => msg.id === messageId); + + const keepTargetMessage = options?.keepTargetMessage === true; + + if (messageIndex === -1) { + // 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 + // into chat.jsonl and drop the archive. + return this.truncateAfterArchivedMessageUnlocked( + workspaceId, + messageId, + keepTargetMessage + ); + } - // Response-level forks branch from the selected assistant turn, so they retain the target - // message while discarding anything that came after it. - const truncatedMessages = messages.slice( - 0, - keepTargetMessage ? messageIndex + 1 : messageIndex - ); + // Response-level forks branch from the selected assistant turn, so they retain the target + // message while discarding anything that came after it. + const truncatedMessages = messages.slice( + 0, + keepTargetMessage ? messageIndex + 1 : messageIndex + ); - // Rewrite the history file with truncated messages - const historyPath = this.getChatHistoryPath(workspaceId); - const historyEntries = this.serializeHistoryEntries(truncatedMessages, workspaceId); + // Rewrite the history file with truncated messages + const historyPath = this.getChatHistoryPath(workspaceId); + const historyEntries = this.serializeHistoryEntries(truncatedMessages, workspaceId); - const archiveMaxSeq = await this.getArchiveTailMaxSequence(workspaceId); + const archiveMaxSeq = await this.getArchiveTailMaxSequence(workspaceId); - // Atomic write prevents corruption if app crashes mid-write - await writeFileAtomic(historyPath, historyEntries); + // Atomic write prevents corruption if app crashes mid-write + await writeFileAtomic(historyPath, historyEntries); - // Update sequence counter to continue from where we truncated. - // Self-healing read path: skip malformed persisted historySequence values. - const maxTruncatedSeq = truncatedMessages.reduce((max, msg) => { - const seq = msg.metadata?.historySequence; - if (seq === undefined) { - return max; - } + // Update sequence counter to continue from where we truncated. + // Self-healing read path: skip malformed persisted historySequence values. + const maxTruncatedSeq = truncatedMessages.reduce((max, msg) => { + const seq = msg.metadata?.historySequence; + if (seq === undefined) { + return max; + } - if (!isNonNegativeInteger(seq)) { - log.warn( - "Ignoring malformed persisted historySequence while updating sequence counter after truncation", - { - workspaceId, - messageId: msg.id, - historySequence: seq, - } - ); - return max; - } + if (!isNonNegativeInteger(seq)) { + log.warn( + "Ignoring malformed persisted historySequence while updating sequence counter after truncation", + { + workspaceId, + messageId: msg.id, + historySequence: seq, + } + ); + return max; + } - return seq > max ? seq : max; - }, -1); - // 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 - // sequence numbers. - const nextSeq = Math.max(maxTruncatedSeq, archiveMaxSeq) + 1; - assert( - isNonNegativeInteger(nextSeq), - "next history sequence counter after truncation must be a non-negative integer" - ); - this.sequenceCounters.set(workspaceId, nextSeq); + return seq > max ? seq : max; + }, -1); + // 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 + // sequence numbers. + const nextSeq = Math.max(maxTruncatedSeq, archiveMaxSeq) + 1; + assert( + isNonNegativeInteger(nextSeq), + "next history sequence counter after truncation must be a non-negative integer" + ); + this.sequenceCounters.set(workspaceId, nextSeq); - return Ok(undefined); - } catch (error) { - const message = getErrorMessage(error); - return Err(`Failed to truncate history: ${message}`); + return Ok(undefined); + } catch (error) { + const message = getErrorMessage(error); + return Err(`Failed to truncate history: ${message}`); + } } - }); + ); } /** @@ -1901,40 +2172,11 @@ export class HistoryService { keepTargetMessage ? messageIndex + 1 : messageIndex ); - const historyPath = this.getChatHistoryPath(workspaceId); - const archivePath = this.getChatArchivePath(workspaceId); - const originalArchive = await fs.readFile(archivePath, "utf-8"); - await writeFileAtomic( - archivePath, - this.serializeHistoryEntries(truncatedMessages.map(stripContextUsage), workspaceId) + await this.rewriteHistoryFilesUnlocked( + workspaceId, + null, + this.serializeHistoryEntries(truncatedMessages, workspaceId) ); - try { - await fs.rm(historyPath, { force: true }); - } catch (error) { - await writeFileAtomic(archivePath, originalArchive); - throw error; - } - let retainedPath = historyPath; - try { - await fs.rename(archivePath, historyPath); - } catch (error) { - retainedPath = archivePath; - log.warn("Keeping archived truncation result in the archive file", { - workspaceId, - error, - }); - } - try { - await writeFileAtomic( - retainedPath, - this.serializeHistoryEntries(truncatedMessages, workspaceId) - ); - } catch (error) { - log.warn("Failed to restore usage after archived history truncation", { - workspaceId, - error, - }); - } // chat.jsonl may contain sealed epochs again — allow the lazy check to re-run. this.sealedRotationChecked.delete(workspaceId); @@ -1984,249 +2226,130 @@ export class HistoryService { workspaceId: string, percentage: number ): Promise> { - return this.fileLocks.withLock(workspaceId, async () => { - try { - const historyPath = this.getChatHistoryPath(workspaceId); - const archivePath = this.getChatArchivePath(workspaceId); - - const readExistingFile = async (filePath: string): Promise => { - try { - return await fs.readFile(filePath, "utf-8"); - } catch (error) { - if (error && typeof error === "object" && "code" in error && error.code === "ENOENT") { - return null; - } - throw error; - } - }; - const archiveTombstonePath = `${archivePath}.truncate`; - const moveArchiveAside = async (): Promise => { - await fs.rm(archiveTombstonePath, { force: true }); - try { - await fs.rename(archivePath, archiveTombstonePath); - return true; - } catch (error) { - if (error && typeof error === "object" && "code" in error && error.code === "ENOENT") { - return false; - } - throw error; - } - }; - const restoreArchive = async (moved: boolean): Promise => { - if (moved) { - await fs.rename(archiveTombstonePath, archivePath); - } - }; - const discardArchiveTombstone = async (moved: boolean): Promise => { - if (!moved) { - return; - } - try { - await fs.rm(archiveTombstonePath, { force: true }); - } catch (error) { - this.sealedRotationChecked.delete(workspaceId); - log.warn("Failed to remove truncated history archive", { workspaceId, error }); - } - }; - - const removeAllMessages = async ( - archivedMessages: MuxMessage[], - chatMessages: MuxMessage[] - ): Promise => { + return this.withRecoveredHistoryResultLock( + workspaceId, + "Failed to truncate history", + async () => { + try { + const archivedMessages = await this.readArchivedHistory(workspaceId); + const chatMessages = await this.readChatHistory(workspaceId); const messages = [...archivedMessages, ...chatMessages]; - const deletedSequences = messages + const allSequences = messages .map((msg) => msg.metadata?.historySequence) .filter((s): s is number => isNonNegativeInteger(s)); - const archiveRemovalChangesContext = prefixCutChangesActiveContext( - messages, - archivedMessages.length - ); - const originalChat = archiveRemovalChangesContext - ? await readExistingFile(historyPath) - : null; - if (archiveRemovalChangesContext) { - await writeFileAtomic( - historyPath, - this.serializeHistoryEntries(chatMessages.map(stripContextUsage), workspaceId) - ); - } - let archiveMoved = false; - try { - archiveMoved = await moveArchiveAside(); - await fs.rm(historyPath, { force: true }); - } catch (error) { - try { - await restoreArchive(archiveMoved); - if (originalChat !== null) { - await writeFileAtomic(historyPath, originalChat); - } - } catch (rollbackError) { - log.error("Failed to roll back history clear", { workspaceId, error: rollbackError }); - } - throw error; + if (percentage >= 1.0) { + await this.rewriteHistoryFilesUnlocked(workspaceId, null, null); + this.sequenceCounters.set(workspaceId, 0); + return Ok(allSequences); } - await discardArchiveTombstone(archiveMoved); - this.sequenceCounters.set(workspaceId, 0); - return deletedSequences; - }; - if (percentage >= 1.0) { - return Ok( - await removeAllMessages( - await this.readArchivedHistory(workspaceId), - await this.readChatHistory(workspaceId) - ) - ); - } + // Structural rewrite requires full history content (oldest rows live in + // the sealed archive). Percentage truncation is a rare recovery path + // (compaction-failure retry), so the O(total-history) read is acceptable. + if (messages.length === 0) { + return Ok([]); // Nothing to truncate + } - // Structural rewrite requires full history content (oldest rows live in - // the sealed archive). Percentage truncation is a rare recovery path - // (compaction-failure retry), so the O(total-history) read is acceptable. - const archivedMessages = await this.readArchivedHistory(workspaceId); - const chatMessages = await this.readChatHistory(workspaceId); - const messages = [...archivedMessages, ...chatMessages]; - if (messages.length === 0) { - return Ok([]); // Nothing to truncate - } + // Get tokenizer for counting (use a default model) + const tokenizer = await getTokenizerForModel(KNOWN_MODELS.SONNET.id); - // Get tokenizer for counting (use a default model) - const tokenizer = await getTokenizerForModel(KNOWN_MODELS.SONNET.id); + // Count tokens for each message + // We stringify the entire message for simplicity - only relative weights matter + const messageTokens: Array<{ message: MuxMessage; tokens: number }> = await Promise.all( + messages.map(async (msg) => { + const tokens = await tokenizer.countTokens(safeStringifyForCounting(msg)); + return { message: msg, tokens }; + }) + ); - // Count tokens for each message - // We stringify the entire message for simplicity - only relative weights matter - const messageTokens: Array<{ message: MuxMessage; tokens: number }> = await Promise.all( - messages.map(async (msg) => { - const tokens = await tokenizer.countTokens(safeStringifyForCounting(msg)); - return { message: msg, tokens }; - }) - ); + // Calculate total tokens and target to remove + const totalTokens = messageTokens.reduce((sum, mt) => sum + mt.tokens, 0); + const tokensToRemove = Math.floor(totalTokens * percentage); - // Calculate total tokens and target to remove - const totalTokens = messageTokens.reduce((sum, mt) => sum + mt.tokens, 0); - const tokensToRemove = Math.floor(totalTokens * percentage); + // Remove messages from beginning until we've removed enough tokens + let tokensRemoved = 0; + let removeCount = 0; + for (const mt of messageTokens) { + if (tokensRemoved >= tokensToRemove) { + break; + } + tokensRemoved += mt.tokens; + removeCount++; + } - // Remove messages from beginning until we've removed enough tokens - let tokensRemoved = 0; - let removeCount = 0; - for (const mt of messageTokens) { - if (tokensRemoved >= tokensToRemove) { - break; + // No-op truncation (percentage 0 or rounding to zero tokens) must not + // rewrite anything — collapsing the archive back into chat.jsonl would + // undo rotation and put lifetime history back on the hot path. + if (removeCount === 0) { + return Ok([]); } - tokensRemoved += mt.tokens; - removeCount++; - } - // No-op truncation (percentage 0 or rounding to zero tokens) must not - // rewrite anything — collapsing the archive back into chat.jsonl would - // undo rotation and put lifetime history back on the hot path. - if (removeCount === 0) { - return Ok([]); - } + // If we're removing all messages, use fast path + if (removeCount >= messages.length) { + await this.rewriteHistoryFilesUnlocked(workspaceId, null, null); + this.sequenceCounters.set(workspaceId, 0); + return Ok(allSequences); + } - // If we're removing all messages, use fast path - if (removeCount >= messages.length) { - return Ok(await removeAllMessages(archivedMessages, chatMessages)); - } + const activeContextChanged = prefixCutChangesActiveContext(messages, removeCount); + const sanitize = activeContextChanged + ? stripContextUsage + : (message: MuxMessage) => message; + const remainingMessages = messages.slice(removeCount).map(sanitize); + const deletedMessages = messages.slice(0, removeCount); + const deletedSequences = deletedMessages + .map((msg) => msg.metadata?.historySequence) + .filter((s): s is number => isNonNegativeInteger(s)); + const remainingArchiveCount = Math.max(0, archivedMessages.length - removeCount); + const remainingArchive = remainingMessages.slice(0, remainingArchiveCount); + const remainingChat = remainingMessages.slice(remainingArchiveCount); - const activeContextChanged = prefixCutChangesActiveContext(messages, removeCount); - const sanitize = activeContextChanged - ? stripContextUsage - : (message: MuxMessage) => message; - const remainingMessages = messages.slice(removeCount).map(sanitize); - const deletedMessages = messages.slice(0, removeCount); - const deletedSequences = deletedMessages - .map((msg) => msg.metadata?.historySequence) - .filter((s): s is number => isNonNegativeInteger(s)); - const remainingArchiveCount = Math.max(0, archivedMessages.length - removeCount); - const remainingArchive = remainingMessages.slice(0, remainingArchiveCount); - const remainingChat = remainingMessages.slice(remainingArchiveCount); - - const archiveCutChangesContext = prefixCutChangesActiveContext( - messages, - Math.min(removeCount, archivedMessages.length) - ); - const originalChat = archiveCutChangesContext ? await readExistingFile(historyPath) : null; - if (archiveCutChangesContext) { - await writeFileAtomic( - historyPath, - this.serializeHistoryEntries(chatMessages.map(stripContextUsage), workspaceId) + await this.rewriteHistoryFilesUnlocked( + workspaceId, + remainingArchive.length > 0 + ? this.serializeHistoryEntries(remainingArchive, workspaceId) + : null, + this.serializeHistoryEntries(remainingChat, workspaceId) ); - } - - if (remainingArchive.length > 0) { - try { - await writeFileAtomic( - archivePath, - this.serializeHistoryEntries(remainingArchive, workspaceId) - ); - } catch (error) { - if (originalChat !== null) { - await writeFileAtomic(historyPath, originalChat); + this.sealedRotationChecked.delete(workspaceId); + + // Update sequence counter to continue from where we are. + // Self-healing read path: skip malformed persisted historySequence values. + const maxRemainingSeq = remainingMessages.reduce((max, msg) => { + const seq = msg.metadata?.historySequence; + if (seq === undefined) { + return max; } - throw error; - } - } else { - let archiveMoved = false; - try { - archiveMoved = await moveArchiveAside(); - await writeFileAtomic( - historyPath, - this.serializeHistoryEntries(remainingChat, workspaceId) - ); - } catch (error) { - try { - await restoreArchive(archiveMoved); - if (originalChat !== null) { - await writeFileAtomic(historyPath, originalChat); - } - } catch (rollbackError) { - log.error("Failed to roll back history truncation", { - workspaceId, - error: rollbackError, - }); - } - throw error; - } - await discardArchiveTombstone(archiveMoved); - } - this.sealedRotationChecked.delete(workspaceId); - - // Update sequence counter to continue from where we are. - // Self-healing read path: skip malformed persisted historySequence values. - const maxRemainingSeq = remainingMessages.reduce((max, msg) => { - const seq = msg.metadata?.historySequence; - if (seq === undefined) { - return max; - } - if (!isNonNegativeInteger(seq)) { - log.warn( - "Ignoring malformed persisted historySequence while updating sequence counter after truncateHistory", - { - workspaceId, - messageId: msg.id, - historySequence: seq, - } - ); - return max; - } + if (!isNonNegativeInteger(seq)) { + log.warn( + "Ignoring malformed persisted historySequence while updating sequence counter after truncateHistory", + { + workspaceId, + messageId: msg.id, + historySequence: seq, + } + ); + return max; + } - return seq > max ? seq : max; - }, -1); - const nextSeq = maxRemainingSeq + 1; - assert( - isNonNegativeInteger(nextSeq), - "next history sequence counter after truncateHistory must be a non-negative integer" - ); - this.sequenceCounters.set(workspaceId, nextSeq); + return seq > max ? seq : max; + }, -1); + const nextSeq = maxRemainingSeq + 1; + assert( + isNonNegativeInteger(nextSeq), + "next history sequence counter after truncateHistory must be a non-negative integer" + ); + this.sequenceCounters.set(workspaceId, nextSeq); - return Ok(deletedSequences); - } catch (error) { - const message = getErrorMessage(error); - return Err(`Failed to truncate history: ${message}`); + return Ok(deletedSequences); + } catch (error) { + const message = getErrorMessage(error); + return Err(`Failed to truncate history: ${message}`); + } } - }); + ); } async clearHistory(workspaceId: string): Promise> { @@ -2243,54 +2366,58 @@ export class HistoryService { * IMPORTANT: Should be called AFTER the session directory has been renamed */ async migrateWorkspaceId(oldWorkspaceId: string, newWorkspaceId: string): Promise> { - return this.fileLocks.withLock(newWorkspaceId, async () => { - try { - // Migrate the sealed archive first so a crash mid-migration never leaves - // the active file pointing at a stale-ID archive. - const archiveMessages = await this.readArchivedHistory(newWorkspaceId); - if (archiveMessages.length > 0) { - await writeFileAtomic( - this.getChatArchivePath(newWorkspaceId), - this.serializeHistoryEntries(archiveMessages, newWorkspaceId) - ); - } + return this.withRecoveredHistoryResultLock( + newWorkspaceId, + "Failed to migrate workspace history", + async () => { + try { + // Migrate the sealed archive first so a crash mid-migration never leaves + // the active file pointing at a stale-ID archive. + const archiveMessages = await this.readArchivedHistory(newWorkspaceId); + if (archiveMessages.length > 0) { + await writeFileAtomic( + this.getChatArchivePath(newWorkspaceId), + this.serializeHistoryEntries(archiveMessages, newWorkspaceId) + ); + } - // Read messages from the NEW workspace location (directory was already renamed). - // Structural rewrite requires full file content. - const messages = await this.readChatHistory(newWorkspaceId); - 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); - return Ok(undefined); - } + // Read messages from the NEW workspace location (directory was already renamed). + // Structural rewrite requires full file content. + const messages = await this.readChatHistory(newWorkspaceId); + 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); + return Ok(undefined); + } - // Rewrite all messages with new workspace ID - const newHistoryPath = this.getChatHistoryPath(newWorkspaceId); - const historyEntries = this.serializeHistoryEntries(messages, newWorkspaceId); + // Rewrite all messages with new workspace ID + const newHistoryPath = this.getChatHistoryPath(newWorkspaceId); + const historyEntries = this.serializeHistoryEntries(messages, newWorkspaceId); - // Atomic write prevents corruption if app crashes mid-write - await writeFileAtomic(newHistoryPath, historyEntries); + // Atomic write prevents corruption if app crashes mid-write + 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); + // Transfer sequence counter to new workspace ID + const oldCounter = this.sequenceCounters.get(oldWorkspaceId) ?? 0; + this.sequenceCounters.set(newWorkspaceId, oldCounter); + this.sequenceCounters.delete(oldWorkspaceId); - log.debug( - `Migrated ${messages.length} messages from ${oldWorkspaceId} to ${newWorkspaceId}` - ); + log.debug( + `Migrated ${messages.length} messages from ${oldWorkspaceId} to ${newWorkspaceId}` + ); - return Ok(undefined); - } catch (error) { - const message = getErrorMessage(error); - return Err(`Failed to migrate workspace ID: ${message}`); + return Ok(undefined); + } catch (error) { + const message = getErrorMessage(error); + return Err(`Failed to migrate workspace ID: ${message}`); + } } - }); + ); } } From 1722f48ab9e9e2e050a6be707cc38608bba98cd5 Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Sat, 8 Aug 2026 09:51:39 +0000 Subject: [PATCH 34/36] =?UTF-8?q?=F0=9F=A4=96=20fix:=20avoid=20nested=20wo?= =?UTF-8?q?rkspace=20history=20locks?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Let usage rebuilds iterate history through the lock-held path so transaction recovery remains serialized without reacquiring the shared non-reentrant mutex. Generated with mux • Model: openai:gpt-5.6-sol • Thinking: xhigh --- src/node/services/historyService.ts | 14 ++++++++++++++ src/node/services/sessionUsageService.ts | 15 ++++++++------- 2 files changed, 22 insertions(+), 7 deletions(-) diff --git a/src/node/services/historyService.ts b/src/node/services/historyService.ts index 1fdcb6a99e..c42d9ea359 100644 --- a/src/node/services/historyService.ts +++ b/src/node/services/historyService.ts @@ -944,6 +944,20 @@ export class HistoryService { ); } + /** Call only while holding workspaceFileLocks for this workspace. */ + async iterateFullHistoryUnderLock( + workspaceId: string, + direction: "forward" | "backward", + visitor: (messages: MuxMessage[]) => boolean | void | Promise + ): Promise> { + try { + await this.recoverTruncateTransactionUnlocked(workspaceId); + return await this.iterateFullHistoryUnlocked(workspaceId, direction, visitor); + } catch (error) { + return Err(`Failed to iterate history: ${getErrorMessage(error)}`); + } + } + private async iterateFullHistoryUnlocked( workspaceId: string, direction: "forward" | "backward", diff --git a/src/node/services/sessionUsageService.ts b/src/node/services/sessionUsageService.ts index 6cccff4f07..3d66eb5ebb 100644 --- a/src/node/services/sessionUsageService.ts +++ b/src/node/services/sessionUsageService.ts @@ -116,15 +116,16 @@ export class SessionUsageService { this.historyService = historyService; this.getProvidersConfig = getProvidersConfig ?? (() => null); } - /** - * Collect all messages from iterateFullHistory into an array. - * Usage rebuild needs every epoch for accurate totals. - */ + /** Usage rebuild needs every epoch for accurate totals. */ private async collectFullHistory(workspaceId: string): Promise { const messages: MuxMessage[] = []; - const result = await this.historyService.iterateFullHistory(workspaceId, "forward", (chunk) => { - messages.push(...chunk); - }); + const result = await this.historyService.iterateFullHistoryUnderLock( + workspaceId, + "forward", + (chunk) => { + messages.push(...chunk); + } + ); if (!result.success) { log.warn(`Failed to iterate history for ${workspaceId}: ${result.error}`); return []; From af4b86b58be10ecff0897f5c97f8089ae7cc0c70 Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Sat, 8 Aug 2026 10:11:37 +0000 Subject: [PATCH 35/36] =?UTF-8?q?=F0=9F=A4=96=20fix:=20snapshot=20fork=20h?= =?UTF-8?q?istory=20under=20the=20source=20lock?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Recover pending archive transactions and capture chat plus sealed history atomically before writing the fork session files. Generated with mux • Model: openai:gpt-5.6-sol • Thinking: xhigh --- src/node/services/historyService.test.ts | 23 ++++++++++++++ src/node/services/historyService.ts | 39 ++++++++++++++++++++++++ src/node/services/workspaceService.ts | 12 +++++--- 3 files changed, 69 insertions(+), 5 deletions(-) diff --git a/src/node/services/historyService.test.ts b/src/node/services/historyService.test.ts index f927cf0d8d..a550efa79a 100644 --- a/src/node/services/historyService.test.ts +++ b/src/node/services/historyService.test.ts @@ -2178,6 +2178,29 @@ describe("HistoryService", () => { ]); }); + it("recovers the source transaction before copying a fork snapshot", async () => { + const targetWorkspaceId = "forked-workspace"; + await appendNumberedMessages(service, wsId, 3); + await service.appendToHistory(wsId, boundaryMessage("boundary-1", 1)); + await service.appendToHistory(wsId, createMuxMessage("post-0", "user", "after")); + const chatContents = await fs.readFile(chatPath(wsId), "utf-8"); + const hash = (contents: string) => createHash("sha256").update(contents).digest("hex"); + await fs.writeFile( + `${archivePath(wsId)}.truncate.json`, + JSON.stringify({ + finalArchiveHash: hash("replacement archive\n"), + finalChatHash: hash(chatContents), + }) + ); + await fs.rename(archivePath(wsId), `${archivePath(wsId)}.truncate`); + + const result = await service.copyHistorySnapshotToNewWorkspace(wsId, targetWorkspaceId); + expect(result.success).toBe(true); + expect( + (await collectFullHistory(service, targetWorkspaceId)).map((message) => message.id) + ).toEqual(["msg-0", "msg-1", "msg-2", "boundary-1", "post-0"]); + }); + it("does not restore a committed archive tombstone before appending", async () => { await appendNumberedMessages(service, wsId, 3); await service.appendToHistory(wsId, boundaryMessage("boundary-1", 1)); diff --git a/src/node/services/historyService.ts b/src/node/services/historyService.ts index c42d9ea359..8266b07a2d 100644 --- a/src/node/services/historyService.ts +++ b/src/node/services/historyService.ts @@ -958,6 +958,45 @@ export class HistoryService { } } + async copyHistorySnapshotToNewWorkspace( + sourceWorkspaceId: string, + targetWorkspaceId: string + ): Promise> { + assert( + sourceWorkspaceId !== targetWorkspaceId, + "history snapshot target must be a new workspace" + ); + const snapshot = await this.withRecoveredHistoryResultLock( + sourceWorkspaceId, + "Failed to read history snapshot", + async () => + Ok({ + archive: await this.readExistingFile(this.getChatArchivePath(sourceWorkspaceId)), + chat: await this.readExistingFile(this.getChatHistoryPath(sourceWorkspaceId)), + }) + ); + if (!snapshot.success) { + return snapshot; + } + + try { + await ensurePrivateDir(this.config.getSessionDir(targetWorkspaceId)); + for (const [targetPath, contents] of [ + [this.getChatArchivePath(targetWorkspaceId), snapshot.data.archive], + [this.getChatHistoryPath(targetWorkspaceId), snapshot.data.chat], + ] as const) { + if (contents === null) { + await fs.rm(targetPath, { force: true }); + } else { + await writeFileAtomic(targetPath, contents); + } + } + return Ok(undefined); + } catch (error) { + return Err(`Failed to copy history snapshot: ${getErrorMessage(error)}`); + } + } + private async iterateFullHistoryUnlocked( workspaceId: string, direction: "forward" | "backward", diff --git a/src/node/services/workspaceService.ts b/src/node/services/workspaceService.ts index 41c083ee6e..4c4c18c4c8 100644 --- a/src/node/services/workspaceService.ts +++ b/src/node/services/workspaceService.ts @@ -8080,13 +8080,15 @@ export class WorkspaceService extends EventEmitter { const newSessionDir = this.config.getSessionDir(newWorkspaceId); try { - await ensurePrivateDir(newSessionDir); + const historyCopyResult = await this.historyService.copyHistorySnapshotToNewWorkspace( + sourceWorkspaceId, + newWorkspaceId + ); + if (!historyCopyResult.success) { + throw new Error(historyCopyResult.error); + } const sessionFiles = [ - CHAT_FILE_NAME, - // Sealed pre-boundary history must travel with chat.jsonl so the fork - // keeps full Load More/paging access to older epochs. - CHAT_ARCHIVE_FILE_NAME, "session-timing.json", ADDITIONAL_SYSTEM_CONTEXT_FILENAME, // Preserve the enabled/disabled toggle when forking so the fork From 61078d3c0c0dd3e5973555e680ca10bfb9c35f54 Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Sat, 8 Aug 2026 10:57:20 +0000 Subject: [PATCH 36/36] =?UTF-8?q?=F0=9F=A4=96=20fix:=20stabilize=20refresh?= =?UTF-8?q?=20and=20async=20service=20tests?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../RightSidebar/CodeReview/ReviewPanel.tsx | 12 ++++++------ src/node/services/taskService.test.ts | 4 ++++ src/node/services/workspaceGoalService.test.ts | 17 ++++++++--------- 3 files changed, 18 insertions(+), 15 deletions(-) diff --git a/src/browser/features/RightSidebar/CodeReview/ReviewPanel.tsx b/src/browser/features/RightSidebar/CodeReview/ReviewPanel.tsx index 2d2d13a607..f2f23ccf11 100644 --- a/src/browser/features/RightSidebar/CodeReview/ReviewPanel.tsx +++ b/src/browser/features/RightSidebar/CodeReview/ReviewPanel.tsx @@ -1360,9 +1360,8 @@ export const ReviewPanel: React.FC = ({ if (!api || isCreating) return; let cancelled = false; - const prevRefreshTrigger = lastFileTreeRefreshTriggerRef.current; - lastFileTreeRefreshTriggerRef.current = refreshTrigger; - const isManualRefresh = refreshTrigger !== 0 && prevRefreshTrigger !== refreshTrigger; + const isManualRefresh = + refreshTrigger !== 0 && lastFileTreeRefreshTriggerRef.current !== refreshTrigger; const numstatCommand = buildGitDiffCommand( filters.diffBase, @@ -1486,6 +1485,7 @@ export const ReviewPanel: React.FC = ({ }); if (cancelled) return; + lastFileTreeRefreshTriggerRef.current = refreshTrigger; setFileTree(tree); } catch (err) { console.error("Failed to load file tree:", err); @@ -1519,9 +1519,8 @@ export const ReviewPanel: React.FC = ({ if (!api || isCreating) return; let cancelled = false; - const prevRefreshTrigger = lastDiffRefreshTriggerRef.current; - lastDiffRefreshTriggerRef.current = refreshTrigger; - const isManualRefresh = refreshTrigger !== 0 && prevRefreshTrigger !== refreshTrigger; + const isManualRefresh = + refreshTrigger !== 0 && lastDiffRefreshTriggerRef.current !== refreshTrigger; const effectiveIncludeUncommitted = getEffectiveReviewIncludeUncommitted({ assistedOnly: filters.assistedOnly, @@ -1652,6 +1651,7 @@ export const ReviewPanel: React.FC = ({ if (cancelled) return; + lastDiffRefreshTriggerRef.current = refreshTrigger; setDiagnosticInfo(data.diagnosticInfo); // Preserve object references for unchanged hunks to prevent unnecessary re-renders. diff --git a/src/node/services/taskService.test.ts b/src/node/services/taskService.test.ts index 57496dad20..5866411195 100644 --- a/src/node/services/taskService.test.ts +++ b/src/node/services/taskService.test.ts @@ -13710,6 +13710,8 @@ describe("TaskService", () => { parts: childPartial.parts as StreamEndEvent["parts"], }); + await flushTerminalAttentionDrains(taskService); + const updatedChildPartial = await partialService.readPartial(childId); expect(updatedChildPartial).toBeNull(); @@ -15189,6 +15191,8 @@ describe("TaskService", () => { parts: childPartial.parts as StreamEndEvent["parts"], }); + await flushTerminalAttentionDrains(taskService); + const parentMessages = await collectFullHistory(historyService, parentId); // Original task tool call remains immutable ("running"), and a synthetic report message is appended. expect(parentMessages.length).toBeGreaterThanOrEqual(2); diff --git a/src/node/services/workspaceGoalService.test.ts b/src/node/services/workspaceGoalService.test.ts index 25b9a098a4..6a82658777 100644 --- a/src/node/services/workspaceGoalService.test.ts +++ b/src/node/services/workspaceGoalService.test.ts @@ -1600,23 +1600,22 @@ describe("WorkspaceGoalService", () => { expect(maxActiveContinuations).toBe(1); }); - test("does not dispatch stale continuation candidates after the goal changes", async () => { + test("does not build stale continuation payloads after the goal changes", async () => { await setGoalOk(service, { workspaceId, objective: "Original" }); - // Give the replacement write time to commit before the idle dispatch builds - // its payload; this test is about rejecting an already-stale candidate, not racing setGoal. - const dispatcher = new IdleDispatcher({ debounceMs: 250 }); - const execute = mock(() => Promise.resolve(true)); - service.registerGoalContinuationConsumer(dispatcher, continuationBridge(execute)); + const dispatcher = new IdleDispatcher(); + const requestDispatch = spyOn(dispatcher, "requestDispatch").mockResolvedValue(); + service.registerGoalContinuationConsumer(dispatcher, continuationBridge()); - const request = service.requestContinuationAfterStreamEnd({ + await service.requestContinuationAfterStreamEnd({ workspaceId, sendOptions: { model: "openai:gpt-4o", agentId: "exec" }, streamEndedAtMs: 10_000, }); + expect(requestDispatch).toHaveBeenCalled(); + expect(await service.buildGoalContinuationPayload(workspaceId)).not.toBeNull(); await setGoalOk(service, { workspaceId, objective: "Replacement" }); - await request; - expect(execute).not.toHaveBeenCalled(); + expect(await service.buildGoalContinuationPayload(workspaceId)).toBeNull(); }); test("preserves goal id and accounting for same-objective set", async () => {