From b12b2bdb730af9dfcdf30c5c740d098c97e12deb Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Fri, 4 Sep 2026 13:05:29 +0000 Subject: [PATCH 01/31] fix(queue): ignore withdrawn entries in correlation readers --- src/node/services/messageQueue.test.ts | 49 +++++++++++++++++++++++++ src/node/services/messageQueue.ts | 50 +++++++++++++------------- 2 files changed, 75 insertions(+), 24 deletions(-) diff --git a/src/node/services/messageQueue.test.ts b/src/node/services/messageQueue.test.ts index 48500d3cc29..9ea10f22bc9 100644 --- a/src/node/services/messageQueue.test.ts +++ b/src/node/services/messageQueue.test.ts @@ -792,6 +792,55 @@ describe("MessageQueue", () => { ).toBe(false); }); + it.each(["continuation", "wake", "manual"] as const)( + "ignores withdrawn predecessors when the live successor is %s", + (kind) => { + const options = { model: "gpt-4", agentId: "exec" }; + const canceled = new AbortController(); + queue.add( + "withdrawn continuation", + { ...options, muxMetadata: metadata }, + { + synthetic: true, + cancelSignal: canceled.signal, + } + ); + queue.add( + "withdrawn wake", + { + ...options, + muxMetadata: { type: "bash-monitor-wake", records: [] }, + }, + { synthetic: true, cancelSignal: canceled.signal } + ); + canceled.abort(); + expect(queue.getNextQueueCutCandidate()).toBeUndefined(); + expect(queue.isNextEntryBashMonitorWake()).toBe(false); + expect( + queue.hasAllWorkspaceTurnContinuations("wst_followup", "parent-workspace", "turn-1") + ).toBe(true); + + const liveMetadata = + kind === "continuation" + ? metadata + : kind === "wake" + ? { type: "bash-monitor-wake" as const, records: [] } + : undefined; + queue.add("live", { ...options, muxMetadata: liveMetadata, queueDispatchMode: "turn-end" }); + expect(queue.getNextQueueCutCandidate()).toEqual({ + muxMetadata: liveMetadata, + dispatchMode: "turn-end", + }); + expect(queue.isNextEntryBashMonitorWake()).toBe(kind === "wake"); + expect( + queue.hasNextWorkspaceTurnContinuation("wst_followup", "parent-workspace", "turn-1") + ).toBe(kind === "continuation"); + expect( + queue.hasAllWorkspaceTurnContinuations("wst_followup", "parent-workspace", "turn-1") + ).toBe(kind === "continuation"); + } + ); + it("exposes the head entry's metadata and dispatch mode as the queue-cut candidate", () => { expect(queue.getNextQueueCutCandidate()).toBeUndefined(); diff --git a/src/node/services/messageQueue.ts b/src/node/services/messageQueue.ts index b18f93983a3..addd62d8963 100644 --- a/src/node/services/messageQueue.ts +++ b/src/node/services/messageQueue.ts @@ -284,37 +284,38 @@ export class MessageQueue { } /** - * Dispatch mode of the first entry whose cancel signal has not fired, or undefined - * when none remains. Aborted entries still drain FIFO (as no-ops that fire - * onCanceled), but they are not pending work and must not arm a tool-end stop. + * The first entry whose cancel signal has not fired. Aborted entries still drain FIFO (as no-ops that fire + * onCanceled), but they are not pending work or continuations of a turn. */ + private nextDispatchableEntry(): QueueEntry | undefined { + return this.entries.find((entry) => entry.cancelSignal?.aborted !== true); + } + getNextDispatchableMode(): QueueDispatchMode | undefined { - return this.entries.find((entry) => entry.cancelSignal?.aborted !== true)?.dispatchMode; + return this.nextDispatchableEntry()?.dispatchMode; } /** - * Whether every queued entry continues the exact workspace turn correlation. + * Whether every pending queued entry continues the exact workspace turn correlation. * * The caller uses this for a new continuation that has not entered the queue. - * An unrelated entry anywhere ahead of it supersedes the correlation. + * An unrelated pending entry anywhere ahead of it supersedes the correlation. */ hasAllWorkspaceTurnContinuations( taskHandleId: string, ownerWorkspaceId: string, turnId: string ): boolean { - return ( - this.entries.length > 0 && - this.entries.every((entry) => { - const metadata = entry.muxMetadata; - return ( - isWorkspaceTurnMetadata(metadata) && - metadata.taskHandleId === taskHandleId && - metadata.ownerWorkspaceId === ownerWorkspaceId && - metadata.turnId === turnId - ); - }) - ); + return this.entries.every((entry) => { + if (entry.cancelSignal?.aborted === true) return true; + const metadata = entry.muxMetadata; + return ( + isWorkspaceTurnMetadata(metadata) && + metadata.taskHandleId === taskHandleId && + metadata.ownerWorkspaceId === ownerWorkspaceId && + metadata.turnId === turnId + ); + }); } /** @@ -331,6 +332,7 @@ export class MessageQueue { turnId: string ): boolean { return this.entries.slice(0, this.trailingHiddenTurnEndRunStart()).every((entry) => { + if (entry.cancelSignal?.aborted === true) return true; const metadata = entry.muxMetadata; return ( isWorkspaceTurnMetadata(metadata) && @@ -359,14 +361,14 @@ export class MessageQueue { } /** - * Whether the next entry continues the exact workspace turn correlation. + * Whether the next dispatchable entry continues the exact workspace turn correlation. */ hasNextWorkspaceTurnContinuation( taskHandleId: string, ownerWorkspaceId: string, turnId: string ): boolean { - const metadata = this.entries[0]?.muxMetadata; + const metadata = this.nextDispatchableEntry()?.muxMetadata; return ( isWorkspaceTurnMetadata(metadata) && metadata.taskHandleId === taskHandleId && @@ -376,7 +378,7 @@ export class MessageQueue { } /** - * FIFO head entry's cut-attribution view: its first muxMetadata plus dispatch mode. + * Next dispatchable entry's cut-attribution view: its first muxMetadata plus dispatch mode. * * Soundness of metadata-based cut attribution rests on the sealing invariant * (see class docblock): workspace-turn entries are sealed at add time and @@ -387,7 +389,7 @@ export class MessageQueue { getNextQueueCutCandidate(): | { muxMetadata: unknown; dispatchMode: QueueDispatchMode } | undefined { - const head = this.entries[0]; + const head = this.nextDispatchableEntry(); if (head == null) { return undefined; } @@ -395,13 +397,13 @@ export class MessageQueue { } /** - * Whether the next entry to dispatch is a bash-monitor wake. Wake sends are + * Whether the next dispatchable entry is a bash-monitor wake. Wake sends are * the only queued input that continues an open delegated workspace turn * (see AgentSession.inheritOpenWorkspaceTurnMetadata); any other head entry * supersedes the turn when it dispatches. */ isNextEntryBashMonitorWake(): boolean { - const muxMetadata = this.entries[0]?.muxMetadata; + const muxMetadata = this.nextDispatchableEntry()?.muxMetadata; if (typeof muxMetadata !== "object" || muxMetadata === null) return false; return (muxMetadata as Record).type === "bash-monitor-wake"; } From 42a70e1b70e013249c7e539c4e92c05fa32da10c Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Fri, 4 Sep 2026 13:09:20 +0000 Subject: [PATCH 02/31] fix(usage): price aborted streams against the effective model --- src/common/orpc/schemas/stream.ts | 1 + .../agentSession.queueDispatch.test.ts | 66 +++++++++++++++++++ src/node/services/agentSession.ts | 3 +- src/node/services/streamManager.test.ts | 29 +++++++- src/node/services/streamManager.ts | 9 ++- 5 files changed, 105 insertions(+), 3 deletions(-) diff --git a/src/common/orpc/schemas/stream.ts b/src/common/orpc/schemas/stream.ts index 63d44f311fa..af79f7faa17 100644 --- a/src/common/orpc/schemas/stream.ts +++ b/src/common/orpc/schemas/stream.ts @@ -336,6 +336,7 @@ export const StreamAbortEventSchema = z.object({ // Last step's provider metadata (for context window cache display) contextProviderMetadata: z.record(z.string(), z.unknown()).optional(), duration: z.number().optional(), + model: z.string().optional(), }) .optional() .meta({ diff --git a/src/node/services/agentSession.queueDispatch.test.ts b/src/node/services/agentSession.queueDispatch.test.ts index 536e0a99ab8..f53666b7e0b 100644 --- a/src/node/services/agentSession.queueDispatch.test.ts +++ b/src/node/services/agentSession.queueDispatch.test.ts @@ -1,12 +1,16 @@ import type { StreamAbortEvent } from "@/common/types/stream"; import { runSessionTerminalPolicy } from "./agentSession.testHarness"; import { describe, expect, mock, spyOn, test } from "bun:test"; +import { EventEmitter } from "node:events"; +import { createDisplayUsage } from "@/common/utils/tokens/displayUsage"; +import { getTotalCost } from "@/common/utils/tokens/usageAggregator"; import type { MuxMessageMetadata } from "@/common/types/message"; import { Err, Ok } from "@/common/types/result"; import type { WorkspaceGoalService } from "./workspaceGoalService"; import { createAgentSessionHarness, createStartedTurnHandle } from "./agentSession.testHarness"; import type { AIService } from "./aiService"; +import type { TurnCompletion } from "./streamManager"; const TEST_MODEL = "anthropic:claude-sonnet-4-5"; const WORKSPACE_TURN_CORRELATION = { @@ -166,6 +170,68 @@ describe("AgentSession queued message tool-call dispatch", () => { } ); + test.each([undefined, "anthropic:claude-opus-4-1"])( + "accounts aborted usage against the effective model %s with request fallback", + async (effectiveModel) => { + const workspaceId = "abort-effective-model"; + const aiEmitter = new EventEmitter(); + const accounting = Promise.withResolvers(); + const completion = Promise.withResolvers(); + const workspaceGoalService = { + assertPricedModelForBudgetedGoal: mock(() => Promise.resolve(Ok(undefined))), + recordStreamAccounting: mock((input: { costUsd: number }) => { + accounting.resolve(input.costUsd); + return Promise.resolve(); + }), + applyPendingAfterStreamEnd: mock(() => Promise.resolve()), + requestContinuationAfterStreamEnd: mock(() => Promise.resolve()), + recordStreamStarted: mock(() => Promise.resolve()), + syncGoalModeWithChatTail: mock(() => Promise.resolve(null)), + } as unknown as WorkspaceGoalService; + const { session, cleanup } = await createAgentSessionHarness({ + workspaceId, + aiEmitter, + workspaceGoalService, + aiServiceOverrides: { + streamMessage: mock(() => { + aiEmitter.emit("stream-start", streamStartEvent(workspaceId)); + return Promise.resolve( + Ok({ messageId: "assistant-1", completion: completion.promise }) + ); + }), + }, + }); + try { + expect( + ( + await session.sendMessage( + "start", + { model: TEST_MODEL, agentId: "exec" }, + { synthetic: true, agentInitiated: true } + ) + ).success + ).toBe(true); + const usage = { inputTokens: 1_000_000, outputTokens: 0, totalTokens: 1_000_000 }; + completion.resolve({ + status: "aborted", + abortReason: "system", + streamAbort: { + type: "stream-abort", + workspaceId, + metadata: { duration: 1, usage, model: effectiveModel }, + }, + }); + expect(await accounting.promise).toBe( + getTotalCost(createDisplayUsage(usage, effectiveModel ?? TEST_MODEL)) + ); + await session.waitForIdle(); + } finally { + session.dispose(); + await cleanup(); + } + } + ); + test("counts only a different direct preparing send as a superseding predecessor", async () => { const sessionHolder: { current?: { diff --git a/src/node/services/agentSession.ts b/src/node/services/agentSession.ts index 35a8eab89f4..03cc639c277 100644 --- a/src/node/services/agentSession.ts +++ b/src/node/services/agentSession.ts @@ -6236,7 +6236,8 @@ export class AgentSession { this.coordinator.beginPolicy(turn); if (!this.coordinator.isCurrentTurn(turn) || !this.coordinator.isCurrentOperation(operation)) return; - const activeModelForAbort = this.activeStreamContext?.modelString; + // A configured fallback can bill a different model than the requested one. + const activeModelForAbort = payload.metadata?.model ?? this.activeStreamContext?.modelString; const activeOptionsForAbort = this.activeStreamContext?.options; this.lastSystemMessageTokens = systemMessageTokens ?? this.lastSystemMessageTokens; if (activeModelForAbort) { diff --git a/src/node/services/streamManager.test.ts b/src/node/services/streamManager.test.ts index 74e7e75dd30..cc4156ba37d 100644 --- a/src/node/services/streamManager.test.ts +++ b/src/node/services/streamManager.test.ts @@ -5,7 +5,11 @@ import * as path from "node:path"; import { KNOWN_MODELS } from "@/common/constants/knownModels"; import type { ProvidersConfigMap } from "@/common/orpc/types"; -import { StreamEndEventSchema, ToolCallStartEventSchema } from "@/common/orpc/schemas/stream"; +import { + StreamAbortEventSchema, + StreamEndEventSchema, + ToolCallStartEventSchema, +} from "@/common/orpc/schemas/stream"; import type { CompletedMessagePart, ToolCallEndEvent, @@ -6555,6 +6559,29 @@ describe("StreamManager - aborted stream usage persistence", () => { }); }); + test("emits the effective fallback model with aborted usage", async () => { + const streamManager = new StreamManager(historyService); + const effectiveModel = "anthropic:claude-opus-4-1"; + const abort = Promise.withResolvers(); + onTurnEngineEvent(streamManager, "stream-abort", (event) => abort.resolve(event)); + const cleanupAborted = getPrivateMethodForTests( + streamManager, + "cleanupAbortedStream" + ); + await cleanupAborted.call( + streamManager, + "fallback-abort", + { + ...createAbortStreamInfo("fallback-message"), + model: effectiveModel, + }, + "system" + ); + const event = StreamAbortEventSchema.parse(await abort.promise); + expect(event.metadata?.model).toBe(effectiveModel); + expect(event.metadata?.usage?.inputTokens).toBe(120); + }); + test("routes tool-only aborted usage to the headless sidecar (commit would drop it)", async () => { // Esc while a tool is still running: the partial's only part is an // input-available tool call, which commitPartial refuses to commit — diff --git a/src/node/services/streamManager.ts b/src/node/services/streamManager.ts index 6929ff22e12..419c437e4ac 100644 --- a/src/node/services/streamManager.ts +++ b/src/node/services/streamManager.ts @@ -1951,7 +1951,14 @@ export class StreamManager { type: "stream-abort", workspaceId, messageId: streamInfo.messageId, - metadata: { usage, contextUsage, duration, providerMetadata, contextProviderMetadata }, + metadata: { + usage, + contextUsage, + duration, + providerMetadata, + contextProviderMetadata, + model: streamInfo.model, + }, abortReason, abandonPartial, acpPromptId: streamInfo.initialMetadata?.acpPromptId, From c91286c4da610180c9faa1b6b3d2af01a417b992 Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Fri, 4 Sep 2026 13:48:56 +0000 Subject: [PATCH 03/31] fix(bash): defer monitor attention until the active turn ends --- .../agentSession.queueDispatch.test.ts | 2 +- .../bashMonitorWakeReconciler.test.ts | 34 +- .../services/bashMonitorWakeReconciler.ts | 4 +- src/node/services/messageQueue.ts | 5 +- src/node/services/workspaceService.test.ts | 621 ++++++++++++++---- src/node/services/workspaceService.ts | 48 +- 6 files changed, 533 insertions(+), 181 deletions(-) diff --git a/src/node/services/agentSession.queueDispatch.test.ts b/src/node/services/agentSession.queueDispatch.test.ts index f53666b7e0b..564df0146a6 100644 --- a/src/node/services/agentSession.queueDispatch.test.ts +++ b/src/node/services/agentSession.queueDispatch.test.ts @@ -222,7 +222,7 @@ describe("AgentSession queued message tool-call dispatch", () => { }, }); expect(await accounting.promise).toBe( - getTotalCost(createDisplayUsage(usage, effectiveModel ?? TEST_MODEL)) + getTotalCost(createDisplayUsage(usage, effectiveModel ?? TEST_MODEL)) ?? -1 ); await session.waitForIdle(); } finally { diff --git a/src/node/services/bashMonitorWakeReconciler.test.ts b/src/node/services/bashMonitorWakeReconciler.test.ts index 54a7d28ea11..fa4ba994d5c 100644 --- a/src/node/services/bashMonitorWakeReconciler.test.ts +++ b/src/node/services/bashMonitorWakeReconciler.test.ts @@ -143,40 +143,18 @@ describe("BashMonitorWakeReconciler", () => { expect(dispatches).toHaveLength(2); }); - test("superseding a queued wake uses a distinct queue key", async () => { - const queuedKeys = new Set(); - const queuedDispatches: BashMonitorWakeDispatch[] = []; - const queueing = new BashMonitorWakeReconciler({ - sessionsDir: root, - processManager: { - pullMonitorWakeSignals: () => live, - getMonitorWakeDeliveryState: () => Promise.resolve(deliveryState), - acknowledgeMonitorWake: () => undefined, - dropRetiredMonitor: () => undefined, - }, - registry: { - listAll: () => Promise.resolve([]), - remove: () => undefined, - recordTerminal: () => undefined, - }, - onWake: (dispatch) => { - if (queuedKeys.has(dispatch.dedupeKey)) return "deferred"; - queuedKeys.add(dispatch.dedupeKey); - queuedDispatches.push(dispatch); - return "in-flight"; - }, - }); + test("a newer match withdraws the in-flight wake and dispatches again", async () => { live = [liveSnapshot()]; - await queueing.reconcile(OWNER); + await reconciler.reconcile(OWNER); live = [ liveSnapshot({ match: { throughOffset: 24, lines: ["READY again"], totalMatches: 2 } }), ]; - await queueing.reconcile(OWNER); + await reconciler.reconcile(OWNER); - expect(queuedDispatches).toHaveLength(2); - expect(queuedKeys.size).toBe(2); - expect(queuedDispatches[0].cancelSignal.aborted).toBe(true); + expect(dispatches).toHaveLength(2); + expect(dispatches[0].cancelSignal.aborted).toBe(true); + expect(dispatches[1].cancelSignal.aborted).toBe(false); }); test("keeps dead registry evidence until the queued wake is accepted", async () => { diff --git a/src/node/services/bashMonitorWakeReconciler.ts b/src/node/services/bashMonitorWakeReconciler.ts index 46728fc4bee..b1ee34225b4 100644 --- a/src/node/services/bashMonitorWakeReconciler.ts +++ b/src/node/services/bashMonitorWakeReconciler.ts @@ -105,7 +105,6 @@ export interface BashMonitorWakeDispatch { ownerWorkspaceId: string; prompt: string; muxMetadata: Extract; - dedupeKey: string; cancelSignal: AbortSignal; onAccepted(): Promise; onDeferred(): Promise; @@ -550,7 +549,6 @@ export class BashMonitorWakeReconciler { ownerWorkspaceId, prompt: buildPrompt(dispatch.signals), muxMetadata: buildMetadata(dispatch.signals), - dedupeKey: "bash-monitor-wake:" + ownerWorkspaceId + ":" + dispatch.id, cancelSignal: dispatch.controller.signal, onAccepted: async () => this.accept(ownerWorkspaceId, dispatch), onDeferred: async () => this.defer(ownerWorkspaceId, dispatch), @@ -592,7 +590,7 @@ export class BashMonitorWakeReconciler { state.dispatch = undefined; } - private async consumeCurrent(ownerWorkspaceId: string): Promise { + async consumeCurrent(ownerWorkspaceId: string): Promise { await this.locks.withLock(ownerWorkspaceId, async () => { this.abortDispatch(ownerWorkspaceId); const collected = await this.collect(ownerWorkspaceId, false); diff --git a/src/node/services/messageQueue.ts b/src/node/services/messageQueue.ts index addd62d8963..8895a5c0d59 100644 --- a/src/node/services/messageQueue.ts +++ b/src/node/services/messageQueue.ts @@ -397,10 +397,7 @@ export class MessageQueue { } /** - * Whether the next dispatchable entry is a bash-monitor wake. Wake sends are - * the only queued input that continues an open delegated workspace turn - * (see AgentSession.inheritOpenWorkspaceTurnMetadata); any other head entry - * supersedes the turn when it dispatches. + * Bash-monitor wakes inherit an open delegated turn's correlation at dispatch. */ isNextEntryBashMonitorWake(): boolean { const muxMetadata = this.nextDispatchableEntry()?.muxMetadata; diff --git a/src/node/services/workspaceService.test.ts b/src/node/services/workspaceService.test.ts index 0a4defe9bc2..a2d26fc6ff0 100644 --- a/src/node/services/workspaceService.test.ts +++ b/src/node/services/workspaceService.test.ts @@ -30,6 +30,9 @@ import type { SessionTimingService } from "./sessionTimingService"; import { SessionUsageService } from "./sessionUsageService"; import type { AIService } from "./aiService"; import { MockLanguageModelV3, simulateReadableStream } from "ai/test"; +import { streamText, tool } from "ai"; +import { z } from "zod"; +import { StreamManager } from "./streamManager"; import type { LanguageModelV3StreamPart } from "@ai-sdk/provider"; import { EXPERIMENT_IDS } from "@/common/constants/experiments"; import type { ExperimentsService } from "./experimentsService"; @@ -87,6 +90,11 @@ import { // nit DEREM-50) — import instead of defining local copies. import { drainPendingDispatches, waitForCondition } from "./testDispatchHelpers"; import { sandboxHostService } from "./sandbox/sandboxHostService"; +import type { + BashMonitorProcessSnapshot, + BashMonitorWakeReconciler, + BashMonitorWakeDispatch, +} from "./bashMonitorWakeReconciler"; // Helper to access private renamingWorkspaces set function addToRenamingWorkspaces(service: WorkspaceService, workspaceId: string): void { @@ -233,6 +241,7 @@ describe("WorkspaceService bash monitor wake reconciler wiring", () => { const { config, historyService, cleanup } = await createTestHistoryService(); const events = new EventEmitter(); const backgroundProcessManager = Object.assign(events, { + cleanup: mock(() => Promise.resolve()), notifyMonitorWakeStateChanged: mock(() => undefined), getActiveMonitorCount: mock(() => 0), pullMonitorWakeSignals: mock(() => Promise.resolve([])), @@ -250,9 +259,467 @@ describe("WorkspaceService bash monitor wake reconciler wiring", () => { ), backgroundProcessManager, }); - return { config, service, events, cleanup }; + return { config, historyService, backgroundProcessManager, service, events, cleanup }; + } + + async function createActiveWakeHarness() { + const fixture = await createWakeWiringService(); + const { config, service, historyService, backgroundProcessManager } = fixture; + const workspaceId = "monitor-attention-owner"; + await config.addWorkspace("/tmp/monitor-attention-project", { + id: workspaceId, + name: workspaceId, + projectName: "monitor-attention-project", + projectPath: "/tmp/monitor-attention-project", + runtimeConfig: { type: "local" }, + }); + const model = "anthropic:claude-sonnet-4-5"; + const aiEmitter = new EventEmitter(); + const requests: Array[0]> = []; + const completions: Array>> = []; + const launched = new EventEmitter(); + let streaming = false; + const harness = await createAgentSessionHarness({ + workspaceId, + config, + historyService, + backgroundProcessManager, + aiEmitter, + aiServiceOverrides: { + isStreaming: () => streaming, + streamMessage: mock((request: Parameters[0]) => { + requests.push(request); + const completion = Promise.withResolvers(); + completions.push(completion); + streaming = true; + const messageId = "assistant-" + requests.length; + aiEmitter.emit("stream-start", { + type: "stream-start", + workspaceId, + messageId, + model, + startTime: Date.now(), + }); + launched.emit("start"); + return Promise.resolve(Ok({ messageId, completion: completion.promise })); + }), + }, + }); + const internal = service as unknown as { + aiService: typeof harness.aiService; + sessions: Map; + bashMonitorRecoveryPromise: Promise; + bashMonitorWakeReconciler: BashMonitorWakeReconciler; + pendingBashMonitorWakeIdleWaitsByOwner: Map>; + getDelegatedTurnContinuationSendOptions(workspaceId: string): Promise; + dispatchBashMonitorWake(dispatch: BashMonitorWakeDispatch): Promise<"in-flight" | "deferred">; + }; + await internal.bashMonitorRecoveryPromise; + internal.aiService = harness.aiService; + internal.sessions.set(workspaceId, harness.session); + internal.getDelegatedTurnContinuationSendOptions = () => + Promise.resolve({ model, agentId: "exec" }); + const signals: BashMonitorProcessSnapshot[] = []; + let shown = 0; + spyOn(backgroundProcessManager, "pullMonitorWakeSignals").mockImplementation(() => signals); + spyOn(backgroundProcessManager, "getMonitorWakeDeliveryState").mockImplementation(() => + Promise.resolve({ status: "settled", shownThroughOffset: shown, terminalStatusShown: false }) + ); + const reconciler = internal.bashMonitorWakeReconciler; + const dispatch = spyOn(internal, "dispatchBashMonitorWake"); + const complete = async (finishReason = "stop") => { + const messageId = "assistant-" + requests.length; + const message = createMuxMessage(messageId, "assistant", "final answer", { + model, + finishReason, + muxMetadata: requests[requests.length - 1].muxMetadata, + }); + await historyService.appendToHistory(workspaceId, message); + const completed = new Promise((resolve) => { + const unsubscribe = harness.session.onChatEvent(({ message: event }) => { + if (event.type === "stream-end") { + unsubscribe(); + resolve(); + } + }); + }); + streaming = false; + const streamEnd = { + type: "stream-end" as const, + workspaceId, + parts: [{ type: "text" as const, text: "final answer" }], + metadata: { model, finishReason }, + }; + aiEmitter.emit("stream-end", { ...streamEnd, messageId }); + completions[requests.length - 1].resolve({ status: "completed", streamEnd }); + await completed; + }; + const abort = (abortReason: "user" | "system") => { + const messageId = "assistant-" + requests.length; + const streamAbort = { type: "stream-abort" as const, workspaceId, metadata: { duration: 1 } }; + streaming = false; + aiEmitter.emit("stream-abort", { ...streamAbort, messageId, abortReason }); + completions[requests.length - 1].resolve({ status: "aborted", abortReason, streamAbort }); + }; + return { + ...fixture, + ...harness, + workspaceId, + model, + requests, + launched, + internal, + reconciler, + dispatch, + complete, + abort, + stopStream: spyOn(harness.aiService, "stopStream"), + addAttention: async (offset: number) => { + signals.splice( + 0, + signals.length, + ...["first", "second"].map((processId) => ({ + processId, + taskId: "bash:" + processId, + ownerWorkspaceId: workspaceId, + filter: "READY", + filterExclude: false, + script: "watch", + createdAt: "2026-01-01T00:00:00.000Z", + retired: false, + match: { throughOffset: offset, lines: ["READY " + offset], totalMatches: 1 }, + })) + ); + fixture.events.emit("monitor:match", workspaceId, {}); + await reconciler.reconcile(workspaceId); + }, + consume: async (offset: number) => { + shown = offset; + fixture.events.emit("output:shown", workspaceId, {}); + await reconciler.reconcile(workspaceId); + }, + finish: async () => { + await reconciler.dispose(workspaceId); + harness.session.dispose(); + await fixture.cleanup(); + }, + }; } + test("the SDK answers in the original stream after repeated owed wakes are consumed", async () => { + const h = await createActiveWakeHarness(); + let step = 0; + let offset = 0; + const sdkModel = new MockLanguageModelV3({ + doStream: () => { + step++; + const chunks: LanguageModelV3StreamPart[] = + step <= 6 + ? [ + { + type: "tool-call", + toolCallId: "tool-" + step, + toolName: step % 2 === 1 ? "held_tool" : "task_await", + input: "{}", + }, + ] + : [ + { type: "text-start", id: "answer" }, + { type: "text-delta", id: "answer", delta: "final answer" }, + { type: "text-end", id: "answer" }, + ]; + chunks.push({ + type: "finish", + finishReason: { unified: step <= 6 ? "tool-calls" : "stop", raw: undefined }, + usage: { + inputTokens: { total: 1, noCache: 1, cacheRead: 0, cacheWrite: 0 }, + outputTokens: { total: 1, text: 1, reasoning: 0 }, + }, + }); + return Promise.resolve({ + stream: simulateReadableStream({ chunks, initialDelayInMs: null, chunkDelayInMs: null }), + }); + }, + }); + try { + await h.session.sendMessage("original", { model: h.model, agentId: "exec" }); + const engine = new StreamManager(h.historyService) as unknown as { + createStopWhenCondition( + request: Pick[0], "hasQueuedMessages"> + ): Array<(options: { steps: unknown[] }) => boolean>; + }; + const result = streamText({ + model: sdkModel, + prompt: "run the monitored tasks", + stopWhen: engine.createStopWhenCondition(h.requests[0]), + tools: { + held_tool: tool({ + inputSchema: z.object({}), + execute: async () => { + offset += 10; + await h.addAttention(offset); + expect((await h.reconciler.snapshot(h.workspaceId)).pendingWakeKinds.size).toBe(2); + return "foreground finished"; + }, + }), + task_await: tool({ + inputSchema: z.object({}), + execute: async () => { + await h.consume(offset); + return "READY"; + }, + }), + }, + }); + expect(await result.text).toBe("final answer"); + expect(step).toBe(7); + expect(h.dispatch).toHaveBeenCalled(); + await h.complete(); + await h.internal.pendingBashMonitorWakeIdleWaitsByOwner.get(h.workspaceId); + await h.reconciler.reconcile(h.workspaceId); + expect(h.requests).toHaveLength(1); + const history = await h.historyService.getHistoryFromLatestBoundary(h.workspaceId); + expect(history.success && history.data.map((row) => row.role)).toEqual(["user", "assistant"]); + } finally { + await h.finish(); + } + }); + + test.each([false, true])( + "owed monitor attention never cuts an active tool (native=%s)", + async (providerExecuted) => { + const h = await createActiveWakeHarness(); + try { + expect( + (await h.session.sendMessage("original", { model: h.model, agentId: "exec" })).success + ).toBe(true); + for (const offset of [10, 20, 30]) { + h.aiEmitter.emit("tool-call-start", { + type: "tool-call-start", + workspaceId: h.workspaceId, + messageId: "assistant-1", + toolCallId: "held-tool", + toolName: "bash", + args: {}, + timestamp: Date.now(), + }); + await h.addAttention(offset); + expect((await h.reconciler.snapshot(h.workspaceId)).pendingWakeKinds.size).toBe(2); + expect(h.dispatch).toHaveBeenCalled(); + expect(h.requests[0].hasQueuedMessages?.("tool-end")).toBe(false); + h.aiEmitter.emit("tool-call-end", { + type: "tool-call-end", + workspaceId: h.workspaceId, + messageId: "assistant-1", + toolCallId: "held-tool", + toolName: "bash", + result: {}, + providerExecuted, + timestamp: Date.now(), + }); + expect(h.stopStream).not.toHaveBeenCalled(); + await h.consume(offset); + expect(h.requests[0].hasQueuedMessages?.("tool-end")).toBe(false); + } + await h.complete(); + await h.internal.pendingBashMonitorWakeIdleWaitsByOwner.get(h.workspaceId); + await h.reconciler.reconcile(h.workspaceId); + expect(h.requests).toHaveLength(1); + const history = await h.historyService.getHistoryFromLatestBoundary(h.workspaceId); + expect(history.success && history.data.map((row) => row.role)).toEqual([ + "user", + "assistant", + ]); + expect((await h.reconciler.snapshot(h.workspaceId)).pendingWakeKinds.size).toBe(0); + } finally { + await h.finish(); + } + } + ); + + test("owed attention does not hold a delegated completion open or inherit its closed correlation", async () => { + const h = await createActiveWakeHarness(); + const correlation = { + type: "workspace-turn-task" as const, + taskHandleId: "wst_turn", + ownerWorkspaceId: "parent", + turnId: "turn", + }; + try { + await h.session.sendMessage( + "delegated", + { model: h.model, agentId: "exec", muxMetadata: correlation }, + { synthetic: true, agentInitiated: true } + ); + await h.addAttention(10); + expect(h.service.hasPendingWorkspaceTurnContinuation(h.workspaceId, correlation)).toBe(false); + expect(h.service.hasPendingBashMonitorWakeContinuation(h.workspaceId)).toBe(false); + const next = new Promise((resolve) => h.launched.once("start", resolve)); + await h.complete(); + await next; + expect(h.requests).toHaveLength(2); + expect(h.requests[0].muxMetadata).toEqual(correlation); + expect(h.requests[1].muxMetadata).toBeUndefined(); + } finally { + await h.finish(); + } + }); + + test("full context discard retires owed attention before the active turn becomes idle", async () => { + const h = await createActiveWakeHarness(); + try { + await h.session.sendMessage("original", { model: h.model, agentId: "exec" }); + await h.addAttention(10); + const token = await h.reconciler.beginFullHistoryClear(h.workspaceId); + await h.reconciler.finishFullHistoryClear(token); + await h.complete(); + await h.internal.pendingBashMonitorWakeIdleWaitsByOwner.get(h.workspaceId); + await h.reconciler.reconcile(h.workspaceId); + expect(h.requests).toHaveLength(1); + expect((await h.reconciler.snapshot(h.workspaceId)).pendingWakeKinds.size).toBe(0); + } finally { + await h.finish(); + } + }); + + test("hard Stop retires owed attention without disarming future idle wakes", async () => { + const h = await createActiveWakeHarness(); + try { + await h.session.sendMessage("original", { model: h.model, agentId: "exec" }); + await h.addAttention(10); + spyOn(h.aiService, "stopStream").mockImplementation(async () => { + h.abort("user"); + await h.session.waitForIdle(); + return Ok(undefined); + }); + spyOn(h.aiService, "isStreaming").mockReturnValue(false); + expect((await h.service.interruptStream(h.workspaceId)).success).toBe(true); + await h.internal.pendingBashMonitorWakeIdleWaitsByOwner.get(h.workspaceId); + await h.reconciler.reconcile(h.workspaceId); + expect(h.requests).toHaveLength(1); + expect((await h.reconciler.snapshot(h.workspaceId)).pendingWakeKinds.size).toBe(0); + await h.addAttention(20); + expect(h.requests).toHaveLength(2); + } finally { + await h.finish(); + } + }); + + test.each(["options", "settings"] as const)( + "wake yields when a turn starts during %s admission", + async (gate) => { + const h = await createActiveWakeHarness(); + const entered = createDeferred(); + const release = createDeferred(); + const options = { model: h.model, agentId: "exec" }; + if (gate === "options") { + spyOn(h.internal, "getDelegatedTurnContinuationSendOptions").mockImplementationOnce( + async () => { + entered.resolve(); + await release.promise; + return options; + } + ); + } else { + const internal = h.service as unknown as { + maybePersistAISettingsFromOptions(): Promise; + }; + spyOn(internal, "maybePersistAISettingsFromOptions").mockImplementationOnce(async () => { + entered.resolve(); + await release.promise; + }); + } + try { + const attention = h.addAttention(10); + await entered.promise; + await h.session.sendMessage("original", options); + release.resolve(); + await attention; + expect(h.requests).toHaveLength(1); + expect(h.requests[0].hasQueuedMessages?.("tool-end")).toBe(false); + await h.consume(10); + await h.complete(); + await h.reconciler.reconcile(h.workspaceId); + expect(h.requests).toHaveLength(1); + } finally { + release.resolve(); + await h.finish(); + } + } + ); + + test("unconsumed attention coalesces after natural completion and idle attention starts promptly", async () => { + const h = await createActiveWakeHarness(); + try { + await h.session.sendMessage("original", { model: h.model, agentId: "exec" }); + await h.addAttention(10); + await h.reconciler.reconcile(h.workspaceId); + expect(h.requests).toHaveLength(1); + const next = new Promise((resolve) => h.launched.once("start", resolve)); + await h.complete(); + await next; + expect(h.requests).toHaveLength(2); + await h.reconciler.reconcile(h.workspaceId); + expect((await h.reconciler.snapshot(h.workspaceId)).pendingWakeKinds.size).toBe(0); + await h.complete(); + await h.addAttention(20); + expect(h.requests).toHaveLength(3); + } finally { + await h.finish(); + } + }); + + test.each([false, true])( + "manual tool-end input takes precedence over owed bash attention (native=%s)", + async (providerExecuted) => { + const h = await createActiveWakeHarness(); + try { + await h.session.sendMessage("original", { model: h.model, agentId: "exec" }); + await h.addAttention(10); + expect( + ( + await h.service.sendMessage(h.workspaceId, "manual", { + model: h.model, + agentId: "exec", + queueDispatchMode: "tool-end", + }) + ).success + ).toBe(true); + expect(h.requests[0].hasQueuedMessages?.("tool-end")).toBe(true); + h.aiEmitter.emit("tool-call-end", { + type: "tool-call-end", + workspaceId: h.workspaceId, + messageId: "assistant-1", + toolCallId: "tool", + toolName: "bash", + result: {}, + providerExecuted, + timestamp: Date.now(), + }); + expect(h.stopStream).toHaveBeenCalledTimes(providerExecuted ? 1 : 0); + await h.consume(10); + const next = new Promise((resolve) => h.launched.once("start", resolve)); + if (providerExecuted) { + h.abort("system"); + } else { + await h.complete("tool-calls"); + } + await next; + expect(h.requests).toHaveLength(2); + const history = await h.historyService.getHistoryFromLatestBoundary(h.workspaceId); + expect( + history.success && + history.data.filter((row) => row.role === "user").map((row) => row.parts[0]) + ).toEqual([ + expect.objectContaining({ type: "text", text: "original" }), + expect.objectContaining({ type: "text", text: "manual" }), + ]); + } finally { + await h.finish(); + } + } + ); + test("monitor lifecycle and shown-output events poke the reconciler", async () => { const { service, events, cleanup } = await createWakeWiringService(); const scheduleReconcile = mock(() => undefined); @@ -749,7 +1216,6 @@ describe("WorkspaceService bash monitor wake reconciler wiring", () => { ownerWorkspaceId: string; prompt: string; muxMetadata: { type: "bash-monitor-wake"; records: [] }; - dedupeKey: string; cancelSignal: AbortSignal; onAccepted(): Promise; onDeferred(): Promise; @@ -762,7 +1228,6 @@ describe("WorkspaceService bash monitor wake reconciler wiring", () => { ownerWorkspaceId: workspaceId, prompt: "wake", muxMetadata: { type: "bash-monitor-wake", records: [] }, - dedupeKey: "wake", cancelSignal: new AbortController().signal, onAccepted, onDeferred, @@ -775,7 +1240,7 @@ describe("WorkspaceService bash monitor wake reconciler wiring", () => { } }); - test("active session-backed streams queue monitor wakes at tool end", async () => { + test("active session-backed streams defer monitor attention until idle", async () => { const { config, service, cleanup } = await createWakeWiringService(); const workspaceId = "streaming-wake-owner"; await config.addWorkspace("/tmp/streaming-wake-project", { @@ -785,20 +1250,7 @@ describe("WorkspaceService bash monitor wake reconciler wiring", () => { projectPath: "/tmp/streaming-wake-project", runtimeConfig: { type: "local" }, }); - let queuedMode: string | undefined; - let queuedCancelState: { canceledBeforeAcceptance: boolean } | undefined; - const sendMessage = mock( - ( - _workspaceId: string, - _prompt: string, - options: { queueDispatchMode?: string }, - internal?: { cancelState?: { canceledBeforeAcceptance: boolean } } - ) => { - queuedMode = options.queueDispatchMode; - queuedCancelState = internal?.cancelState; - return Promise.resolve(Ok(undefined)); - } - ); + const sendMessage = mock(() => Promise.resolve(Ok(undefined))); const afterIdle = mock(() => undefined); const internal = service as unknown as { aiService: { isStreaming(workspaceId: string): boolean }; @@ -811,7 +1263,6 @@ describe("WorkspaceService bash monitor wake reconciler wiring", () => { ownerWorkspaceId: string; prompt: string; muxMetadata: { type: "bash-monitor-wake"; records: [] }; - dedupeKey: string; cancelSignal: AbortSignal; onAccepted(): Promise; onDeferred(): Promise; @@ -829,119 +1280,59 @@ describe("WorkspaceService bash monitor wake reconciler wiring", () => { ownerWorkspaceId: workspaceId, prompt: "wake", muxMetadata: { type: "bash-monitor-wake", records: [] }, - dedupeKey: "wake", cancelSignal: new AbortController().signal, onAccepted: () => Promise.resolve(), onDeferred: () => Promise.resolve(), }); - expect(outcome).toBe("in-flight"); - expect(sendMessage).toHaveBeenCalledTimes(1); - expect(queuedMode).toBe("tool-end"); - expect(queuedCancelState).toEqual({ canceledBeforeAcceptance: false }); - expect(afterIdle).not.toHaveBeenCalled(); + expect(outcome).toBe("deferred"); + expect(sendMessage).not.toHaveBeenCalled(); + expect(afterIdle).toHaveBeenCalledWith(workspaceId); } finally { await cleanup(); } }); - test("withdrawing a queued monitor wake removes it and releases its dedupe key", async () => { - const { config, service, cleanup } = await createWakeWiringService(); - const workspaceId = "withdrawn-wake-owner"; - await config.addWorkspace("/tmp/withdrawn-wake-project", { - id: workspaceId, - name: workspaceId, - projectName: "withdrawn-wake-project", - projectPath: "/tmp/withdrawn-wake-project", - runtimeConfig: { type: "local" }, + test("withdrawn idle wake rolls back admission and permits a fresh delivery", async () => { + const h = await createActiveWakeHarness(); + const entered = createDeferred(); + const release = createDeferred(); + const append = h.historyService.appendToHistory.bind(h.historyService); + spyOn(h.historyService, "appendToHistory").mockImplementationOnce(async (...args) => { + entered.resolve(); + await release.promise; + return append(...args); }); - const session = service.getOrCreateSession(workspaceId); - const queuedModes: Array<"tool-end" | "turn-end" | null> = []; - // The real sendMessage queues behind a busy session; mirror only that branch. - const sendMessage = mock( - ( - _workspaceId: string, - prompt: string, - options: SendMessageOptions, - internal?: { - synthetic?: boolean; - agentInitiated?: boolean; - queueDedupeKey?: string; - removableQueueDedupeKey?: boolean; - cancelState?: { canceledBeforeAcceptance: boolean }; - cancelSignal?: AbortSignal; - onCanceled?: (reason: string) => Promise | void; - } - ) => { - queuedModes.push( - session.queueMessage(prompt, options, { - synthetic: internal?.synthetic, - agentInitiated: internal?.agentInitiated, - dedupeKey: internal?.queueDedupeKey, - removableDedupeKey: internal?.removableQueueDedupeKey, - cancelState: internal?.cancelState, - cancelSignal: internal?.cancelSignal, - onCanceled: internal?.onCanceled, - }) - ); - return Promise.resolve(Ok(undefined)); - } - ); - const internal = service as unknown as { - aiService: { isStreaming(workspaceId: string): boolean }; - hasPendingQueuedOrPreparingTurn(workspaceId: string): boolean; - isBusyForMessage(workspaceId: string): boolean; - getDelegatedTurnContinuationSendOptions(workspaceId: string): Promise; - sendMessage: typeof sendMessage; - dispatchBashMonitorWake(dispatch: { - ownerWorkspaceId: string; - prompt: string; - muxMetadata: { type: "bash-monitor-wake"; records: [] }; - dedupeKey: string; - cancelSignal: AbortSignal; - onAccepted(): Promise; - onDeferred(): Promise; - }): Promise<"in-flight" | "deferred">; - }; - const dedupeKey = "bash-monitor-wake:" + workspaceId + ":dispatch-1"; - const onDeferred = mock(() => Promise.resolve()); - const dispatch = (cancelSignal: AbortSignal) => - internal.dispatchBashMonitorWake({ - ownerWorkspaceId: workspaceId, + const controller = new AbortController(); + const accepted = mock(() => Promise.resolve()); + const deferred = mock(() => Promise.resolve()); + const send = (cancelSignal: AbortSignal) => + h.internal.dispatchBashMonitorWake({ + ownerWorkspaceId: h.workspaceId, prompt: "wake", muxMetadata: { type: "bash-monitor-wake", records: [] }, - dedupeKey, cancelSignal, - onAccepted: () => Promise.resolve(), - onDeferred, + onAccepted: accepted, + onDeferred: deferred, }); try { - internal.aiService = { isStreaming: () => true }; - internal.hasPendingQueuedOrPreparingTurn = () => false; - internal.isBusyForMessage = () => true; - internal.getDelegatedTurnContinuationSendOptions = () => Promise.resolve({}); - internal.sendMessage = sendMessage; - - const controller = new AbortController(); - expect(await dispatch(controller.signal)).toBe("in-flight"); - expect(session.hasQueuedMessages("tool-end")).toBe(true); - - controller.abort("output already shown"); - expect(session.hasQueuedMessages()).toBe(false); - expect(service.removeQueuedMessagesByDedupeKeyPrefix(workspaceId, dedupeKey)).toEqual(Ok(0)); - await new Promise((resolve) => setTimeout(resolve, 0)); - expect(onDeferred).toHaveBeenCalledTimes(1); - - // Already withdrawn at dispatch: never reaches the send, so nothing can be enqueued. - expect(await dispatch(controller.signal)).toBe("deferred"); - expect(sendMessage).toHaveBeenCalledTimes(1); - expect(session.hasQueuedMessages()).toBe(false); - expect(onDeferred).toHaveBeenCalledTimes(1); - - expect(await dispatch(new AbortController().signal)).toBe("in-flight"); - expect(queuedModes).toEqual(["tool-end", "tool-end"]); + const dispatch = send(controller.signal); + await entered.promise; + controller.abort(); + release.resolve(); + await dispatch; + expect(accepted).not.toHaveBeenCalled(); + expect(deferred).toHaveBeenCalledTimes(1); + expect(h.requests).toHaveLength(0); + expect(h.session.hasQueuedMessages()).toBe(false); + const history = await h.historyService.getHistoryFromLatestBoundary(h.workspaceId); + expect(history.success && history.data).toEqual([]); + await send(new AbortController().signal); + expect(h.requests).toHaveLength(1); + expect(accepted).toHaveBeenCalledTimes(1); } finally { - await cleanup(); + release.resolve(); + await h.finish(); } }); diff --git a/src/node/services/workspaceService.ts b/src/node/services/workspaceService.ts index 819ee590112..3ce6955e7d5 100644 --- a/src/node/services/workspaceService.ts +++ b/src/node/services/workspaceService.ts @@ -2528,11 +2528,13 @@ export class WorkspaceService extends EventEmitter implements WorkspaceHost { const hasPendingTurn = this.hasPendingQueuedOrPreparingTurn(ownerWorkspaceId); const hasSessionBackedBusyState = this.isBusyForMessage(ownerWorkspaceId); const hasAiServiceStream = this.aiService.isStreaming(ownerWorkspaceId); - if (hasPendingTurn || (hasSessionBackedBusyState && !hasAiServiceStream)) { + // Cancelable attention must not cut a turn that can consume it in its current tool call. + // Keep it outside the queue so later manual tool-end input cannot be held behind it. + if (hasPendingTurn || hasSessionBackedBusyState) { this.scheduleBashMonitorWakeReconcileAfterIdle(ownerWorkspaceId); return "deferred"; } - if (hasAiServiceStream && !hasSessionBackedBusyState) { + if (hasAiServiceStream) { return "deferred"; } const sendOptions = @@ -2543,43 +2545,23 @@ export class WorkspaceService extends EventEmitter implements WorkspaceHost { return "deferred"; } - // Withdrawn while awaiting send options above: the abort listener below would never - // fire, and send preflight (which persists AI settings) has nothing left to admit. + // Withdrawal during send-option resolution must not enter preflight or persist settings. if (dispatch.cancelSignal.aborted) return "deferred"; let accepted = false; - // A queued wake can be superseded after dequeue. Share cancellation state so - // AgentSession can release PREPARING when cancellation wins before acceptance. - const cancelState = { canceledBeforeAcceptance: false }; - // Withdrawal (output already shown, process discarded, history cleared) must - // free the queue slot now, not at stream end: a lingering entry keeps the - // workspace reported busy and its dedupe key held. The key is unique per - // dispatch, so this cannot drop a newer wake's entry. - dispatch.cancelSignal.addEventListener( - "abort", - () => { - this.removeQueuedMessagesByDedupeKeyPrefix(ownerWorkspaceId, dispatch.dedupeKey, { - cancelReason: "Bash monitor wake withdrawn before dispatch.", - }); - }, - { once: true } - ); const sendResult = await this.sendMessage( ownerWorkspaceId, dispatch.prompt, { ...sendOptions, - queueDispatchMode: "tool-end", muxMetadata: dispatch.muxMetadata, }, { skipAutoResumeReset: true, synthetic: true, agentInitiated: true, - cancelState, + requireIdle: true, cancelSignal: dispatch.cancelSignal, - queueDedupeKey: dispatch.dedupeKey, - removableQueueDedupeKey: true, onAccepted: async () => { accepted = true; await dispatch.onAccepted(); @@ -11623,7 +11605,14 @@ export class WorkspaceService extends EventEmitter implements WorkspaceHost { } const session = this.getOrCreateSession(workspaceId); - const stopResult = await session.interruptStream(options); + const stopResult = options?.soft + ? await session.interruptStream(options) + : await this.bashMonitorHistoryLocks.withLock(workspaceId, async () => { + const result = await session.interruptStream(options); + // Retire already-owed attention before releasing idle dispatch after a hard Stop. + if (result.success) await this.bashMonitorWakeReconciler.consumeCurrent(workspaceId); + return result; + }); if (!stopResult.success) { // Interrupt failed, so clear hard-interrupt suppression we set above. if (!options?.soft) { @@ -14685,11 +14674,10 @@ export class WorkspaceService extends EventEmitter implements WorkspaceHost { } /** - * Send options for continuing a STILL-OPEN delegated workspace turn (bash-monitor - * wakes cut turns at tool boundaries). The delegated prompt's persisted - * retrySendOptions carry the turn's own settings — including per-turn overrides - * (agentId, model, strictAgentResolution) that are deliberately NOT in the - * workspace's persisted defaults when the launch used skipAiSettingsPersistence — + * Send options for continuing a STILL-OPEN delegated workspace turn. The delegated + * prompt's persisted retrySendOptions carry the turn's own settings — including + * per-turn overrides (agentId, model, strictAgentResolution) that are deliberately NOT + * in the workspace's persisted defaults when the launch used skipAiSettingsPersistence — * so resolving from workspace defaults would continue the turn under the wrong * agent. Openness is decided by the same rule as workspace-turn correlation * (inheritOpenWorkspaceTurnMetadata): only a correlated assistant cut with From 27acae7dd780f2e53ec318be6d137e517c993a4d Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Sun, 6 Sep 2026 23:43:34 +0000 Subject: [PATCH 04/31] fix(bash): retire owed attention before Stop without the history lock Codex review on c91286c4da: - hard Stop no longer takes bashMonitorHistoryLocks (a wake admission holds it across preflight and stream construction); consumeCurrent runs before the abort and is best-effort so a persistence failure cannot fail the Stop - stream-abort metadata carries the request-pinned metadataModel so aborted Coder-runtime streams keep their pricing identity in goal accounting --- src/common/orpc/schemas/stream.ts | 1 + .../agentSession.queueDispatch.test.ts | 22 ++++++--- src/node/services/agentSession.ts | 1 + src/node/services/streamManager.test.ts | 7 ++- src/node/services/streamManager.ts | 1 + src/node/services/workspaceService.test.ts | 47 +++++++++++++++++++ src/node/services/workspaceService.ts | 20 ++++---- 7 files changed, 82 insertions(+), 17 deletions(-) diff --git a/src/common/orpc/schemas/stream.ts b/src/common/orpc/schemas/stream.ts index af79f7faa17..52920e205b6 100644 --- a/src/common/orpc/schemas/stream.ts +++ b/src/common/orpc/schemas/stream.ts @@ -337,6 +337,7 @@ export const StreamAbortEventSchema = z.object({ contextProviderMetadata: z.record(z.string(), z.unknown()).optional(), duration: z.number().optional(), model: z.string().optional(), + metadataModel: z.string().optional(), }) .optional() .meta({ diff --git a/src/node/services/agentSession.queueDispatch.test.ts b/src/node/services/agentSession.queueDispatch.test.ts index 564df0146a6..379839532c4 100644 --- a/src/node/services/agentSession.queueDispatch.test.ts +++ b/src/node/services/agentSession.queueDispatch.test.ts @@ -170,9 +170,14 @@ describe("AgentSession queued message tool-call dispatch", () => { } ); - test.each([undefined, "anthropic:claude-opus-4-1"])( - "accounts aborted usage against the effective model %s with request fallback", - async (effectiveModel) => { + test.each([ + { effectiveModel: undefined, metadataModel: undefined }, + { effectiveModel: "anthropic:claude-opus-4-1", metadataModel: undefined }, + // A Coder runtime ID has no catalog price; the request-pinned identity must price it. + { effectiveModel: "coder:acme/opus", metadataModel: "anthropic:claude-opus-4-1" }, + ])( + "accounts aborted usage against the effective model $effectiveModel priced as $metadataModel", + async ({ effectiveModel, metadataModel }) => { const workspaceId = "abort-effective-model"; const aiEmitter = new EventEmitter(); const accounting = Promise.withResolvers(); @@ -218,12 +223,15 @@ describe("AgentSession queued message tool-call dispatch", () => { streamAbort: { type: "stream-abort", workspaceId, - metadata: { duration: 1, usage, model: effectiveModel }, + metadata: { duration: 1, usage, model: effectiveModel, metadataModel }, }, }); - expect(await accounting.promise).toBe( - getTotalCost(createDisplayUsage(usage, effectiveModel ?? TEST_MODEL)) ?? -1 - ); + const expectedCost = + getTotalCost( + createDisplayUsage(usage, effectiveModel ?? TEST_MODEL, undefined, metadataModel) + ) ?? 0; + expect(expectedCost).toBeGreaterThan(0); + expect(await accounting.promise).toBe(expectedCost); await session.waitForIdle(); } finally { session.dispose(); diff --git a/src/node/services/agentSession.ts b/src/node/services/agentSession.ts index 03cc639c277..51c3942d314 100644 --- a/src/node/services/agentSession.ts +++ b/src/node/services/agentSession.ts @@ -6272,6 +6272,7 @@ export class AgentSession { model: activeModelForAbort, usage: payload.metadata?.usage, providerMetadata: payload.metadata?.providerMetadata, + metadataModel: payload.metadata?.metadataModel, goalKind: this.activeStreamContext?.goalKind, agentInitiated: this.activeStreamContext?.agentInitiated, isCompaction: hadCompactionRequest, diff --git a/src/node/services/streamManager.test.ts b/src/node/services/streamManager.test.ts index cc4156ba37d..51d0a7127e1 100644 --- a/src/node/services/streamManager.test.ts +++ b/src/node/services/streamManager.test.ts @@ -6559,9 +6559,10 @@ describe("StreamManager - aborted stream usage persistence", () => { }); }); - test("emits the effective fallback model with aborted usage", async () => { + test("emits the effective fallback model and its pinned pricing identity with aborted usage", async () => { const streamManager = new StreamManager(historyService); - const effectiveModel = "anthropic:claude-opus-4-1"; + const effectiveModel = "coder:acme/opus"; + const pinnedMetadataModel = "anthropic:claude-opus-4-1"; const abort = Promise.withResolvers(); onTurnEngineEvent(streamManager, "stream-abort", (event) => abort.resolve(event)); const cleanupAborted = getPrivateMethodForTests( @@ -6574,11 +6575,13 @@ describe("StreamManager - aborted stream usage persistence", () => { { ...createAbortStreamInfo("fallback-message"), model: effectiveModel, + metadataModel: pinnedMetadataModel, }, "system" ); const event = StreamAbortEventSchema.parse(await abort.promise); expect(event.metadata?.model).toBe(effectiveModel); + expect(event.metadata?.metadataModel).toBe(pinnedMetadataModel); expect(event.metadata?.usage?.inputTokens).toBe(120); }); diff --git a/src/node/services/streamManager.ts b/src/node/services/streamManager.ts index 419c437e4ac..4314690bd60 100644 --- a/src/node/services/streamManager.ts +++ b/src/node/services/streamManager.ts @@ -1958,6 +1958,7 @@ export class StreamManager { providerMetadata, contextProviderMetadata, model: streamInfo.model, + metadataModel: streamInfo.metadataModel, }, abortReason, abandonPartial, diff --git a/src/node/services/workspaceService.test.ts b/src/node/services/workspaceService.test.ts index a2d26fc6ff0..ce39ac17cfb 100644 --- a/src/node/services/workspaceService.test.ts +++ b/src/node/services/workspaceService.test.ts @@ -605,6 +605,53 @@ describe("WorkspaceService bash monitor wake reconciler wiring", () => { } }); + test("hard Stop does not wait behind a wake admission holding the history lock", async () => { + const h = await createActiveWakeHarness(); + try { + await h.session.sendMessage("original", { model: h.model, agentId: "exec" }); + await h.addAttention(10); + const release = createDeferred(); + const locks = ( + h.service as unknown as { + bashMonitorHistoryLocks: { withLock(key: string, op: () => Promise): Promise }; + } + ).bashMonitorHistoryLocks; + const held = locks.withLock(h.workspaceId, () => release.promise); + spyOn(h.aiService, "stopStream").mockImplementation(async () => { + h.abort("user"); + await h.session.waitForIdle(); + return Ok(undefined); + }); + spyOn(h.aiService, "isStreaming").mockReturnValue(false); + expect((await h.service.interruptStream(h.workspaceId)).success).toBe(true); + release.resolve(); + await held; + await h.internal.pendingBashMonitorWakeIdleWaitsByOwner.get(h.workspaceId); + await h.reconciler.reconcile(h.workspaceId); + expect(h.requests).toHaveLength(1); + } finally { + await h.finish(); + } + }); + + test("hard Stop succeeds when retiring owed attention fails", async () => { + const h = await createActiveWakeHarness(); + try { + await h.session.sendMessage("original", { model: h.model, agentId: "exec" }); + spyOn(h.reconciler, "consumeCurrent").mockRejectedValueOnce(new Error("watermark write")); + spyOn(h.aiService, "stopStream").mockImplementation(async () => { + h.abort("user"); + await h.session.waitForIdle(); + return Ok(undefined); + }); + spyOn(h.aiService, "isStreaming").mockReturnValue(false); + expect((await h.service.interruptStream(h.workspaceId)).success).toBe(true); + expect(h.stopStream).toHaveBeenCalledTimes(1); + } finally { + await h.finish(); + } + }); + test.each(["options", "settings"] as const)( "wake yields when a turn starts during %s admission", async (gate) => { diff --git a/src/node/services/workspaceService.ts b/src/node/services/workspaceService.ts index 3ce6955e7d5..6025f31a5af 100644 --- a/src/node/services/workspaceService.ts +++ b/src/node/services/workspaceService.ts @@ -11605,14 +11605,18 @@ export class WorkspaceService extends EventEmitter implements WorkspaceHost { } const session = this.getOrCreateSession(workspaceId); - const stopResult = options?.soft - ? await session.interruptStream(options) - : await this.bashMonitorHistoryLocks.withLock(workspaceId, async () => { - const result = await session.interruptStream(options); - // Retire already-owed attention before releasing idle dispatch after a hard Stop. - if (result.success) await this.bashMonitorWakeReconciler.consumeCurrent(workspaceId); - return result; - }); + if (!options?.soft) { + // Retire owed attention before the abort: interruptStream returns after the abort + // settled, when an idle-triggered dispatch may already be admitting it. Consuming first + // withdraws any in-flight dispatch; monitors stay armed for new output. Best-effort, and + // never behind the history lock (a wake admission holds it across stream construction). + try { + await this.bashMonitorWakeReconciler.consumeCurrent(workspaceId); + } catch (error: unknown) { + log.warn("Failed to retire bash monitor attention before Stop", { workspaceId, error }); + } + } + const stopResult = await session.interruptStream(options); if (!stopResult.success) { // Interrupt failed, so clear hard-interrupt suppression we set above. if (!options?.soft) { From 12dd020f18811fbca63a5f6dd42a367229d3d97d Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Mon, 7 Sep 2026 00:25:43 +0000 Subject: [PATCH 05/31] fix(bash): withdrawn wakes never claim PREPARING; retire attention only on user Stop A cancelable monitor wake withdrawn past the acceptance point of no return (a hard Stop retiring owed attention while the wake is in goal sync or acceptance) keeps its durable rows but resolves Ok without starting a stream instead of claiming PREPARING after the Stop returned. interruptStream retires owed attention only when the caller passes retireBashMonitorAttention (user Stop button, Escape, command palette, ACP cancel); goal promotion, archive, ACP disconnect and send-now keep monitor output owed. --- .../ChatBarrier/StreamingBarrier.test.tsx | 10 +++- .../Messages/ChatBarrier/StreamingBarrier.tsx | 5 +- src/browser/hooks/useAIViewKeybinds.ts | 5 +- src/browser/utils/commands/sources.ts | 5 +- src/common/orpc/schemas/api.ts | 3 + src/node/acp/agent.ts | 5 +- .../agentSession.queueDispatch.test.ts | 13 +++- src/node/services/agentSession.ts | 10 ++++ src/node/services/workspaceService.test.ts | 59 ++++++++++++++++++- src/node/services/workspaceService.ts | 11 +++- 10 files changed, 114 insertions(+), 12 deletions(-) diff --git a/src/browser/features/Messages/ChatBarrier/StreamingBarrier.test.tsx b/src/browser/features/Messages/ChatBarrier/StreamingBarrier.test.tsx index 4978ca1dbc8..de582215194 100644 --- a/src/browser/features/Messages/ChatBarrier/StreamingBarrier.test.tsx +++ b/src/browser/features/Messages/ChatBarrier/StreamingBarrier.test.tsx @@ -177,7 +177,10 @@ describe("StreamingBarrier", () => { expect(setAutoRetryEnabled).toHaveBeenCalledWith({ workspaceId: "ws-1", enabled: false }); expect(setInterrupting).toHaveBeenCalledWith("ws-1"); - expect(interruptStream).toHaveBeenCalledWith({ workspaceId: "ws-1" }); + expect(interruptStream).toHaveBeenCalledWith({ + workspaceId: "ws-1", + options: { retireBashMonitorAttention: true }, + }); }); test("clicking stop during stream-start interrupts without setting interrupting state", () => { @@ -197,7 +200,10 @@ describe("StreamingBarrier", () => { expect(setAutoRetryEnabled).toHaveBeenCalledWith({ workspaceId: "ws-1", enabled: false }); expect(setInterrupting).not.toHaveBeenCalled(); - expect(interruptStream).toHaveBeenCalledWith({ workspaceId: "ws-1" }); + expect(interruptStream).toHaveBeenCalledWith({ + workspaceId: "ws-1", + options: { retireBashMonitorAttention: true }, + }); }); test("shows the barrier immediately on first appearance", () => { diff --git a/src/browser/features/Messages/ChatBarrier/StreamingBarrier.tsx b/src/browser/features/Messages/ChatBarrier/StreamingBarrier.tsx index 6e97be26da9..076fbc4eecf 100644 --- a/src/browser/features/Messages/ChatBarrier/StreamingBarrier.tsx +++ b/src/browser/features/Messages/ChatBarrier/StreamingBarrier.tsx @@ -285,7 +285,10 @@ export const StreamingBarrier: React.FC = ({ storeRaw.setInterrupting(workspaceId); } - void api.workspace.interruptStream({ workspaceId }); + void api.workspace.interruptStream({ + workspaceId, + options: { retireBashMonitorAttention: true }, + }); }; // Show settings hint during compaction if no custom compaction model is configured diff --git a/src/browser/hooks/useAIViewKeybinds.ts b/src/browser/hooks/useAIViewKeybinds.ts index bb312b2a573..442cd1a19c2 100644 --- a/src/browser/hooks/useAIViewKeybinds.ts +++ b/src/browser/hooks/useAIViewKeybinds.ts @@ -121,7 +121,10 @@ export function useAIViewKeybinds({ if (canInterrupt || showRetryBarrier) { e.preventDefault(); void api?.workspace.setAutoRetryEnabled?.({ workspaceId, enabled: false }); - void api?.workspace.interruptStream({ workspaceId }); + void api?.workspace.interruptStream({ + workspaceId, + options: { retireBashMonitorAttention: true }, + }); return; } } diff --git a/src/browser/utils/commands/sources.ts b/src/browser/utils/commands/sources.ts index fba9c4fb94e..a7985de1515 100644 --- a/src/browser/utils/commands/sources.ts +++ b/src/browser/utils/commands/sources.ts @@ -1218,7 +1218,10 @@ export function buildCoreSources(p: BuildSourcesParams): Array<() => CommandActi return; } await p.api?.workspace.setAutoRetryEnabled?.({ workspaceId: id, enabled: false }); - await p.api?.workspace.interruptStream({ workspaceId: id }); + await p.api?.workspace.interruptStream({ + workspaceId: id, + options: { retireBashMonitorAttention: true }, + }); }, }); list.push({ diff --git a/src/common/orpc/schemas/api.ts b/src/common/orpc/schemas/api.ts index 5b4d8b73f6b..7c3f92a26f5 100644 --- a/src/common/orpc/schemas/api.ts +++ b/src/common/orpc/schemas/api.ts @@ -1654,6 +1654,9 @@ export const workspace = { soft: z.boolean().optional(), abandonPartial: z.boolean().optional(), sendQueuedImmediately: z.boolean().optional(), + // User Stop only: owed bash-monitor attention is dismissed instead of waking the + // agent on the output it just stopped around. + retireBashMonitorAttention: z.boolean().optional(), }) .optional(), }), diff --git a/src/node/acp/agent.ts b/src/node/acp/agent.ts index ec10041fbbc..7b1d47c7a74 100644 --- a/src/node/acp/agent.ts +++ b/src/node/acp/agent.ts @@ -615,7 +615,10 @@ export class MuxAgent implements Agent { this.touchSession(sessionId); const workspaceId = this.sessionManager.getWorkspaceId(sessionId); - const interruptResult = await this.server.client.workspace.interruptStream({ workspaceId }); + const interruptResult = await this.server.client.workspace.interruptStream({ + workspaceId, + options: { retireBashMonitorAttention: true }, + }); if (!interruptResult.success) { throw new Error(`cancel: workspace.interruptStream failed: ${interruptResult.error}`); diff --git a/src/node/services/agentSession.queueDispatch.test.ts b/src/node/services/agentSession.queueDispatch.test.ts index 379839532c4..b09ade2166a 100644 --- a/src/node/services/agentSession.queueDispatch.test.ts +++ b/src/node/services/agentSession.queueDispatch.test.ts @@ -966,7 +966,11 @@ describe("AgentSession queued message tool-call dispatch", () => { test("rollback failure preserves the wake and continues acceptance", async () => { const workspaceId = "queue-dispatch-cancel-rollback-failure"; - const { session, cleanup, historyService } = await createAgentSessionHarness({ workspaceId }); + const streamMessage = mock(() => Promise.resolve(Ok(createStartedTurnHandle()))); + const { session, cleanup, historyService } = await createAgentSessionHarness({ + workspaceId, + aiServiceOverrides: { streamMessage }, + }); const originalAppend = historyService.appendToHistory.bind(historyService); let markAppendStarted: () => void = () => undefined; const appendStarted = new Promise((resolve) => { @@ -1019,6 +1023,9 @@ describe("AgentSession queued message tool-call dispatch", () => { expect(canceledReasons).toEqual([]); expect(cancelState.canceledBeforeAcceptance).toBe(false); expect(accepted).toBe(true); + // Accepted but withdrawn: the row stays durable and no turn starts. + expect(streamMessage).not.toHaveBeenCalled(); + expect(session.isBusy()).toBe(false); const history = await historyService.getHistoryFromLatestBoundary(workspaceId); expect(history.success).toBe(true); @@ -1144,9 +1151,11 @@ describe("AgentSession queued message tool-call dispatch", () => { assertPricedModelForBudgetedGoal: mock(() => Promise.resolve(Ok(undefined))), syncGoalModeWithChatTail, } as unknown as WorkspaceGoalService; + const streamMessage = mock(() => Promise.resolve(Ok(createStartedTurnHandle()))); const { session, cleanup, historyService } = await createAgentSessionHarness({ workspaceId, workspaceGoalService, + aiServiceOverrides: { streamMessage }, }); try { @@ -1181,6 +1190,8 @@ describe("AgentSession queued message tool-call dispatch", () => { expect(canceledReasons).toEqual([]); expect(cancelState.canceledBeforeAcceptance).toBe(false); expect(accepted).toBe(true); + expect(streamMessage).not.toHaveBeenCalled(); + expect(session.isBusy()).toBe(false); const history = await historyService.getHistoryFromLatestBoundary(workspaceId); expect(history.success).toBe(true); diff --git a/src/node/services/agentSession.ts b/src/node/services/agentSession.ts index 51c3942d314..8c795c29e3f 100644 --- a/src/node/services/agentSession.ts +++ b/src/node/services/agentSession.ts @@ -4150,6 +4150,16 @@ export class AgentSession { await notifyAcceptedPreStreamFailure(error); return Err(error); } + // A cancelable send withdrawn past the point of no return (a hard Stop retiring owed + // attention during goal sync or acceptance) keeps its durable, accepted rows but must not + // claim PREPARING: the Stop saw no turn to abort and has already returned. Withdrawn sends + // resolve Ok without a stream, like cancelBeforeAcceptance and the disposed path above. + if (cancelSignal?.aborted === true) { + if (this.coordinator.thinkingOverride === turnThinkingOverride) { + this.coordinator.releaseThinkingOverride(turnThinkingOverride); + } + return Ok(undefined); + } const preparedTurnAbortController = new AbortController(); this.preparingWorkspaceTurnMetadata = getWorkspaceTurnMuxMetadata(optionsForStream.muxMetadata); diff --git a/src/node/services/workspaceService.test.ts b/src/node/services/workspaceService.test.ts index ce39ac17cfb..b32531002a5 100644 --- a/src/node/services/workspaceService.test.ts +++ b/src/node/services/workspaceService.test.ts @@ -593,7 +593,10 @@ describe("WorkspaceService bash monitor wake reconciler wiring", () => { return Ok(undefined); }); spyOn(h.aiService, "isStreaming").mockReturnValue(false); - expect((await h.service.interruptStream(h.workspaceId)).success).toBe(true); + expect( + (await h.service.interruptStream(h.workspaceId, { retireBashMonitorAttention: true })) + .success + ).toBe(true); await h.internal.pendingBashMonitorWakeIdleWaitsByOwner.get(h.workspaceId); await h.reconciler.reconcile(h.workspaceId); expect(h.requests).toHaveLength(1); @@ -623,7 +626,10 @@ describe("WorkspaceService bash monitor wake reconciler wiring", () => { return Ok(undefined); }); spyOn(h.aiService, "isStreaming").mockReturnValue(false); - expect((await h.service.interruptStream(h.workspaceId)).success).toBe(true); + expect( + (await h.service.interruptStream(h.workspaceId, { retireBashMonitorAttention: true })) + .success + ).toBe(true); release.resolve(); await held; await h.internal.pendingBashMonitorWakeIdleWaitsByOwner.get(h.workspaceId); @@ -645,13 +651,60 @@ describe("WorkspaceService bash monitor wake reconciler wiring", () => { return Ok(undefined); }); spyOn(h.aiService, "isStreaming").mockReturnValue(false); - expect((await h.service.interruptStream(h.workspaceId)).success).toBe(true); + expect( + (await h.service.interruptStream(h.workspaceId, { retireBashMonitorAttention: true })) + .success + ).toBe(true); expect(h.stopStream).toHaveBeenCalledTimes(1); } finally { await h.finish(); } }); + test("an interrupt without retireBashMonitorAttention keeps owed attention for the idle wake", async () => { + const h = await createActiveWakeHarness(); + try { + await h.session.sendMessage("original", { model: h.model, agentId: "exec" }); + await h.addAttention(10); + spyOn(h.aiService, "stopStream").mockImplementation(async () => { + h.abort("system"); + await h.session.waitForIdle(); + return Ok(undefined); + }); + spyOn(h.aiService, "isStreaming").mockReturnValue(false); + expect((await h.service.interruptStream(h.workspaceId)).success).toBe(true); + await h.internal.pendingBashMonitorWakeIdleWaitsByOwner.get(h.workspaceId); + await h.reconciler.reconcile(h.workspaceId); + expect(h.requests).toHaveLength(2); + } finally { + await h.finish(); + } + }); + + test("hard Stop during a wake's acceptance window keeps the wake from streaming", async () => { + const h = await createActiveWakeHarness(); + try { + let stop: Promise> | undefined; + const unsubscribe = h.session.onChatEvent(({ message: event }) => { + // The wake's user row is emitted past the point of no return and before PREPARING. + if (event.type === "message" && event.role === "user" && stop == null) { + stop = h.service.interruptStream(h.workspaceId, { retireBashMonitorAttention: true }); + } + }); + await h.addAttention(10); + unsubscribe(); + expect(stop).toBeDefined(); + expect((await stop!).success).toBe(true); + expect(h.requests).toHaveLength(0); + expect(h.session.isBusy()).toBe(false); + expect((await h.reconciler.snapshot(h.workspaceId)).pendingWakeKinds.size).toBe(0); + await h.addAttention(20); + expect(h.requests).toHaveLength(1); + } finally { + await h.finish(); + } + }); + test.each(["options", "settings"] as const)( "wake yields when a turn starts during %s admission", async (gate) => { diff --git a/src/node/services/workspaceService.ts b/src/node/services/workspaceService.ts index 6025f31a5af..41f12e8c89f 100644 --- a/src/node/services/workspaceService.ts +++ b/src/node/services/workspaceService.ts @@ -11585,7 +11585,12 @@ export class WorkspaceService extends EventEmitter implements WorkspaceHost { async interruptStream( workspaceId: string, - options?: { soft?: boolean; abandonPartial?: boolean; sendQueuedImmediately?: boolean } + options?: { + soft?: boolean; + abandonPartial?: boolean; + sendQueuedImmediately?: boolean; + retireBashMonitorAttention?: boolean; + } ): Promise> { let releaseHardStopLatch: (() => void) | undefined; try { @@ -11605,7 +11610,9 @@ export class WorkspaceService extends EventEmitter implements WorkspaceHost { } const session = this.getOrCreateSession(workspaceId); - if (!options?.soft) { + // Only a user Stop dismisses owed attention; internal interrupts (goal promotion, archive, + // ACP disconnect, send-now) must not lose monitor output. + if (options?.retireBashMonitorAttention === true) { // Retire owed attention before the abort: interruptStream returns after the abort // settled, when an idle-triggered dispatch may already be admitting it. Consuming first // withdraws any in-flight dispatch; monitors stay armed for new output. Best-effort, and From a8f06c19640ab5b80d971c2d60eae8265860de10 Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Mon, 7 Sep 2026 01:15:36 +0000 Subject: [PATCH 06/31] fix(bash): retire attention on compaction Stop; withdraw wakes synchronously; defer during pending compaction - Compaction Stop paths (cancelCompaction, StreamingBarrier fallback) pass retireBashMonitorAttention so a match during compaction does not wake the agent. - consumeCurrent aborts the in-flight dispatch before taking the owner lock; interruptStream starts retirement before the abort and awaits it after. - Point-of-no-return withdrawal persists the user-abort abandon marker so startup recovery does not replay the retired wake. - Wake dispatch treats a pending mid-stream compaction as turn work; the idle waiter waits on a deterministic settle signal from AgentSession. --- .../ChatBarrier/StreamingBarrier.test.tsx | 2 +- .../Messages/ChatBarrier/StreamingBarrier.tsx | 2 +- src/browser/utils/compaction/handler.test.ts | 2 +- src/browser/utils/compaction/handler.ts | 2 +- .../agentSession.queueDispatch.test.ts | 22 ++++-- src/node/services/agentSession.ts | 27 ++++++- .../bashMonitorWakeReconciler.test.ts | 29 +++++++ .../services/bashMonitorWakeReconciler.ts | 6 +- src/node/services/workspaceService.test.ts | 78 ++++++++++++++++++- src/node/services/workspaceService.ts | 49 ++++++++---- tests/ipc/acp.promptCorrelation.test.ts | 1 + 11 files changed, 187 insertions(+), 33 deletions(-) diff --git a/src/browser/features/Messages/ChatBarrier/StreamingBarrier.test.tsx b/src/browser/features/Messages/ChatBarrier/StreamingBarrier.test.tsx index de582215194..a8dcda1e5d5 100644 --- a/src/browser/features/Messages/ChatBarrier/StreamingBarrier.test.tsx +++ b/src/browser/features/Messages/ChatBarrier/StreamingBarrier.test.tsx @@ -384,7 +384,7 @@ describe("StreamingBarrier", () => { expect(setInterrupting).not.toHaveBeenCalled(); expect(interruptStream).toHaveBeenCalledWith({ workspaceId: "ws-1", - options: { abandonPartial: true }, + options: { abandonPartial: true, retireBashMonitorAttention: true }, }); }); diff --git a/src/browser/features/Messages/ChatBarrier/StreamingBarrier.tsx b/src/browser/features/Messages/ChatBarrier/StreamingBarrier.tsx index 076fbc4eecf..ce5219f8693 100644 --- a/src/browser/features/Messages/ChatBarrier/StreamingBarrier.tsx +++ b/src/browser/features/Messages/ChatBarrier/StreamingBarrier.tsx @@ -276,7 +276,7 @@ export const StreamingBarrier: React.FC = ({ void api.workspace.interruptStream({ workspaceId, - options: { abandonPartial: true }, + options: { abandonPartial: true, retireBashMonitorAttention: true }, }); return; } diff --git a/src/browser/utils/compaction/handler.test.ts b/src/browser/utils/compaction/handler.test.ts index c4a72be3176..c590907ba8e 100644 --- a/src/browser/utils/compaction/handler.test.ts +++ b/src/browser/utils/compaction/handler.test.ts @@ -62,7 +62,7 @@ describe("cancelCompaction", () => { }); expect(interruptStream).toHaveBeenCalledWith({ workspaceId: "ws-1", - options: { abandonPartial: true }, + options: { abandonPartial: true, retireBashMonitorAttention: true }, }); expect(calls).toEqual(["edit", "interrupt"]); }); diff --git a/src/browser/utils/compaction/handler.ts b/src/browser/utils/compaction/handler.ts index 5f5a66db001..4dbdddddab6 100644 --- a/src/browser/utils/compaction/handler.ts +++ b/src/browser/utils/compaction/handler.ts @@ -102,7 +102,7 @@ export async function cancelCompaction( // Backend detects this and skips compaction (Ctrl+C flow) await client.workspace.interruptStream({ workspaceId, - options: { abandonPartial: true }, + options: { abandonPartial: true, retireBashMonitorAttention: true }, }); return true; diff --git a/src/node/services/agentSession.queueDispatch.test.ts b/src/node/services/agentSession.queueDispatch.test.ts index b09ade2166a..a4470b0a92f 100644 --- a/src/node/services/agentSession.queueDispatch.test.ts +++ b/src/node/services/agentSession.queueDispatch.test.ts @@ -1195,15 +1195,27 @@ describe("AgentSession queued message tool-call dispatch", () => { const history = await historyService.getHistoryFromLatestBoundary(workspaceId); expect(history.success).toBe(true); - if (history.success) { - expect( - history.data.some((message) => + const wakeRow = history.success + ? history.data.find((message) => message.parts.some( (part) => part.type === "text" && part.text === "Background monitor wake" ) ) - ).toBe(true); - } + : undefined; + expect(wakeRow).toBeDefined(); + + // The accepted row has no assistant follow-up, so startup recovery would otherwise treat + // it as an interrupted turn and replay the withdrawn wake. + const preferencePath = ( + session as unknown as { getAutoRetryPreferencePath: () => string } + ).getAutoRetryPreferencePath(); + const persisted = (await Bun.file(preferencePath).json()) as { + startupAutoRetryAbandon?: unknown; + }; + expect(persisted.startupAutoRetryAbandon).toEqual({ + reason: "aborted", + userMessageId: wakeRow?.id, + }); } finally { releaseInitialSync(); session.dispose(); diff --git a/src/node/services/agentSession.ts b/src/node/services/agentSession.ts index 8c795c29e3f..357c18ab2c8 100644 --- a/src/node/services/agentSession.ts +++ b/src/node/services/agentSession.ts @@ -759,6 +759,7 @@ export class AgentSession { /** Prevent duplicate mid-stream compaction interrupts while we are already transitioning. */ private midStreamCompactionPending = false; + private midStreamCompactionSettledWaiters: Array<() => void> = []; private continuousCompactionAbandoned = false; private continuousCompactionStopped = false; private continuousCompactionObserving = false; @@ -4153,11 +4154,14 @@ export class AgentSession { // A cancelable send withdrawn past the point of no return (a hard Stop retiring owed // attention during goal sync or acceptance) keeps its durable, accepted rows but must not // claim PREPARING: the Stop saw no turn to abort and has already returned. Withdrawn sends - // resolve Ok without a stream, like cancelBeforeAcceptance and the disposed path above. + // resolve Ok without a stream, like cancelBeforeAcceptance and the disposed path above. The + // trailing UI-visible row would otherwise read as an interrupted turn to startup recovery, + // so record the same abandon marker a user-aborted stream leaves. if (cancelSignal?.aborted === true) { if (this.coordinator.thinkingOverride === turnThinkingOverride) { this.coordinator.releaseThinkingOverride(turnThinkingOverride); } + await this.updateStartupAutoRetryAbandonFromAbort("user", userMessage.id); return Ok(undefined); } @@ -4994,7 +4998,7 @@ export class AgentSession { // Reserve through dispatch and cleanup, not just the compactor's apply latch. // Waiters/duplicate invalidations never own or clear these flags. if (this.continuousCompactionObservation === observation) { - this.midStreamCompactionPending = false; + this.settleMidStreamCompaction(); this.continuousCompactionStopped = false; this.continuousCompactionObserving = false; this.continuousCompactionObservation = null; @@ -5226,7 +5230,7 @@ export class AgentSession { } } } finally { - this.midStreamCompactionPending = false; + this.settleMidStreamCompaction(); // Preflight drains deferred to this pending compaction have no other retry: if the // compaction request never became a turn, release the queue now (no-op when it did). this.drainQueuedMessagesIfIdle(); @@ -6670,7 +6674,7 @@ export class AgentSession { ...this.getContinuousCompactionContext(context.modelString, context.options), phase: "mid-stream", }); - this.midStreamCompactionPending = false; + this.settleMidStreamCompaction(); await this.finishContinuousCompaction(result === "applied", context); }); } catch (error) { @@ -6888,6 +6892,21 @@ export class AgentSession { return this.isBusy() || this.midStreamCompactionPending; } + /** + * Resolves once no mid-stream compaction request is pending. The window closes with no + * chat event when the compaction request never becomes a turn, so idle waiters need this + * signal rather than the stream lifecycle. + */ + waitForMidStreamCompactionSettled(): Promise { + if (!this.midStreamCompactionPending) return Promise.resolve(); + return new Promise((resolve) => this.midStreamCompactionSettledWaiters.push(resolve)); + } + + private settleMidStreamCompaction(): void { + this.midStreamCompactionPending = false; + for (const resolve of this.midStreamCompactionSettledWaiters.splice(0)) resolve(); + } + /** * Number of queued message entries (including synthetic/internal ones). The * interrupt_active archive path compares this against the delegated queued turns it is diff --git a/src/node/services/bashMonitorWakeReconciler.test.ts b/src/node/services/bashMonitorWakeReconciler.test.ts index fa4ba994d5c..6cf0d02267b 100644 --- a/src/node/services/bashMonitorWakeReconciler.test.ts +++ b/src/node/services/bashMonitorWakeReconciler.test.ts @@ -60,6 +60,7 @@ describe("BashMonitorWakeReconciler", () => { let removedOwners: string[]; let dropped: string[]; let droppedGenerations: Array; + let acknowledgeGate: ReturnType> | undefined; let reconciler: BashMonitorWakeReconciler; beforeEach(async () => { @@ -74,6 +75,7 @@ describe("BashMonitorWakeReconciler", () => { removedOwners = []; dropped = []; droppedGenerations = []; + acknowledgeGate = undefined; reconciler = new BashMonitorWakeReconciler({ sessionsDir: root, processManager: { @@ -84,6 +86,7 @@ describe("BashMonitorWakeReconciler", () => { processId, ...(matchedThroughOffset != null ? { matchedThroughOffset } : {}), }); + return acknowledgeGate?.promise; }, dropRetiredMonitor: (processId, createdAt) => { droppedGenerations.push(createdAt); @@ -157,6 +160,32 @@ describe("BashMonitorWakeReconciler", () => { expect(dispatches[1].cancelSignal.aborted).toBe(false); }); + test("consumeCurrent withdraws an in-flight wake without waiting behind acceptance I/O", async () => { + live = [liveSnapshot()]; + await reconciler.reconcile(OWNER); + const wake = dispatches[0]; + + // Acceptance holds the owner lock while acknowledging the process; a Stop must still + // withdraw the wake immediately so the admission can bail before claiming a turn. + acknowledgeGate = Promise.withResolvers(); + const accepted = wake.onAccepted(); + while (acknowledged.length === 0) { + await new Promise((resolve) => setTimeout(resolve, 1)); + } + + const consumed = reconciler.consumeCurrent(OWNER); + try { + expect(wake.cancelSignal.aborted).toBe(true); + } finally { + acknowledgeGate.resolve(); + } + await accepted; + await consumed; + expect(acknowledged).toHaveLength(1); + await reconciler.reconcile(OWNER); + expect(dispatches).toHaveLength(1); + }); + test("keeps dead registry evidence until the queued wake is accepted", async () => { rows = [registryRecord()]; diff --git a/src/node/services/bashMonitorWakeReconciler.ts b/src/node/services/bashMonitorWakeReconciler.ts index b1ee34225b4..03d3e7bfe9d 100644 --- a/src/node/services/bashMonitorWakeReconciler.ts +++ b/src/node/services/bashMonitorWakeReconciler.ts @@ -439,7 +439,6 @@ export class BashMonitorWakeReconciler { }); } async beginFullHistoryClear(ownerWorkspaceId: string): Promise { - this.abortDispatch(ownerWorkspaceId); await this.consumeCurrent(ownerWorkspaceId); return { ownerWorkspaceId }; } @@ -591,6 +590,11 @@ export class BashMonitorWakeReconciler { } async consumeCurrent(ownerWorkspaceId: string): Promise { + // Withdraw before taking the lock: an acceptance in progress holds it across watermark, + // registry, and process-acknowledgement I/O, and a hard Stop must cancel the admission + // without waiting behind that. The lock slot is reserved synchronously too, ahead of any + // reconcile the stop's own stream abort triggers. + this.abortDispatch(ownerWorkspaceId); await this.locks.withLock(ownerWorkspaceId, async () => { this.abortDispatch(ownerWorkspaceId); const collected = await this.collect(ownerWorkspaceId, false); diff --git a/src/node/services/workspaceService.test.ts b/src/node/services/workspaceService.test.ts index b32531002a5..c741361af3b 100644 --- a/src/node/services/workspaceService.test.ts +++ b/src/node/services/workspaceService.test.ts @@ -1355,7 +1355,6 @@ describe("WorkspaceService bash monitor wake reconciler wiring", () => { const internal = service as unknown as { aiService: { isStreaming(workspaceId: string): boolean }; hasPendingQueuedOrPreparingTurn(workspaceId: string): boolean; - isBusyForMessage(workspaceId: string): boolean; scheduleBashMonitorWakeReconcileAfterIdle(workspaceId: string): void; getDelegatedTurnContinuationSendOptions(workspaceId: string): Promise; sendMessage: typeof sendMessage; @@ -1369,9 +1368,60 @@ describe("WorkspaceService bash monitor wake reconciler wiring", () => { }): Promise<"in-flight" | "deferred">; }; try { + spyOn(service.getOrCreateSession(workspaceId), "isBusy").mockReturnValue(true); internal.aiService = { isStreaming: () => true }; internal.hasPendingQueuedOrPreparingTurn = () => false; - internal.isBusyForMessage = () => true; + internal.scheduleBashMonitorWakeReconcileAfterIdle = afterIdle; + internal.getDelegatedTurnContinuationSendOptions = () => Promise.resolve({}); + internal.sendMessage = sendMessage; + + const outcome = await internal.dispatchBashMonitorWake({ + ownerWorkspaceId: workspaceId, + prompt: "wake", + muxMetadata: { type: "bash-monitor-wake", records: [] }, + cancelSignal: new AbortController().signal, + onAccepted: () => Promise.resolve(), + onDeferred: () => Promise.resolve(), + }); + + expect(outcome).toBe("deferred"); + expect(sendMessage).not.toHaveBeenCalled(); + expect(afterIdle).toHaveBeenCalledWith(workspaceId); + } finally { + await cleanup(); + } + }); + + test("pending mid-stream compaction defers monitor attention like an active turn", async () => { + const { config, service, cleanup } = await createWakeWiringService(); + const workspaceId = "compacting-wake-owner"; + await config.addWorkspace("/tmp/compacting-wake-project", { + id: workspaceId, + name: workspaceId, + projectName: "compacting-wake-project", + projectPath: "/tmp/compacting-wake-project", + runtimeConfig: { type: "local" }, + }); + const sendMessage = mock(() => Promise.resolve(Ok(undefined))); + const afterIdle = mock(() => undefined); + const internal = service as unknown as { + scheduleBashMonitorWakeReconcileAfterIdle(workspaceId: string): void; + getDelegatedTurnContinuationSendOptions(workspaceId: string): Promise; + sendMessage: typeof sendMessage; + dispatchBashMonitorWake(dispatch: { + ownerWorkspaceId: string; + prompt: string; + muxMetadata: { type: "bash-monitor-wake"; records: [] }; + cancelSignal: AbortSignal; + onAccepted(): Promise; + onDeferred(): Promise; + }): Promise<"in-flight" | "deferred">; + }; + try { + // Between the stopped stream and its compaction request the coordinator is idle and no + // stream is running; only the session's pending flag marks the turn work. + const session = service.getOrCreateSession(workspaceId); + Reflect.set(session, "midStreamCompactionPending", true); internal.scheduleBashMonitorWakeReconcileAfterIdle = afterIdle; internal.getDelegatedTurnContinuationSendOptions = () => Promise.resolve({}); internal.sendMessage = sendMessage; @@ -6946,6 +6996,7 @@ describe("WorkspaceService truncateHistory goal acknowledgment", () => { } const session = { isBusy: mock(() => busy), + hasActiveOrPendingTurnWork: mock(() => busy), hasQueuedMessages: mock(() => false), hasPendingAutoRetry: mock(() => pendingAutoRetry), waitForIdle, @@ -6988,6 +7039,29 @@ describe("WorkspaceService truncateHistory goal acknowledgment", () => { } }); + test("idle wait outlasts a pending mid-stream compaction request", async () => { + const { workspaceService, cleanup } = await createServices(); + const workspaceId = "idle-wait-pending-compaction"; + const session = workspaceService.getOrCreateSession(workspaceId); + const settle = Reflect.get(session, "settleMidStreamCompaction") as () => void; + try { + Reflect.set(session, "midStreamCompactionPending", true); + let resolved = false; + const waitPromise = workspaceService.waitForIdleAndNoQueuedMessages(workspaceId).then(() => { + resolved = true; + }); + await drainPendingDispatches(); + expect(resolved).toBe(false); + + // The compaction request never became a turn: no stream event fires, only the window closes. + settle.call(session); + await waitPromise; + expect(resolved).toBe(true); + } finally { + await cleanup(); + } + }); + test("destructive clear waits for startup monitor recovery discovery", async () => { const { historyService, workspaceService, cleanup } = await createServices(); const workspaceId = "clear-waits-for-monitor-recovery"; diff --git a/src/node/services/workspaceService.ts b/src/node/services/workspaceService.ts index 41f12e8c89f..2eb7cc98d39 100644 --- a/src/node/services/workspaceService.ts +++ b/src/node/services/workspaceService.ts @@ -2526,7 +2526,10 @@ export class WorkspaceService extends EventEmitter implements WorkspaceHost { return "in-flight"; } const hasPendingTurn = this.hasPendingQueuedOrPreparingTurn(ownerWorkspaceId); - const hasSessionBackedBusyState = this.isBusyForMessage(ownerWorkspaceId); + // Pending mid-stream compaction counts as turn work: the session reads idle between the + // stopped stream and its compaction request, which the session sends directly. + const hasSessionBackedBusyState = + this.sessions.get(ownerWorkspaceId)?.hasActiveOrPendingTurnWork() === true; const hasAiServiceStream = this.aiService.isStreaming(ownerWorkspaceId); // Cancelable attention must not cut a turn that can consume it in its current tool call. // Keep it outside the queue so later manual tool-end input cannot be held behind it. @@ -11611,19 +11614,22 @@ export class WorkspaceService extends EventEmitter implements WorkspaceHost { const session = this.getOrCreateSession(workspaceId); // Only a user Stop dismisses owed attention; internal interrupts (goal promotion, archive, - // ACP disconnect, send-now) must not lose monitor output. - if (options?.retireBashMonitorAttention === true) { - // Retire owed attention before the abort: interruptStream returns after the abort - // settled, when an idle-triggered dispatch may already be admitting it. Consuming first - // withdraws any in-flight dispatch; monitors stay armed for new output. Best-effort, and - // never behind the history lock (a wake admission holds it across stream construction). - try { - await this.bashMonitorWakeReconciler.consumeCurrent(workspaceId); - } catch (error: unknown) { - log.warn("Failed to retire bash monitor attention before Stop", { workspaceId, error }); - } - } + // ACP disconnect, send-now) must not lose monitor output. Start retiring before the abort: + // consumeCurrent withdraws an in-flight dispatch synchronously and reserves the reconciler + // lock ahead of the reconcile this abort's idle transition triggers, so the abort itself + // never waits behind acceptance I/O. Monitors stay armed for new output. Best-effort, and + // never behind the history lock (a wake admission holds it across stream construction). + const retirement = + options?.retireBashMonitorAttention === true + ? this.bashMonitorWakeReconciler.consumeCurrent(workspaceId).catch((error: unknown) => { + log.warn("Failed to retire bash monitor attention before Stop", { + workspaceId, + error, + }); + }) + : undefined; const stopResult = await session.interruptStream(options); + await retirement; if (!stopResult.success) { // Interrupt failed, so clear hard-interrupt suppression we set above. if (!options?.soft) { @@ -11996,11 +12002,21 @@ export class WorkspaceService extends EventEmitter implements WorkspaceHost { return; } - while (session.isBusy() || session.hasQueuedMessages() || session.hasPendingAutoRetry()) { + // Pending mid-stream compaction is turn work the coordinator cannot see: the session reads + // idle until the compaction request claims PREPARING. + const hasTurnWork = () => + session.hasActiveOrPendingTurnWork() || + session.hasQueuedMessages() || + session.hasPendingAutoRetry(); + while (hasTurnWork()) { if (session.isBusy()) { await session.waitForIdle(); continue; } + if (session.hasActiveOrPendingTurnWork()) { + await session.waitForMidStreamCompactionSettled(); + continue; + } await new Promise((resolve) => { let settled = false; @@ -12020,13 +12036,12 @@ export class WorkspaceService extends EventEmitter implements WorkspaceHost { eventType === "stream-lifecycle"; const queuedOrRetryCleared = (eventType === "queued-message-changed" || eventType === "auto-retry-abandoned") && - !session.hasQueuedMessages() && - !session.hasPendingAutoRetry(); + !hasTurnWork(); if (retryStartedOrTurnPhaseChanged || queuedOrRetryCleared) { finish(); } }); - if (!session.hasQueuedMessages() && !session.hasPendingAutoRetry()) { + if (!hasTurnWork()) { finish(); } }); diff --git a/tests/ipc/acp.promptCorrelation.test.ts b/tests/ipc/acp.promptCorrelation.test.ts index 9109ade9411..fbf4a75be1d 100644 --- a/tests/ipc/acp.promptCorrelation.test.ts +++ b/tests/ipc/acp.promptCorrelation.test.ts @@ -885,6 +885,7 @@ describe("ACP prompt stream correlation", () => { expect(harness.interruptCalls).toEqual([ { workspaceId: newSessionResponse.sessionId, + options: { retireBashMonitorAttention: true }, }, ]); From 1f3cc0be6dd28d68ac635ebbdf1f949718fee8bf Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Mon, 7 Sep 2026 01:41:19 +0000 Subject: [PATCH 07/31] fix(bash): keep compaction pending through the follow-up dispatch; commit retirement only after a successful stop - The prefix-swap-invalidated path no longer settles the mid-stream compaction window before finishContinuousCompaction dispatches the saved continuation; the observation's finally settles it afterwards, as the usage-delta path already does. - consumeCurrent takes a commit gate: it still withdraws the in-flight wake and reserves the reconciler lock before the abort, but advances watermarks only once the stop succeeded. A failed stop leaves the signals owed and schedules a reconcile. --- .../agentSession.continuousCompaction.test.ts | 9 +++++- src/node/services/agentSession.ts | 3 +- .../bashMonitorWakeReconciler.test.ts | 14 ++++++++++ .../services/bashMonitorWakeReconciler.ts | 12 ++++++-- src/node/services/workspaceService.test.ts | 20 +++++++++++++ src/node/services/workspaceService.ts | 28 +++++++++++++------ 6 files changed, 73 insertions(+), 13 deletions(-) diff --git a/src/node/services/agentSession.continuousCompaction.test.ts b/src/node/services/agentSession.continuousCompaction.test.ts index 1085dd3d3ba..1f3a6c7f907 100644 --- a/src/node/services/agentSession.continuousCompaction.test.ts +++ b/src/node/services/agentSession.continuousCompaction.test.ts @@ -718,6 +718,7 @@ describe("AgentSession continuous compaction wiring", () => { async (eventType) => { const h = await setup(); const resumed = deferred(); + const settled = deferred(); const order: string[] = []; let starts = 0; spyOn(h.aiService, "streamMessage").mockImplementation(() => { @@ -774,6 +775,11 @@ describe("AgentSession continuous compaction wiring", () => { dispatchOptions: { source: "internal-resume" }, }); order.push("apply"); + // Idle waiters (monitor wakes) must stay parked until the continuation is sent. + void h.session.waitForMidStreamCompactionSettled().then(() => { + order.push("settled"); + settled.resolve(); + }); await appendBoundary(h, followUp); return true; } @@ -819,7 +825,8 @@ describe("AgentSession continuous compaction wiring", () => { observationFinished.resolve(); } await resumed.promise; - expect(order).toEqual(["stop", "apply", "latch-released", "resume"]); + await settled.promise; + expect(order).toEqual(["stop", "apply", "latch-released", "resume", "settled"]); const history = await rows(h); expect(history.some((row) => row.metadata?.muxMetadata?.type === "compaction-request")).toBe( false diff --git a/src/node/services/agentSession.ts b/src/node/services/agentSession.ts index 357c18ab2c8..a1de0d7d92d 100644 --- a/src/node/services/agentSession.ts +++ b/src/node/services/agentSession.ts @@ -6674,7 +6674,8 @@ export class AgentSession { ...this.getContinuousCompactionContext(context.modelString, context.options), phase: "mid-stream", }); - this.settleMidStreamCompaction(); + // The observation's finally settles the pending window only after this dispatches the + // continuation; settling earlier would let an idle waiter race the follow-up send. await this.finishContinuousCompaction(result === "applied", context); }); } catch (error) { diff --git a/src/node/services/bashMonitorWakeReconciler.test.ts b/src/node/services/bashMonitorWakeReconciler.test.ts index 6cf0d02267b..adb20ee4bb5 100644 --- a/src/node/services/bashMonitorWakeReconciler.test.ts +++ b/src/node/services/bashMonitorWakeReconciler.test.ts @@ -186,6 +186,20 @@ describe("BashMonitorWakeReconciler", () => { expect(dispatches).toHaveLength(1); }); + test("consumeCurrent withdraws the wake but keeps signals owed when the commit is refused", async () => { + live = [liveSnapshot()]; + await reconciler.reconcile(OWNER); + const wake = dispatches[0]; + + await reconciler.consumeCurrent(OWNER, () => Promise.resolve(false)); + + expect(wake.cancelSignal.aborted).toBe(true); + expect(acknowledged).toEqual([]); + await reconciler.reconcile(OWNER); + expect(dispatches).toHaveLength(2); + expect(dispatches[1].cancelSignal.aborted).toBe(false); + }); + test("keeps dead registry evidence until the queued wake is accepted", async () => { rows = [registryRecord()]; diff --git a/src/node/services/bashMonitorWakeReconciler.ts b/src/node/services/bashMonitorWakeReconciler.ts index 03d3e7bfe9d..077c8f15b45 100644 --- a/src/node/services/bashMonitorWakeReconciler.ts +++ b/src/node/services/bashMonitorWakeReconciler.ts @@ -589,19 +589,27 @@ export class BashMonitorWakeReconciler { state.dispatch = undefined; } - async consumeCurrent(ownerWorkspaceId: string): Promise { + /** + * Withdraws the in-flight wake and consumes every outstanding signal. When `commit` is given, + * the durable consumption waits for it under the lock and is skipped when it resolves false, + * leaving the withdrawn signals owed to the next reconcile. + */ + async consumeCurrent(ownerWorkspaceId: string, commit?: () => Promise): Promise { // Withdraw before taking the lock: an acceptance in progress holds it across watermark, // registry, and process-acknowledgement I/O, and a hard Stop must cancel the admission // without waiting behind that. The lock slot is reserved synchronously too, ahead of any // reconcile the stop's own stream abort triggers. this.abortDispatch(ownerWorkspaceId); - await this.locks.withLock(ownerWorkspaceId, async () => { + const committed = await this.locks.withLock(ownerWorkspaceId, async () => { this.abortDispatch(ownerWorkspaceId); + if (commit != null && !(await commit())) return false; const collected = await this.collect(ownerWorkspaceId, false); const consumed = [...collected.signals, ...collected.autoConsumed]; await this.advanceWatermarks(ownerWorkspaceId, collected.watermarks, consumed); await this.cleanup(consumed); + return true; }); + if (!committed) this.scheduleReconcile(ownerWorkspaceId); } private async collect( diff --git a/src/node/services/workspaceService.test.ts b/src/node/services/workspaceService.test.ts index c741361af3b..88379979e2e 100644 --- a/src/node/services/workspaceService.test.ts +++ b/src/node/services/workspaceService.test.ts @@ -661,6 +661,26 @@ describe("WorkspaceService bash monitor wake reconciler wiring", () => { } }); + test("a failed hard Stop keeps owed attention for the idle wake", async () => { + const h = await createActiveWakeHarness(); + try { + await h.session.sendMessage("original", { model: h.model, agentId: "exec" }); + await h.addAttention(10); + h.stopStream.mockResolvedValueOnce(Err("stop failed")); + expect( + (await h.service.interruptStream(h.workspaceId, { retireBashMonitorAttention: true })) + .success + ).toBe(false); + expect((await h.reconciler.snapshot(h.workspaceId)).pendingWakeKinds.size).toBe(2); + await h.complete(); + await h.internal.pendingBashMonitorWakeIdleWaitsByOwner.get(h.workspaceId); + await h.reconciler.reconcile(h.workspaceId); + expect(h.requests).toHaveLength(2); + } finally { + await h.finish(); + } + }); + test("an interrupt without retireBashMonitorAttention keeps owed attention for the idle wake", async () => { const h = await createActiveWakeHarness(); try { diff --git a/src/node/services/workspaceService.ts b/src/node/services/workspaceService.ts index 2eb7cc98d39..95c0a3564ad 100644 --- a/src/node/services/workspaceService.ts +++ b/src/node/services/workspaceService.ts @@ -11617,18 +11617,28 @@ export class WorkspaceService extends EventEmitter implements WorkspaceHost { // ACP disconnect, send-now) must not lose monitor output. Start retiring before the abort: // consumeCurrent withdraws an in-flight dispatch synchronously and reserves the reconciler // lock ahead of the reconcile this abort's idle transition triggers, so the abort itself - // never waits behind acceptance I/O. Monitors stay armed for new output. Best-effort, and - // never behind the history lock (a wake admission holds it across stream construction). + // never waits behind acceptance I/O. The durable consumption commits only once the stop + // succeeded: a failed stop leaves the agent running, so its output stays owed. Monitors stay + // armed for new output. Best-effort, and never behind the history lock (a wake admission + // holds it across stream construction). + const stopSettled = Promise.withResolvers(); const retirement = options?.retireBashMonitorAttention === true - ? this.bashMonitorWakeReconciler.consumeCurrent(workspaceId).catch((error: unknown) => { - log.warn("Failed to retire bash monitor attention before Stop", { - workspaceId, - error, - }); - }) + ? this.bashMonitorWakeReconciler + .consumeCurrent(workspaceId, () => stopSettled.promise) + .catch((error: unknown) => { + log.warn("Failed to retire bash monitor attention before Stop", { + workspaceId, + error, + }); + }) : undefined; - const stopResult = await session.interruptStream(options); + let stopResult: Result | undefined; + try { + stopResult = await session.interruptStream(options); + } finally { + stopSettled.resolve(stopResult?.success === true); + } await retirement; if (!stopResult.success) { // Interrupt failed, so clear hard-interrupt suppression we set above. From 4876b427e7d2c7cffac37f3a6ed37fde9e590a6e Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Mon, 7 Sep 2026 01:57:48 +0000 Subject: [PATCH 08/31] fix(bash): join the withdrawn wake's send before acknowledging Stop A wake withdrawn past its point of no return (durable row, not yet PREPARING) resolves only after recording the startup abandon marker for that row. Stop now waits for that send to settle, so a forced exit right after Stop cannot leave the row eligible for startup replay. --- src/node/services/workspaceService.test.ts | 47 ++++++++++++++++++++++ src/node/services/workspaceService.ts | 44 ++++++++++++++------ 2 files changed, 79 insertions(+), 12 deletions(-) diff --git a/src/node/services/workspaceService.test.ts b/src/node/services/workspaceService.test.ts index 88379979e2e..25e51e051c0 100644 --- a/src/node/services/workspaceService.test.ts +++ b/src/node/services/workspaceService.test.ts @@ -725,6 +725,53 @@ describe("WorkspaceService bash monitor wake reconciler wiring", () => { } }); + test("hard Stop during a wake's acceptance window is acknowledged only once the wake's abandon marker is durable", async () => { + const h = await createActiveWakeHarness(); + const release = createDeferred(); + try { + const sessionInternal = h.session as unknown as { + persistAutoRetryState(): Promise; + getAutoRetryPreferencePath(): string; + }; + const persist = sessionInternal.persistAutoRetryState.bind(h.session); + const persisting = createDeferred(); + spyOn(sessionInternal, "persistAutoRetryState").mockImplementation(async () => { + persisting.resolve(); + await release.promise; + await persist(); + }); + let stop: Promise> | undefined; + const unsubscribe = h.session.onChatEvent(({ message: event }) => { + if (event.type === "message" && event.role === "user" && stop == null) { + stop = h.service.interruptStream(h.workspaceId, { retireBashMonitorAttention: true }); + } + }); + const attention = h.addAttention(10); + await persisting.promise; + unsubscribe(); + let stopSettled = false; + void stop!.then(() => { + stopSettled = true; + }); + // Retirement has consumed the signals; Stop still waits for the withdrawn send's marker. + expect((await h.reconciler.snapshot(h.workspaceId)).pendingWakeKinds.size).toBe(0); + await new Promise((resolve) => setTimeout(resolve, 20)); + expect(stopSettled).toBe(false); + release.resolve(); + expect((await stop!).success).toBe(true); + await attention; + const persisted = JSON.parse( + await fsPromises.readFile(sessionInternal.getAutoRetryPreferencePath(), "utf-8") + ) as { startupAutoRetryAbandon?: { reason: string; userMessageId?: string } }; + expect(persisted.startupAutoRetryAbandon?.reason).toBe("aborted"); + expect(persisted.startupAutoRetryAbandon?.userMessageId).toBeDefined(); + expect(h.requests).toHaveLength(0); + } finally { + release.resolve(); + await h.finish(); + } + }); + test.each(["options", "settings"] as const)( "wake yields when a turn starts during %s admission", async (gate) => { diff --git a/src/node/services/workspaceService.ts b/src/node/services/workspaceService.ts index 95c0a3564ad..f29373619c0 100644 --- a/src/node/services/workspaceService.ts +++ b/src/node/services/workspaceService.ts @@ -1849,6 +1849,8 @@ export class WorkspaceService extends EventEmitter implements WorkspaceHost { private readonly bashMonitorWakeReconciler: BashMonitorWakeReconciler; private readonly constructedAtMs = Date.now(); private readonly pendingBashMonitorWakeIdleWaitsByOwner = new Map>(); + /** The wake send in flight per owner (at most one: dispatch runs under the history lock). */ + private readonly inFlightBashMonitorWakeSendsByOwner = new Map>(); private readonly bashMonitorHistoryLocks = new MutexMap(); private readonly bashMonitorRecoveryPromise: Promise; private readonly pendingBashMonitorPersistenceByWorkspace = new Map>>(); @@ -2552,7 +2554,7 @@ export class WorkspaceService extends EventEmitter implements WorkspaceHost { if (dispatch.cancelSignal.aborted) return "deferred"; let accepted = false; - const sendResult = await this.sendMessage( + const send = this.sendMessage( ownerWorkspaceId, dispatch.prompt, { @@ -2581,6 +2583,16 @@ export class WorkspaceService extends EventEmitter implements WorkspaceHost { }, } ); + // Published so a hard Stop that withdraws this wake can join it (see interruptStream). + this.inFlightBashMonitorWakeSendsByOwner.set(ownerWorkspaceId, send); + let sendResult: Awaited; + try { + sendResult = await send; + } finally { + if (this.inFlightBashMonitorWakeSendsByOwner.get(ownerWorkspaceId) === send) { + this.inFlightBashMonitorWakeSendsByOwner.delete(ownerWorkspaceId); + } + } if (!sendResult.success && !accepted) { this.scheduleBashMonitorWakeReconcileAfterIdle(ownerWorkspaceId); return "deferred"; @@ -11621,18 +11633,21 @@ export class WorkspaceService extends EventEmitter implements WorkspaceHost { // succeeded: a failed stop leaves the agent running, so its output stays owed. Monitors stay // armed for new output. Best-effort, and never behind the history lock (a wake admission // holds it across stream construction). + const retiring = options?.retireBashMonitorAttention === true; + const withdrawnWakeSend = retiring + ? this.inFlightBashMonitorWakeSendsByOwner.get(workspaceId) + : undefined; const stopSettled = Promise.withResolvers(); - const retirement = - options?.retireBashMonitorAttention === true - ? this.bashMonitorWakeReconciler - .consumeCurrent(workspaceId, () => stopSettled.promise) - .catch((error: unknown) => { - log.warn("Failed to retire bash monitor attention before Stop", { - workspaceId, - error, - }); - }) - : undefined; + const retirement = retiring + ? this.bashMonitorWakeReconciler + .consumeCurrent(workspaceId, () => stopSettled.promise) + .catch((error: unknown) => { + log.warn("Failed to retire bash monitor attention before Stop", { + workspaceId, + error, + }); + }) + : undefined; let stopResult: Result | undefined; try { stopResult = await session.interruptStream(options); @@ -11640,6 +11655,11 @@ export class WorkspaceService extends EventEmitter implements WorkspaceHost { stopSettled.resolve(stopResult?.success === true); } await retirement; + // A wake withdrawn past its point of no return (durable row, not yet PREPARING, so the + // session interrupt above saw idle) resolves only after recording the startup abandon marker + // for that row. Stop is acknowledged after it settles: a forced exit right after Stop must + // not leave the row eligible for startup replay. Its own failure is reported by the dispatch. + await withdrawnWakeSend?.catch(() => undefined); if (!stopResult.success) { // Interrupt failed, so clear hard-interrupt suppression we set above. if (!options?.soft) { From 47f37591cf3e6d3c2b4ea987672fba0bea2f3633 Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Mon, 7 Sep 2026 02:05:23 +0000 Subject: [PATCH 09/31] fix(bash): retire only the attention owed when Stop was requested consumeCurrent snapshots the outstanding signals before waiting on the stop gate, so output that arrives while the stop settles is new and stays owed to the idle agent instead of being consumed by the successful Stop. --- .../bashMonitorWakeReconciler.test.ts | 22 +++++++++++++++++++ .../services/bashMonitorWakeReconciler.ts | 10 +++++---- 2 files changed, 28 insertions(+), 4 deletions(-) diff --git a/src/node/services/bashMonitorWakeReconciler.test.ts b/src/node/services/bashMonitorWakeReconciler.test.ts index adb20ee4bb5..807398b9ef7 100644 --- a/src/node/services/bashMonitorWakeReconciler.test.ts +++ b/src/node/services/bashMonitorWakeReconciler.test.ts @@ -200,6 +200,28 @@ describe("BashMonitorWakeReconciler", () => { expect(dispatches[1].cancelSignal.aborted).toBe(false); }); + test("consumeCurrent retires only the attention owed when the stop was requested", async () => { + live = [liveSnapshot()]; + await reconciler.reconcile(OWNER); + const stop = Promise.withResolvers(); + const stopRequested = Promise.withResolvers(); + const consuming = reconciler.consumeCurrent(OWNER, () => { + stopRequested.resolve(); + return stop.promise; + }); + await stopRequested.promise; + live = [ + liveSnapshot({ match: { throughOffset: 30, lines: ["READY", "READY"], totalMatches: 2 } }), + ]; + stop.resolve(true); + await consuming; + + expect(acknowledged).toEqual([{ processId: "proc", matchedThroughOffset: 12 }]); + await reconciler.reconcile(OWNER); + expect(dispatches).toHaveLength(2); + expect(dispatches[1].cancelSignal.aborted).toBe(false); + }); + test("keeps dead registry evidence until the queued wake is accepted", async () => { rows = [registryRecord()]; diff --git a/src/node/services/bashMonitorWakeReconciler.ts b/src/node/services/bashMonitorWakeReconciler.ts index 077c8f15b45..ef063ee1b2e 100644 --- a/src/node/services/bashMonitorWakeReconciler.ts +++ b/src/node/services/bashMonitorWakeReconciler.ts @@ -590,9 +590,9 @@ export class BashMonitorWakeReconciler { } /** - * Withdraws the in-flight wake and consumes every outstanding signal. When `commit` is given, - * the durable consumption waits for it under the lock and is skipped when it resolves false, - * leaving the withdrawn signals owed to the next reconcile. + * Withdraws the in-flight wake and consumes every signal outstanding on entry. When `commit` is + * given, the durable consumption waits for it under the lock and is skipped when it resolves + * false, leaving the withdrawn signals owed to the next reconcile. */ async consumeCurrent(ownerWorkspaceId: string, commit?: () => Promise): Promise { // Withdraw before taking the lock: an acceptance in progress holds it across watermark, @@ -602,8 +602,10 @@ export class BashMonitorWakeReconciler { this.abortDispatch(ownerWorkspaceId); const committed = await this.locks.withLock(ownerWorkspaceId, async () => { this.abortDispatch(ownerWorkspaceId); - if (commit != null && !(await commit())) return false; + // Snapshot before waiting on the stop: output that arrives while it settles is new and + // stays owed to the idle agent. const collected = await this.collect(ownerWorkspaceId, false); + if (commit != null && !(await commit())) return false; const consumed = [...collected.signals, ...collected.autoConsumed]; await this.advanceWatermarks(ownerWorkspaceId, collected.watermarks, consumed); await this.cleanup(consumed); From 72038d802c67bf18dd48277be840c70d3de6b919 Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Mon, 7 Sep 2026 02:19:42 +0000 Subject: [PATCH 10/31] Record the abandon marker on every withdrawn wake exit past the point of no return A hard Stop that withdraws a bash monitor wake after its row is durable but while goal sync is pending left the row eligible for startup replay when goal sync threw: AgentSession.sendMessage finalized onAccepted and rethrew before reaching the abort-marker write, WorkspaceService.sendMessage resolved Err, and interruptStream discarded the joined Result. Route the goal-sync catch, the disposed exit, the onAccepted failure exit, and the existing withdrawn exit through one abandonWithdrawnSend helper so the marker lands before the send settles on every path, which is what the Stop join relies on. --- src/node/services/agentSession.ts | 26 +++++++++----- src/node/services/workspaceService.test.ts | 42 +++++++++++++++++++++- src/node/services/workspaceService.ts | 8 +++-- 3 files changed, 64 insertions(+), 12 deletions(-) diff --git a/src/node/services/agentSession.ts b/src/node/services/agentSession.ts index a1de0d7d92d..971940da89b 100644 --- a/src/node/services/agentSession.ts +++ b/src/node/services/agentSession.ts @@ -4015,6 +4015,16 @@ export class AgentSession { if (cancelSignal != null) { cancellationDisabled = true; } + // A cancelable send withdrawn past the point of no return (a hard Stop retiring owed attention + // during goal sync or acceptance) keeps its durable, accepted rows but never streams: the Stop + // saw no turn to abort. The trailing UI-visible row would read as an interrupted turn to + // startup recovery, so every exit below that skips PREPARING records the same abandon marker a + // user-aborted stream leaves, before the send resolves (Stop joins the send for this). + const abandonWithdrawnSend = async (): Promise => { + if (cancelSignal?.aborted === true) { + await this.updateStartupAutoRetryAbandonFromAbort("user", userMessage.id); + } + }; // r54: the pre-turn batch is now irrevocable — rollbackPersistedTurnRows // is never invoked past this point, so even a failure in goal sync or // acceptance leaves the payload + trigger rows durable in the transcript. @@ -4026,7 +4036,9 @@ export class AgentSession { } catch (error) { if (cancelSignal != null) { // The durable row crossed the point of no return, so every later goal-sync failure must still - // finalize this monitor wake. Startup recovery can resume the row without redelivering it. + // finalize this monitor wake. Startup recovery can resume the row without redelivering it, + // unless a Stop withdrew the wake (marker recorded first, in case acceptance throws). + await abandonWithdrawnSend(); await internal?.onAccepted?.(); } throw error; @@ -4044,6 +4056,7 @@ export class AgentSession { // wake past the point of no return is already durable, so finalize it before leaving. if (this.coordinator.disposed) { if (cancelSignal != null && cancellationDisabled) { + await abandonWithdrawnSend(); await internal?.onAccepted?.(); } return Ok(undefined); @@ -4116,6 +4129,7 @@ export class AgentSession { if (this.coordinator.thinkingOverride === turnThinkingOverride) { this.coordinator.releaseThinkingOverride(turnThinkingOverride); } + await abandonWithdrawnSend(); return Err(createUnknownSendMessageError(getErrorMessage(error))); } @@ -4151,17 +4165,13 @@ export class AgentSession { await notifyAcceptedPreStreamFailure(error); return Err(error); } - // A cancelable send withdrawn past the point of no return (a hard Stop retiring owed - // attention during goal sync or acceptance) keeps its durable, accepted rows but must not - // claim PREPARING: the Stop saw no turn to abort and has already returned. Withdrawn sends - // resolve Ok without a stream, like cancelBeforeAcceptance and the disposed path above. The - // trailing UI-visible row would otherwise read as an interrupted turn to startup recovery, - // so record the same abandon marker a user-aborted stream leaves. + // A withdrawn send must not claim PREPARING (see abandonWithdrawnSend); it resolves Ok without + // a stream, like cancelBeforeAcceptance and the disposed path above. if (cancelSignal?.aborted === true) { if (this.coordinator.thinkingOverride === turnThinkingOverride) { this.coordinator.releaseThinkingOverride(turnThinkingOverride); } - await this.updateStartupAutoRetryAbandonFromAbort("user", userMessage.id); + await abandonWithdrawnSend(); return Ok(undefined); } diff --git a/src/node/services/workspaceService.test.ts b/src/node/services/workspaceService.test.ts index 25e51e051c0..e9469c054e3 100644 --- a/src/node/services/workspaceService.test.ts +++ b/src/node/services/workspaceService.test.ts @@ -262,7 +262,9 @@ describe("WorkspaceService bash monitor wake reconciler wiring", () => { return { config, historyService, backgroundProcessManager, service, events, cleanup }; } - async function createActiveWakeHarness() { + async function createActiveWakeHarness(options?: { + workspaceGoalService?: WorkspaceGoalService; + }) { const fixture = await createWakeWiringService(); const { config, service, historyService, backgroundProcessManager } = fixture; const workspaceId = "monitor-attention-owner"; @@ -285,6 +287,7 @@ describe("WorkspaceService bash monitor wake reconciler wiring", () => { historyService, backgroundProcessManager, aiEmitter, + workspaceGoalService: options?.workspaceGoalService, aiServiceOverrides: { isStreaming: () => streaming, streamMessage: mock((request: Parameters[0]) => { @@ -772,6 +775,43 @@ describe("WorkspaceService bash monitor wake reconciler wiring", () => { } }); + test("hard Stop during a wake's goal sync records the abandon marker even when goal sync fails", async () => { + let stop: Promise> | undefined; + const h = await createActiveWakeHarness({ + workspaceGoalService: { + assertPricedModelForBudgetedGoal: () => Promise.resolve(Ok(undefined)), + recordStreamStarted: () => undefined, + // Goal sync runs past the point of no return; Stop lands while it is pending. + syncGoalModeWithChatTail: () => { + stop ??= h.service.interruptStream(h.workspaceId, { retireBashMonitorAttention: true }); + return Promise.reject(new Error("goal sync failed")); + }, + } as unknown as WorkspaceGoalService, + }); + try { + await h.addAttention(10); + expect(stop).toBeDefined(); + expect((await stop!).success).toBe(true); + const sessionInternal = h.session as unknown as { getAutoRetryPreferencePath(): string }; + const persisted = JSON.parse( + await fsPromises.readFile(sessionInternal.getAutoRetryPreferencePath(), "utf-8") + ) as { startupAutoRetryAbandon?: { reason: string; userMessageId?: string } }; + const history = await h.historyService.getHistoryFromLatestBoundary(h.workspaceId); + const wakeRow = history.success + ? history.data.filter((row) => row.role === "user").at(-1) + : undefined; + expect(wakeRow).toBeDefined(); + expect(persisted.startupAutoRetryAbandon).toEqual({ + reason: "aborted", + userMessageId: wakeRow!.id, + }); + expect((await h.reconciler.snapshot(h.workspaceId)).pendingWakeKinds.size).toBe(0); + expect(h.requests).toHaveLength(0); + } finally { + await h.finish(); + } + }); + test.each(["options", "settings"] as const)( "wake yields when a turn starts during %s admission", async (gate) => { diff --git a/src/node/services/workspaceService.ts b/src/node/services/workspaceService.ts index f29373619c0..0a1656dd0a5 100644 --- a/src/node/services/workspaceService.ts +++ b/src/node/services/workspaceService.ts @@ -11656,9 +11656,11 @@ export class WorkspaceService extends EventEmitter implements WorkspaceHost { } await retirement; // A wake withdrawn past its point of no return (durable row, not yet PREPARING, so the - // session interrupt above saw idle) resolves only after recording the startup abandon marker - // for that row. Stop is acknowledged after it settles: a forced exit right after Stop must - // not leave the row eligible for startup replay. Its own failure is reported by the dispatch. + // session interrupt above saw idle) records the startup abandon marker for that row on every + // exit before it resolves, including a failed goal sync or acceptance (see + // abandonWithdrawnSend in AgentSession.sendMessage). Stop is acknowledged after it settles: a + // forced exit right after Stop must not leave the row eligible for startup replay. The send's + // own result is the dispatch's to report. await withdrawnWakeSend?.catch(() => undefined); if (!stopResult.success) { // Interrupt failed, so clear hard-interrupt suppression we set above. From 80e6716d9e77cd288d7c46e604c7346ebf25ef34 Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Mon, 7 Sep 2026 03:31:26 +0000 Subject: [PATCH 11/31] Check wake withdrawal after the acceptance I/O on every exit past the point of no return The goal-sync failure and disposed exits checked the cancel signal before awaiting onAccepted, so a Stop that aborted the signal while the reconciler was persisting acceptance was missed: the catch rethrew without an abandon marker and Stop joined the send and returned success with the durable wake row still eligible for startup replay. Run abandonWithdrawnSend in a finally after onAccepted on those exits and after the accepted admission-stale failure, so the check is the last await of every exit that skips PREPARING. --- src/node/services/agentSession.ts | 21 ++++-- src/node/services/workspaceService.test.ts | 81 +++++++++++++--------- 2 files changed, 62 insertions(+), 40 deletions(-) diff --git a/src/node/services/agentSession.ts b/src/node/services/agentSession.ts index 971940da89b..867467ceb82 100644 --- a/src/node/services/agentSession.ts +++ b/src/node/services/agentSession.ts @@ -4019,7 +4019,9 @@ export class AgentSession { // during goal sync or acceptance) keeps its durable, accepted rows but never streams: the Stop // saw no turn to abort. The trailing UI-visible row would read as an interrupted turn to // startup recovery, so every exit below that skips PREPARING records the same abandon marker a - // user-aborted stream leaves, before the send resolves (Stop joins the send for this). + // user-aborted stream leaves, before the send resolves (Stop joins the send for this). The + // withdrawal can land during any await on the way out, including acceptance I/O, so each exit + // runs this check after its last other await. const abandonWithdrawnSend = async (): Promise => { if (cancelSignal?.aborted === true) { await this.updateStartupAutoRetryAbandonFromAbort("user", userMessage.id); @@ -4037,9 +4039,12 @@ export class AgentSession { if (cancelSignal != null) { // The durable row crossed the point of no return, so every later goal-sync failure must still // finalize this monitor wake. Startup recovery can resume the row without redelivering it, - // unless a Stop withdrew the wake (marker recorded first, in case acceptance throws). - await abandonWithdrawnSend(); - await internal?.onAccepted?.(); + // unless a Stop withdrew the wake. + try { + await internal?.onAccepted?.(); + } finally { + await abandonWithdrawnSend(); + } } throw error; } @@ -4056,8 +4061,11 @@ export class AgentSession { // wake past the point of no return is already durable, so finalize it before leaving. if (this.coordinator.disposed) { if (cancelSignal != null && cancellationDisabled) { - await abandonWithdrawnSend(); - await internal?.onAccepted?.(); + try { + await internal?.onAccepted?.(); + } finally { + await abandonWithdrawnSend(); + } } return Ok(undefined); } @@ -4163,6 +4171,7 @@ export class AgentSession { // callback to revert it — returning without notifying would strand // that bookkeeping (r41). await notifyAcceptedPreStreamFailure(error); + await abandonWithdrawnSend(); return Err(error); } // A withdrawn send must not claim PREPARING (see abandonWithdrawnSend); it resolves Ok without diff --git a/src/node/services/workspaceService.test.ts b/src/node/services/workspaceService.test.ts index e9469c054e3..1b6e9edde23 100644 --- a/src/node/services/workspaceService.test.ts +++ b/src/node/services/workspaceService.test.ts @@ -775,42 +775,55 @@ describe("WorkspaceService bash monitor wake reconciler wiring", () => { } }); - test("hard Stop during a wake's goal sync records the abandon marker even when goal sync fails", async () => { - let stop: Promise> | undefined; - const h = await createActiveWakeHarness({ - workspaceGoalService: { - assertPricedModelForBudgetedGoal: () => Promise.resolve(Ok(undefined)), - recordStreamStarted: () => undefined, - // Goal sync runs past the point of no return; Stop lands while it is pending. - syncGoalModeWithChatTail: () => { - stop ??= h.service.interruptStream(h.workspaceId, { retireBashMonitorAttention: true }); - return Promise.reject(new Error("goal sync failed")); - }, - } as unknown as WorkspaceGoalService, - }); - try { - await h.addAttention(10); - expect(stop).toBeDefined(); - expect((await stop!).success).toBe(true); - const sessionInternal = h.session as unknown as { getAutoRetryPreferencePath(): string }; - const persisted = JSON.parse( - await fsPromises.readFile(sessionInternal.getAutoRetryPreferencePath(), "utf-8") - ) as { startupAutoRetryAbandon?: { reason: string; userMessageId?: string } }; - const history = await h.historyService.getHistoryFromLatestBoundary(h.workspaceId); - const wakeRow = history.success - ? history.data.filter((row) => row.role === "user").at(-1) - : undefined; - expect(wakeRow).toBeDefined(); - expect(persisted.startupAutoRetryAbandon).toEqual({ - reason: "aborted", - userMessageId: wakeRow!.id, + test.each([ + ["the failing goal sync", "goal-sync"], + ["the acceptance I/O owed after a failed goal sync", "acceptance"], + ] as const)( + "hard Stop during %s records the abandon marker for the withdrawn wake", + async (_, at) => { + let stop: Promise> | undefined; + const requestStop = () => { + stop ??= h.service.interruptStream(h.workspaceId, { retireBashMonitorAttention: true }); + }; + const h = await createActiveWakeHarness({ + workspaceGoalService: { + assertPricedModelForBudgetedGoal: () => Promise.resolve(Ok(undefined)), + recordStreamStarted: () => undefined, + // Goal sync runs past the point of no return and fails; the failure path still awaits + // acceptance, so Stop can land during either await. + syncGoalModeWithChatTail: () => { + if (at === "goal-sync") requestStop(); + return Promise.reject(new Error("goal sync failed")); + }, + } as unknown as WorkspaceGoalService, }); - expect((await h.reconciler.snapshot(h.workspaceId)).pendingWakeKinds.size).toBe(0); - expect(h.requests).toHaveLength(0); - } finally { - await h.finish(); + if (at === "acceptance") { + spyOn(h.backgroundProcessManager, "acknowledgeMonitorWake").mockImplementation(requestStop); + } + try { + await h.addAttention(10); + expect(stop).toBeDefined(); + expect((await stop!).success).toBe(true); + const sessionInternal = h.session as unknown as { getAutoRetryPreferencePath(): string }; + const persisted = JSON.parse( + await fsPromises.readFile(sessionInternal.getAutoRetryPreferencePath(), "utf-8") + ) as { startupAutoRetryAbandon?: { reason: string; userMessageId?: string } }; + const history = await h.historyService.getHistoryFromLatestBoundary(h.workspaceId); + const wakeRow = history.success + ? history.data.filter((row) => row.role === "user").at(-1) + : undefined; + expect(wakeRow).toBeDefined(); + expect(persisted.startupAutoRetryAbandon).toEqual({ + reason: "aborted", + userMessageId: wakeRow!.id, + }); + expect((await h.reconciler.snapshot(h.workspaceId)).pendingWakeKinds.size).toBe(0); + expect(h.requests).toHaveLength(0); + } finally { + await h.finish(); + } } - }); + ); test.each(["options", "settings"] as const)( "wake yields when a turn starts during %s admission", From 8c9f5f68d3c87e9c56f791f3ed5cd5c055889405 Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Mon, 7 Sep 2026 04:09:21 +0000 Subject: [PATCH 12/31] =?UTF-8?q?=F0=9F=A4=96=20fix:=20fail=20Stop=20when?= =?UTF-8?q?=20a=20withdrawn=20wake's=20abandon=20marker=20is=20not=20writt?= =?UTF-8?q?en?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit persistAutoRetryState swallowed write errors, so a wake withdrawn past its point of no return resolved Ok even when the startup abandon marker never reached disk, and Stop acknowledged success while leaving the trailing synthetic row eligible for startup replay. The marker writer now reports whether the file reflects memory; a withdrawn wake whose marker could not be recorded fails with WITHDRAWN_WAKE_UNRECORDED_MESSAGE, and the joining interruptStream propagates that failure. Other preference writes stay best-effort. --- src/node/services/agentSession.ts | 97 ++++++++++++++-------- src/node/services/workspaceService.test.ts | 32 ++++++- src/node/services/workspaceService.ts | 23 ++++- 3 files changed, 112 insertions(+), 40 deletions(-) diff --git a/src/node/services/agentSession.ts b/src/node/services/agentSession.ts index 867467ceb82..49419709fb6 100644 --- a/src/node/services/agentSession.ts +++ b/src/node/services/agentSession.ts @@ -536,6 +536,12 @@ export async function clearProviderConfigFixableAbandonMarkers( export const CONTEXT_MUTATION_SEND_BLOCKED_MESSAGE = "Workspace history is being cleared or reset. Please wait and try again."; const SESSION_SHUTDOWN_SEND_BLOCKED_MESSAGE = "Xum is shutting down; the message was not sent."; +/** + * Failure of a monitor wake withdrawn by a Stop past its point of no return whose startup abandon + * marker could not be written: the durable row stays replayable, so the joining Stop reports it. + */ +export const WITHDRAWN_WAKE_UNRECORDED_MESSAGE = + "The stopped monitor wake could not record its startup abandon marker."; const STARTUP_AUTO_RETRY_HISTORY_FAILURE_BASE_DELAY_MS = 1_000; const STARTUP_AUTO_RETRY_HISTORY_FAILURE_MAX_DELAY_MS = 30_000; @@ -1492,7 +1498,11 @@ export class AgentSession { } } - private async persistAutoRetryState(): Promise { + /** + * Best-effort for most callers; returns whether the file now reflects the in-memory state so the + * one caller that must not acknowledge an unrecorded write (a withdrawn monitor wake) can tell. + */ + private async persistAutoRetryState(): Promise { const preferencePath = this.getAutoRetryPreferencePath(); const enabled = this.autoRetryEnabledPreference !== false; const hasStartupAbandonState = this.startupAutoRetryAbandon !== null; @@ -1510,9 +1520,10 @@ export class AgentSession { workspaceId: this.workspaceId, error: getErrorMessage(error), }); + return false; } } - return; + return true; } const payload: { @@ -1531,11 +1542,13 @@ export class AgentSession { try { await mkdir(path.dirname(preferencePath), { recursive: true }); await writeFile(preferencePath, JSON.stringify(payload) + "\n", "utf-8"); + return true; } catch (error) { log.warn("Failed to persist auto-retry preference", { workspaceId: this.workspaceId, error: getErrorMessage(error), }); + return false; } } @@ -1547,21 +1560,21 @@ export class AgentSession { private async persistStartupAutoRetryAbandon( reason: string, userMessageId?: string - ): Promise { + ): Promise { this.startupAutoRetryAbandon = { reason, ...(userMessageId ? { userMessageId } : {}), }; - await this.persistAutoRetryState(); + return this.persistAutoRetryState(); } - private async clearStartupAutoRetryAbandon(): Promise { + private async clearStartupAutoRetryAbandon(): Promise { if (this.startupAutoRetryAbandon === null) { - return; + return true; } this.startupAutoRetryAbandon = null; - await this.persistAutoRetryState(); + return this.persistAutoRetryState(); } async handleProviderConfigChanged(): Promise { @@ -1578,31 +1591,30 @@ export class AgentSession { private async updateStartupAutoRetryAbandonFromFailure( errorType: string, userMessageId?: string - ): Promise { + ): Promise { if ( isNonRetryableSendError({ type: errorType }) || isNonRetryableStreamError({ type: errorType }) ) { - await this.persistStartupAutoRetryAbandon(errorType, userMessageId); - return; + return this.persistStartupAutoRetryAbandon(errorType, userMessageId); } - await this.clearStartupAutoRetryAbandon(); + return this.clearStartupAutoRetryAbandon(); } private async updateStartupAutoRetryAbandonFromAbort( abortReason: StreamAbortReason | undefined, userMessageId?: string - ): Promise { + ): Promise { // "system" and "startup" aborts come from backend-orchestrated flows // (for example, mid-stream auto-compaction or canceling a pending startup). // They are not user intent and must not poison startup recovery with a // persisted non-retryable "aborted" marker. if (abortReason === "system" || abortReason === "startup") { - return; + return true; } - await this.updateStartupAutoRetryAbandonFromFailure("aborted", userMessageId); + return this.updateStartupAutoRetryAbandonFromFailure("aborted", userMessageId); } private isAiStreaming(): boolean { @@ -4021,11 +4033,31 @@ export class AgentSession { // startup recovery, so every exit below that skips PREPARING records the same abandon marker a // user-aborted stream leaves, before the send resolves (Stop joins the send for this). The // withdrawal can land during any await on the way out, including acceptance I/O, so each exit - // runs this check after its last other await. - const abandonWithdrawnSend = async (): Promise => { - if (cancelSignal?.aborted === true) { - await this.updateStartupAutoRetryAbandonFromAbort("user", userMessage.id); + // runs this check after its last other await. The marker writer is best-effort; a write it could + // not make leaves the row replayable, so the send fails with WITHDRAWN_WAKE_UNRECORDED_MESSAGE + // instead of its own outcome and the joining Stop is not acknowledged. + const abandonWithdrawnSend = async (): Promise | undefined> => { + if (cancelSignal?.aborted !== true) return undefined; + const recorded = await this.updateStartupAutoRetryAbandonFromAbort("user", userMessage.id); + return recorded + ? undefined + : Err(createUnknownSendMessageError(WITHDRAWN_WAKE_UNRECORDED_MESSAGE)); + }; + // Exits that still owe acceptance I/O run the withdrawal check last, since a Stop can land during + // that I/O; an unrecorded marker outranks an acceptance failure because only it keeps the row + // replayable. + const acceptThenAbandonWithdrawnSend = async (): Promise< + AgentSessionResult | undefined + > => { + try { + await internal?.onAccepted?.(); + } catch (error) { + return ( + (await abandonWithdrawnSend()) ?? + Err(createUnknownSendMessageError(getErrorMessage(error))) + ); } + return abandonWithdrawnSend(); }; // r54: the pre-turn batch is now irrevocable — rollbackPersistedTurnRows // is never invoked past this point, so even a failure in goal sync or @@ -4040,10 +4072,13 @@ export class AgentSession { // The durable row crossed the point of no return, so every later goal-sync failure must still // finalize this monitor wake. Startup recovery can resume the row without redelivering it, // unless a Stop withdrew the wake. - try { - await internal?.onAccepted?.(); - } finally { - await abandonWithdrawnSend(); + const failure = await acceptThenAbandonWithdrawnSend(); + if (failure != null) { + log.error("Goal sync failed for a monitor wake that could not finalize", { + workspaceId: this.workspaceId, + error: getErrorMessage(error), + }); + return failure; } } throw error; @@ -4061,11 +4096,8 @@ export class AgentSession { // wake past the point of no return is already durable, so finalize it before leaving. if (this.coordinator.disposed) { if (cancelSignal != null && cancellationDisabled) { - try { - await internal?.onAccepted?.(); - } finally { - await abandonWithdrawnSend(); - } + const failure = await acceptThenAbandonWithdrawnSend(); + if (failure != null) return failure; } return Ok(undefined); } @@ -4137,8 +4169,9 @@ export class AgentSession { if (this.coordinator.thinkingOverride === turnThinkingOverride) { this.coordinator.releaseThinkingOverride(turnThinkingOverride); } - await abandonWithdrawnSend(); - return Err(createUnknownSendMessageError(getErrorMessage(error))); + return ( + (await abandonWithdrawnSend()) ?? Err(createUnknownSendMessageError(getErrorMessage(error))) + ); } let acceptedPreStreamFailureNotified = false; @@ -4171,8 +4204,7 @@ export class AgentSession { // callback to revert it — returning without notifying would strand // that bookkeeping (r41). await notifyAcceptedPreStreamFailure(error); - await abandonWithdrawnSend(); - return Err(error); + return (await abandonWithdrawnSend()) ?? Err(error); } // A withdrawn send must not claim PREPARING (see abandonWithdrawnSend); it resolves Ok without // a stream, like cancelBeforeAcceptance and the disposed path above. @@ -4180,8 +4212,7 @@ export class AgentSession { if (this.coordinator.thinkingOverride === turnThinkingOverride) { this.coordinator.releaseThinkingOverride(turnThinkingOverride); } - await abandonWithdrawnSend(); - return Ok(undefined); + return (await abandonWithdrawnSend()) ?? Ok(undefined); } const preparedTurnAbortController = new AbortController(); diff --git a/src/node/services/workspaceService.test.ts b/src/node/services/workspaceService.test.ts index 1b6e9edde23..4cefaacbe8b 100644 --- a/src/node/services/workspaceService.test.ts +++ b/src/node/services/workspaceService.test.ts @@ -4,7 +4,10 @@ import { WorkspaceService, generateForkBranchName, generateForkTitle } from "./w import { registerInProcessWorkflowRun } from "@/node/services/workflows/workflowArchiveAdmission"; import type { IdleCompactionOutcome } from "./idleCompactionService"; import type { AgentSession } from "./agentSession"; -import { CONTEXT_MUTATION_SEND_BLOCKED_MESSAGE } from "./agentSession"; +import { + CONTEXT_MUTATION_SEND_BLOCKED_MESSAGE, + WITHDRAWN_WAKE_UNRECORDED_MESSAGE, +} from "./agentSession"; import { createAgentSessionHarness, createStartedTurnHandle, @@ -733,7 +736,7 @@ describe("WorkspaceService bash monitor wake reconciler wiring", () => { const release = createDeferred(); try { const sessionInternal = h.session as unknown as { - persistAutoRetryState(): Promise; + persistAutoRetryState(): Promise; getAutoRetryPreferencePath(): string; }; const persist = sessionInternal.persistAutoRetryState.bind(h.session); @@ -741,7 +744,7 @@ describe("WorkspaceService bash monitor wake reconciler wiring", () => { spyOn(sessionInternal, "persistAutoRetryState").mockImplementation(async () => { persisting.resolve(); await release.promise; - await persist(); + return persist(); }); let stop: Promise> | undefined; const unsubscribe = h.session.onChatEvent(({ message: event }) => { @@ -775,6 +778,29 @@ describe("WorkspaceService bash monitor wake reconciler wiring", () => { } }); + test("hard Stop during a wake's acceptance window fails when the withdrawn wake's abandon marker cannot be written", async () => { + const h = await createActiveWakeHarness(); + try { + const sessionInternal = h.session as unknown as { getAutoRetryPreferencePath(): string }; + // A directory at the preference path makes the marker write fail (EISDIR). + await fsPromises.mkdir(sessionInternal.getAutoRetryPreferencePath(), { recursive: true }); + let stop: Promise> | undefined; + const unsubscribe = h.session.onChatEvent(({ message: event }) => { + if (event.type === "message" && event.role === "user" && stop == null) { + stop = h.service.interruptStream(h.workspaceId, { retireBashMonitorAttention: true }); + } + }); + await h.addAttention(10); + unsubscribe(); + expect(stop).toBeDefined(); + expect(await stop!).toEqual(Err(WITHDRAWN_WAKE_UNRECORDED_MESSAGE)); + expect(h.requests).toHaveLength(0); + expect(h.session.isBusy()).toBe(false); + } finally { + await h.finish(); + } + }); + test.each([ ["the failing goal sync", "goal-sync"], ["the acceptance I/O owed after a failed goal sync", "acceptance"], diff --git a/src/node/services/workspaceService.ts b/src/node/services/workspaceService.ts index 0a1656dd0a5..f390d90ff38 100644 --- a/src/node/services/workspaceService.ts +++ b/src/node/services/workspaceService.ts @@ -45,6 +45,7 @@ import { CONTEXT_MUTATION_SEND_BLOCKED_MESSAGE, inheritOpenWorkspaceTurnMetadata, type StreamErrorRecoveryOutcome, + WITHDRAWN_WAKE_UNRECORDED_MESSAGE, } from "@/node/services/agentSession"; import type { QueueCutCutter } from "@/node/services/messageQueue"; import { cancelReasonBeforeAcceptance } from "@/node/services/messageQueue"; @@ -1850,7 +1851,10 @@ export class WorkspaceService extends EventEmitter implements WorkspaceHost { private readonly constructedAtMs = Date.now(); private readonly pendingBashMonitorWakeIdleWaitsByOwner = new Map>(); /** The wake send in flight per owner (at most one: dispatch runs under the history lock). */ - private readonly inFlightBashMonitorWakeSendsByOwner = new Map>(); + private readonly inFlightBashMonitorWakeSendsByOwner = new Map< + string, + Promise> + >(); private readonly bashMonitorHistoryLocks = new MutexMap(); private readonly bashMonitorRecoveryPromise: Promise; private readonly pendingBashMonitorPersistenceByWorkspace = new Map>>(); @@ -11659,9 +11663,14 @@ export class WorkspaceService extends EventEmitter implements WorkspaceHost { // session interrupt above saw idle) records the startup abandon marker for that row on every // exit before it resolves, including a failed goal sync or acceptance (see // abandonWithdrawnSend in AgentSession.sendMessage). Stop is acknowledged after it settles: a - // forced exit right after Stop must not leave the row eligible for startup replay. The send's - // own result is the dispatch's to report. - await withdrawnWakeSend?.catch(() => undefined); + // forced exit right after Stop must not leave the row eligible for startup replay. A marker + // the send could not write fails the Stop below; its other outcomes are the dispatch's to + // report. + const withdrawnWakeResult = await withdrawnWakeSend?.catch(() => undefined); + const withdrawnWakeUnrecorded = + withdrawnWakeResult?.success === false && + withdrawnWakeResult.error.type === "unknown" && + withdrawnWakeResult.error.raw === WITHDRAWN_WAKE_UNRECORDED_MESSAGE; if (!stopResult.success) { // Interrupt failed, so clear hard-interrupt suppression we set above. if (!options?.soft) { @@ -11711,6 +11720,12 @@ export class WorkspaceService extends EventEmitter implements WorkspaceHost { session.restoreQueueToInput(); } + if (withdrawnWakeUnrecorded) { + log.error("Stop left a withdrawn monitor wake eligible for startup replay", { + workspaceId, + }); + return Err(WITHDRAWN_WAKE_UNRECORDED_MESSAGE); + } return Ok(undefined); } catch (error) { if (!options?.soft) { From f8223a342811f19cc38a78a15b77e3ffe65077fb Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Mon, 7 Sep 2026 05:03:50 +0000 Subject: [PATCH 13/31] =?UTF-8?q?=F0=9F=A4=96=20fix:=20keep=20Stop=20retir?= =?UTF-8?q?ement=20owed=20until=20it=20lands=20and=20retry=20unrecorded=20?= =?UTF-8?q?wake=20markers?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Round 8 review fixes for bash monitor wakes: - consumeCurrent snapshots the process frontier synchronously on entry and keeps it as an owed retirement in reconciler state once the stop commits; reconcileOnce retries it before any dispatch, so retirement I/O that fails cannot let the stop's idle reconcile re-dispatch dismissed attention, and output arriving while the stop waits behind acceptance stays owed - the unrecorded startup abandon marker is tracked in AgentSession and retried on every later user Stop, which fails with STOP_UNRECORDED_MESSAGE until the marker is on disk - the idle waiter releases its map slot before scheduling reconciliation so a reconcile that loses the race to a new turn installs the next idle wait - the RetryBarrier Stop button issues the same attention-retiring interrupt as its keyboard shortcut --- .../ChatBarrier/RetryBarrier.test.tsx | 25 ++++ .../Messages/ChatBarrier/RetryBarrier.tsx | 6 + src/node/services/agentSession.ts | 117 ++++++++-------- .../services/bashMonitorWakeReconciler.ts | 77 +++++++---- src/node/services/workspaceService.test.ts | 127 ++++++++++++++++-- src/node/services/workspaceService.ts | 45 +++---- 6 files changed, 275 insertions(+), 122 deletions(-) diff --git a/src/browser/features/Messages/ChatBarrier/RetryBarrier.test.tsx b/src/browser/features/Messages/ChatBarrier/RetryBarrier.test.tsx index 4a87d6cb9cb..5488334c716 100644 --- a/src/browser/features/Messages/ChatBarrier/RetryBarrier.test.tsx +++ b/src/browser/features/Messages/ChatBarrier/RetryBarrier.test.tsx @@ -72,6 +72,7 @@ function createDeferred() { let resumeStreamResult: ResumeStreamResult = { success: true, data: { started: true } }; let previousAutoRetryEnabled = false; const resumeStream = mock((_input: unknown) => Promise.resolve(resumeStreamResult)); +const interruptStream = mock((_input: unknown) => Promise.resolve({ success: true as const })); const setAutoRetryEnabled = mock((input: unknown) => { if ( typeof input === "object" && @@ -107,6 +108,7 @@ void mock.module("@/browser/contexts/API", () => ({ api: { workspace: { resumeStream, + interruptStream, setAutoRetryEnabled, }, }, @@ -152,6 +154,7 @@ describe("RetryBarrier", () => { resumeStreamResult = { success: true, data: { started: true } }; previousAutoRetryEnabled = false; resumeStream.mockClear(); + interruptStream.mockClear(); setAutoRetryEnabled.mockClear(); }); @@ -394,4 +397,26 @@ describe("RetryBarrier", () => { }); expect(resumeStream).toHaveBeenCalledTimes(1); }); + + test("the Stop button issues the same attention-retiring Stop as its shortcut", () => { + currentWorkspaceState = createWorkspaceState({ + autoRetryStatus: { + type: "auto-retry-scheduled", + attempt: 1, + delayMs: 5_000, + scheduledAt: Date.now(), + }, + }); + + const view = render(); + + fireEvent.click(view.getByRole("button", { name: /^Stop/ })); + + expect(setAutoRetryEnabled).toHaveBeenCalledWith({ workspaceId: "ws-1", enabled: false }); + expect(interruptStream).toHaveBeenCalledTimes(1); + expect(interruptStream).toHaveBeenCalledWith({ + workspaceId: "ws-1", + options: { retireBashMonitorAttention: true }, + }); + }); }); diff --git a/src/browser/features/Messages/ChatBarrier/RetryBarrier.tsx b/src/browser/features/Messages/ChatBarrier/RetryBarrier.tsx index e3c954092a0..11a9ee87cf7 100644 --- a/src/browser/features/Messages/ChatBarrier/RetryBarrier.tsx +++ b/src/browser/features/Messages/ChatBarrier/RetryBarrier.tsx @@ -237,6 +237,12 @@ export const RetryBarrier: React.FC = (props) => { setCountdown(0); setManualRetryError(null); void api?.workspace.setAutoRetryEnabled?.({ workspaceId: props.workspaceId, enabled: false }); + // Same Stop as the shortcut shown on the button (useAIViewKeybinds): owed monitor output is + // dismissed rather than launched as a wake once the retry stops. + void api?.workspace.interruptStream({ + workspaceId: props.workspaceId, + options: { retireBashMonitorAttention: true }, + }); }; const lastMessage = getLastMainRetryCandidateMessage(workspaceState.messages); diff --git a/src/node/services/agentSession.ts b/src/node/services/agentSession.ts index 49419709fb6..6251f784157 100644 --- a/src/node/services/agentSession.ts +++ b/src/node/services/agentSession.ts @@ -536,12 +536,6 @@ export async function clearProviderConfigFixableAbandonMarkers( export const CONTEXT_MUTATION_SEND_BLOCKED_MESSAGE = "Workspace history is being cleared or reset. Please wait and try again."; const SESSION_SHUTDOWN_SEND_BLOCKED_MESSAGE = "Xum is shutting down; the message was not sent."; -/** - * Failure of a monitor wake withdrawn by a Stop past its point of no return whose startup abandon - * marker could not be written: the durable row stays replayable, so the joining Stop reports it. - */ -export const WITHDRAWN_WAKE_UNRECORDED_MESSAGE = - "The stopped monitor wake could not record its startup abandon marker."; const STARTUP_AUTO_RETRY_HISTORY_FAILURE_BASE_DELAY_MS = 1_000; const STARTUP_AUTO_RETRY_HISTORY_FAILURE_MAX_DELAY_MS = 30_000; @@ -758,6 +752,8 @@ export class AgentSession { private autoRetryEnabledPreference: boolean | null = null; private legacyAutoRetryEnabledHint: boolean | null = null; private startupAutoRetryAbandon: { reason: string; userMessageId?: string } | null = null; + // The preference file may not reflect memory after a failed write (see persistAutoRetryState). + private autoRetryStateUnrecorded = false; /** Latest context-usage snapshot used for on-send compaction checks. */ private lastUsageState?: AutoCompactionUsageState; @@ -1498,14 +1494,13 @@ export class AgentSession { } } - /** - * Best-effort for most callers; returns whether the file now reflects the in-memory state so the - * one caller that must not acknowledge an unrecorded write (a withdrawn monitor wake) can tell. - */ - private async persistAutoRetryState(): Promise { + // Best-effort: a failed write only sets autoRetryStateUnrecorded, which the one caller that must + // not acknowledge an unrecorded write (a user Stop) checks via recordPendingStartupAutoRetryAbandon. + private async persistAutoRetryState(): Promise { const preferencePath = this.getAutoRetryPreferencePath(); const enabled = this.autoRetryEnabledPreference !== false; const hasStartupAbandonState = this.startupAutoRetryAbandon !== null; + this.autoRetryStateUnrecorded = true; if (enabled && !hasStartupAbandonState) { try { @@ -1520,10 +1515,11 @@ export class AgentSession { workspaceId: this.workspaceId, error: getErrorMessage(error), }); - return false; + return; } } - return true; + this.autoRetryStateUnrecorded = false; + return; } const payload: { @@ -1542,16 +1538,27 @@ export class AgentSession { try { await mkdir(path.dirname(preferencePath), { recursive: true }); await writeFile(preferencePath, JSON.stringify(payload) + "\n", "utf-8"); - return true; + this.autoRetryStateUnrecorded = false; } catch (error) { log.warn("Failed to persist auto-retry preference", { workspaceId: this.workspaceId, error: getErrorMessage(error), }); - return false; } } + /** + * A user Stop is acknowledged only once the startup abandon marker its stopped turn relies on is + * on disk; otherwise the trailing row stays eligible for startup replay. A marker write that failed + * earlier (a withdrawn monitor wake, an aborted stream) is retried here, so the obligation survives + * the Stop that first reported it. + */ + async recordPendingStartupAutoRetryAbandon(): Promise { + if (this.startupAutoRetryAbandon === null) return true; + if (this.autoRetryStateUnrecorded) await this.persistAutoRetryState(); + return !this.autoRetryStateUnrecorded; + } + private async persistAutoRetryEnabledPreference(enabled: boolean): Promise { this.autoRetryEnabledPreference = enabled; await this.persistAutoRetryState(); @@ -1560,21 +1567,21 @@ export class AgentSession { private async persistStartupAutoRetryAbandon( reason: string, userMessageId?: string - ): Promise { + ): Promise { this.startupAutoRetryAbandon = { reason, ...(userMessageId ? { userMessageId } : {}), }; - return this.persistAutoRetryState(); + await this.persistAutoRetryState(); } - private async clearStartupAutoRetryAbandon(): Promise { + private async clearStartupAutoRetryAbandon(): Promise { if (this.startupAutoRetryAbandon === null) { - return true; + return; } this.startupAutoRetryAbandon = null; - return this.persistAutoRetryState(); + await this.persistAutoRetryState(); } async handleProviderConfigChanged(): Promise { @@ -1591,30 +1598,31 @@ export class AgentSession { private async updateStartupAutoRetryAbandonFromFailure( errorType: string, userMessageId?: string - ): Promise { + ): Promise { if ( isNonRetryableSendError({ type: errorType }) || isNonRetryableStreamError({ type: errorType }) ) { - return this.persistStartupAutoRetryAbandon(errorType, userMessageId); + await this.persistStartupAutoRetryAbandon(errorType, userMessageId); + return; } - return this.clearStartupAutoRetryAbandon(); + await this.clearStartupAutoRetryAbandon(); } private async updateStartupAutoRetryAbandonFromAbort( abortReason: StreamAbortReason | undefined, userMessageId?: string - ): Promise { + ): Promise { // "system" and "startup" aborts come from backend-orchestrated flows // (for example, mid-stream auto-compaction or canceling a pending startup). // They are not user intent and must not poison startup recovery with a // persisted non-retryable "aborted" marker. if (abortReason === "system" || abortReason === "startup") { - return true; + return; } - return this.updateStartupAutoRetryAbandonFromFailure("aborted", userMessageId); + await this.updateStartupAutoRetryAbandonFromFailure("aborted", userMessageId); } private isAiStreaming(): boolean { @@ -4033,31 +4041,11 @@ export class AgentSession { // startup recovery, so every exit below that skips PREPARING records the same abandon marker a // user-aborted stream leaves, before the send resolves (Stop joins the send for this). The // withdrawal can land during any await on the way out, including acceptance I/O, so each exit - // runs this check after its last other await. The marker writer is best-effort; a write it could - // not make leaves the row replayable, so the send fails with WITHDRAWN_WAKE_UNRECORDED_MESSAGE - // instead of its own outcome and the joining Stop is not acknowledged. - const abandonWithdrawnSend = async (): Promise | undefined> => { - if (cancelSignal?.aborted !== true) return undefined; - const recorded = await this.updateStartupAutoRetryAbandonFromAbort("user", userMessage.id); - return recorded - ? undefined - : Err(createUnknownSendMessageError(WITHDRAWN_WAKE_UNRECORDED_MESSAGE)); - }; - // Exits that still owe acceptance I/O run the withdrawal check last, since a Stop can land during - // that I/O; an unrecorded marker outranks an acceptance failure because only it keeps the row - // replayable. - const acceptThenAbandonWithdrawnSend = async (): Promise< - AgentSessionResult | undefined - > => { - try { - await internal?.onAccepted?.(); - } catch (error) { - return ( - (await abandonWithdrawnSend()) ?? - Err(createUnknownSendMessageError(getErrorMessage(error))) - ); + // runs this check after its last other await. + const abandonWithdrawnSend = async (): Promise => { + if (cancelSignal?.aborted === true) { + await this.updateStartupAutoRetryAbandonFromAbort("user", userMessage.id); } - return abandonWithdrawnSend(); }; // r54: the pre-turn batch is now irrevocable — rollbackPersistedTurnRows // is never invoked past this point, so even a failure in goal sync or @@ -4072,13 +4060,10 @@ export class AgentSession { // The durable row crossed the point of no return, so every later goal-sync failure must still // finalize this monitor wake. Startup recovery can resume the row without redelivering it, // unless a Stop withdrew the wake. - const failure = await acceptThenAbandonWithdrawnSend(); - if (failure != null) { - log.error("Goal sync failed for a monitor wake that could not finalize", { - workspaceId: this.workspaceId, - error: getErrorMessage(error), - }); - return failure; + try { + await internal?.onAccepted?.(); + } finally { + await abandonWithdrawnSend(); } } throw error; @@ -4096,8 +4081,11 @@ export class AgentSession { // wake past the point of no return is already durable, so finalize it before leaving. if (this.coordinator.disposed) { if (cancelSignal != null && cancellationDisabled) { - const failure = await acceptThenAbandonWithdrawnSend(); - if (failure != null) return failure; + try { + await internal?.onAccepted?.(); + } finally { + await abandonWithdrawnSend(); + } } return Ok(undefined); } @@ -4169,9 +4157,8 @@ export class AgentSession { if (this.coordinator.thinkingOverride === turnThinkingOverride) { this.coordinator.releaseThinkingOverride(turnThinkingOverride); } - return ( - (await abandonWithdrawnSend()) ?? Err(createUnknownSendMessageError(getErrorMessage(error))) - ); + await abandonWithdrawnSend(); + return Err(createUnknownSendMessageError(getErrorMessage(error))); } let acceptedPreStreamFailureNotified = false; @@ -4204,7 +4191,8 @@ export class AgentSession { // callback to revert it — returning without notifying would strand // that bookkeeping (r41). await notifyAcceptedPreStreamFailure(error); - return (await abandonWithdrawnSend()) ?? Err(error); + await abandonWithdrawnSend(); + return Err(error); } // A withdrawn send must not claim PREPARING (see abandonWithdrawnSend); it resolves Ok without // a stream, like cancelBeforeAcceptance and the disposed path above. @@ -4212,7 +4200,8 @@ export class AgentSession { if (this.coordinator.thinkingOverride === turnThinkingOverride) { this.coordinator.releaseThinkingOverride(turnThinkingOverride); } - return (await abandonWithdrawnSend()) ?? Ok(undefined); + await abandonWithdrawnSend(); + return Ok(undefined); } const preparedTurnAbortController = new AbortController(); diff --git a/src/node/services/bashMonitorWakeReconciler.ts b/src/node/services/bashMonitorWakeReconciler.ts index ef063ee1b2e..d42805fdc1e 100644 --- a/src/node/services/bashMonitorWakeReconciler.ts +++ b/src/node/services/bashMonitorWakeReconciler.ts @@ -72,9 +72,8 @@ export type BashMonitorWakeDeliveryState = }; export interface BashMonitorWakeReconcilerProcessManager { - pullMonitorWakeSignals( - ownerWorkspaceId: string - ): Promise | readonly BashMonitorProcessSnapshot[]; + /** Synchronous so a caller can snapshot the process frontier in the tick it decides to act. */ + pullMonitorWakeSignals(ownerWorkspaceId: string): readonly BashMonitorProcessSnapshot[]; getMonitorWakeDeliveryState( processId: string, originNotAfterMs: number @@ -162,6 +161,8 @@ interface ReconcileState { scheduled: boolean; promise?: Promise; dispatch?: DispatchState; + /** Frontier a committed stop still has to retire; applied before any dispatch. */ + owedRetirement?: readonly BashMonitorProcessSnapshot[]; } function signalKey(processId: string, createdAt: string): string { @@ -504,6 +505,10 @@ export class BashMonitorWakeReconciler { private async reconcileOnce(ownerWorkspaceId: string): Promise { const dispatch = await this.locks.withLock(ownerWorkspaceId, async () => { + // A stop's retirement that failed on transient I/O is retried here first (a throw lands in + // the reconcile retry backoff), so dismissed attention never dispatches when the stop's own + // idle transition reconciles. + await this.retireOwed(ownerWorkspaceId, this.state(ownerWorkspaceId)); const collected = await this.collect(ownerWorkspaceId, true); for (const readSettled of collected.deferredReads) { void readSettled.finally(() => this.scheduleReconcile(ownerWorkspaceId)); @@ -600,42 +605,49 @@ export class BashMonitorWakeReconciler { // without waiting behind that. The lock slot is reserved synchronously too, ahead of any // reconcile the stop's own stream abort triggers. this.abortDispatch(ownerWorkspaceId); + // Snapshot the process frontier on entry as well, in the same tick: output that arrives while + // the stop waits for the lock or settles is new and stays owed to the idle agent, so the + // retirement itself can run after the commit, or on a later reconcile if its I/O fails. + const frontier = this.args.processManager.pullMonitorWakeSignals(ownerWorkspaceId); const committed = await this.locks.withLock(ownerWorkspaceId, async () => { this.abortDispatch(ownerWorkspaceId); - // Snapshot before waiting on the stop: output that arrives while it settles is new and - // stays owed to the idle agent. - const collected = await this.collect(ownerWorkspaceId, false); if (commit != null && !(await commit())) return false; - const consumed = [...collected.signals, ...collected.autoConsumed]; - await this.advanceWatermarks(ownerWorkspaceId, collected.watermarks, consumed); - await this.cleanup(consumed); + const state = this.state(ownerWorkspaceId); + const owed = new Map( + (state.owedRetirement ?? []).map((s) => [signalKey(s.processId, s.createdAt), s] as const) + ); + for (const s of frontier) owed.set(signalKey(s.processId, s.createdAt), s); + state.owedRetirement = [...owed.values()]; + await this.retireOwed(ownerWorkspaceId, state); return true; }); if (!committed) this.scheduleReconcile(ownerWorkspaceId); } - private async collect( + private async retireOwed(ownerWorkspaceId: string, state: ReconcileState): Promise { + if (state.owedRetirement == null) return; + const collected = await this.collect(ownerWorkspaceId, false, state.owedRetirement); + const consumed = [...collected.signals, ...collected.autoConsumed]; + await this.advanceWatermarks(ownerWorkspaceId, collected.watermarks, consumed); + await this.cleanup(consumed); + state.owedRetirement = undefined; + } + + /** Live monitors merged with their registry rows, plus registry rows whose process is gone. */ + private async candidates( ownerWorkspaceId: string, - applyFrontier: boolean - ): Promise<{ - signals: DerivedSignal[]; - autoConsumed: DerivedSignal[]; - deferredReads: Array>; - watermarks: Map; - }> { - await this.deleteLegacyWakeDirOnce(ownerWorkspaceId); - const [live, registryRows, watermarks] = await Promise.all([ - this.args.processManager.pullMonitorWakeSignals(ownerWorkspaceId), - this.args.registry.listAll(ownerWorkspaceId), - this.readWatermarks(ownerWorkspaceId), - ]); + live: readonly BashMonitorProcessSnapshot[] = this.args.processManager.pullMonitorWakeSignals( + ownerWorkspaceId + ) + ): Promise> { + const registryRows = await this.args.registry.listAll(ownerWorkspaceId); const registryByKey = new Map( registryRows.map((record) => [signalKey(record.processId, record.createdAt), record] as const) ); const liveKeys = new Set( live.map((snapshot) => signalKey(snapshot.processId, snapshot.createdAt)) ); - const candidates: Array<{ snapshot: BashMonitorProcessSnapshot; deadRegistryRow: boolean }> = [ + return [ ...live.map((snapshot) => { const record = registryByKey.get(signalKey(snapshot.processId, snapshot.createdAt)); return { @@ -656,6 +668,23 @@ export class BashMonitorWakeReconciler { deadRegistryRow: true, })), ]; + } + + private async collect( + ownerWorkspaceId: string, + applyFrontier: boolean, + liveAsOf?: readonly BashMonitorProcessSnapshot[] + ): Promise<{ + signals: DerivedSignal[]; + autoConsumed: DerivedSignal[]; + deferredReads: Array>; + watermarks: Map; + }> { + await this.deleteLegacyWakeDirOnce(ownerWorkspaceId); + const [candidates, watermarks] = await Promise.all([ + this.candidates(ownerWorkspaceId, liveAsOf), + this.readWatermarks(ownerWorkspaceId), + ]); const activeKeys = new Set( candidates.map(({ snapshot }) => signalKey(snapshot.processId, snapshot.createdAt)) ); diff --git a/src/node/services/workspaceService.test.ts b/src/node/services/workspaceService.test.ts index 4cefaacbe8b..b05be7329cb 100644 --- a/src/node/services/workspaceService.test.ts +++ b/src/node/services/workspaceService.test.ts @@ -1,13 +1,15 @@ import type { TurnCompletion } from "./streamManager"; import { describe, expect, test, mock, beforeEach, afterEach, spyOn, type Mock } from "bun:test"; -import { WorkspaceService, generateForkBranchName, generateForkTitle } from "./workspaceService"; +import { + WorkspaceService, + generateForkBranchName, + generateForkTitle, + STOP_UNRECORDED_MESSAGE, +} from "./workspaceService"; import { registerInProcessWorkflowRun } from "@/node/services/workflows/workflowArchiveAdmission"; import type { IdleCompactionOutcome } from "./idleCompactionService"; import type { AgentSession } from "./agentSession"; -import { - CONTEXT_MUTATION_SEND_BLOCKED_MESSAGE, - WITHDRAWN_WAKE_UNRECORDED_MESSAGE, -} from "./agentSession"; +import { CONTEXT_MUTATION_SEND_BLOCKED_MESSAGE } from "./agentSession"; import { createAgentSessionHarness, createStartedTurnHandle, @@ -96,6 +98,8 @@ import { sandboxHostService } from "./sandbox/sandboxHostService"; import type { BashMonitorProcessSnapshot, BashMonitorWakeReconciler, + BashMonitorWakeReconcilerProcessManager, + BashMonitorWakeReconcilerRegistry, BashMonitorWakeDispatch, } from "./bashMonitorWakeReconciler"; @@ -247,7 +251,7 @@ describe("WorkspaceService bash monitor wake reconciler wiring", () => { cleanup: mock(() => Promise.resolve()), notifyMonitorWakeStateChanged: mock(() => undefined), getActiveMonitorCount: mock(() => 0), - pullMonitorWakeSignals: mock(() => Promise.resolve([])), + pullMonitorWakeSignals: mock(() => []), getMonitorWakeDeliveryState: mock(() => Promise.resolve(undefined)), acknowledgeMonitorWake: mock(() => undefined), dropRetiredMonitor: mock(() => undefined), @@ -327,7 +331,9 @@ describe("WorkspaceService bash monitor wake reconciler wiring", () => { Promise.resolve({ model, agentId: "exec" }); const signals: BashMonitorProcessSnapshot[] = []; let shown = 0; - spyOn(backgroundProcessManager, "pullMonitorWakeSignals").mockImplementation(() => signals); + spyOn(backgroundProcessManager, "pullMonitorWakeSignals").mockImplementation(() => [ + ...signals, + ]); spyOn(backgroundProcessManager, "getMonitorWakeDeliveryState").mockImplementation(() => Promise.resolve({ status: "settled", shownThroughOffset: shown, terminalStatusShown: false }) ); @@ -667,6 +673,39 @@ describe("WorkspaceService bash monitor wake reconciler wiring", () => { } }); + test("attention a hard Stop failed to retire is retired before any later wake", async () => { + const h = await createActiveWakeHarness(); + try { + await h.session.sendMessage("original", { model: h.model, agentId: "exec" }); + await h.addAttention(10); + const reconcilerInternal = h.reconciler as unknown as { + args: { registry: BashMonitorWakeReconcilerRegistry }; + }; + spyOn(reconcilerInternal.args.registry, "listAll").mockRejectedValueOnce( + new Error("transient registry read") + ); + spyOn(h.aiService, "stopStream").mockImplementation(async () => { + h.abort("user"); + await h.session.waitForIdle(); + return Ok(undefined); + }); + spyOn(h.aiService, "isStreaming").mockReturnValue(false); + expect( + (await h.service.interruptStream(h.workspaceId, { retireBashMonitorAttention: true })) + .success + ).toBe(true); + await h.internal.pendingBashMonitorWakeIdleWaitsByOwner.get(h.workspaceId); + await h.reconciler.reconcile(h.workspaceId); + // The stop's idle reconcile retried the retirement instead of re-dispatching the output. + expect(h.requests).toHaveLength(1); + expect((await h.reconciler.snapshot(h.workspaceId)).pendingWakeKinds.size).toBe(0); + await h.addAttention(20); + expect(h.requests).toHaveLength(2); + } finally { + await h.finish(); + } + }); + test("a failed hard Stop keeps owed attention for the idle wake", async () => { const h = await createActiveWakeHarness(); try { @@ -707,6 +746,30 @@ describe("WorkspaceService bash monitor wake reconciler wiring", () => { } }); + test("a wake deferred as its idle wait hands off installs the next idle wait", async () => { + const h = await createActiveWakeHarness(); + try { + const internal = h.internal as typeof h.internal & { + scheduleBashMonitorWakeReconcileAfterIdle(ownerWorkspaceId: string): void; + }; + const waits = h.internal.pendingBashMonitorWakeIdleWaitsByOwner; + internal.scheduleBashMonitorWakeReconcileAfterIdle(h.workspaceId); + const handedOff = waits.get(h.workspaceId); + let replacedDuringHandoff: boolean | undefined; + spyOn(h.reconciler, "scheduleReconcile").mockImplementationOnce(() => { + // A turn that started as the wait resolved defers the wake from inside the wait's own + // hand-off; the finished wait must not swallow the re-arm as a duplicate. + internal.scheduleBashMonitorWakeReconcileAfterIdle(h.workspaceId); + replacedDuringHandoff = waits.get(h.workspaceId) !== handedOff; + }); + await handedOff; + expect(replacedDuringHandoff).toBe(true); + await waits.get(h.workspaceId); + } finally { + await h.finish(); + } + }); + test("hard Stop during a wake's acceptance window keeps the wake from streaming", async () => { const h = await createActiveWakeHarness(); try { @@ -736,7 +799,7 @@ describe("WorkspaceService bash monitor wake reconciler wiring", () => { const release = createDeferred(); try { const sessionInternal = h.session as unknown as { - persistAutoRetryState(): Promise; + persistAutoRetryState(): Promise; getAutoRetryPreferencePath(): string; }; const persist = sessionInternal.persistAutoRetryState.bind(h.session); @@ -778,12 +841,13 @@ describe("WorkspaceService bash monitor wake reconciler wiring", () => { } }); - test("hard Stop during a wake's acceptance window fails when the withdrawn wake's abandon marker cannot be written", async () => { + test("hard Stop during a wake's acceptance window fails until the withdrawn wake's abandon marker is written", async () => { const h = await createActiveWakeHarness(); try { const sessionInternal = h.session as unknown as { getAutoRetryPreferencePath(): string }; + const preferencePath = sessionInternal.getAutoRetryPreferencePath(); // A directory at the preference path makes the marker write fail (EISDIR). - await fsPromises.mkdir(sessionInternal.getAutoRetryPreferencePath(), { recursive: true }); + await fsPromises.mkdir(preferencePath, { recursive: true }); let stop: Promise> | undefined; const unsubscribe = h.session.onChatEvent(({ message: event }) => { if (event.type === "message" && event.role === "user" && stop == null) { @@ -793,10 +857,51 @@ describe("WorkspaceService bash monitor wake reconciler wiring", () => { await h.addAttention(10); unsubscribe(); expect(stop).toBeDefined(); - expect(await stop!).toEqual(Err(WITHDRAWN_WAKE_UNRECORDED_MESSAGE)); + expect(await stop!).toEqual(Err(STOP_UNRECORDED_MESSAGE)); expect(h.requests).toHaveLength(0); expect(h.session.isBusy()).toBe(false); + // The obligation outlives the joined send: a later Stop retries the write once it can succeed. + await fsPromises.rmdir(preferencePath); + expect( + (await h.service.interruptStream(h.workspaceId, { retireBashMonitorAttention: true })) + .success + ).toBe(true); + const persisted = JSON.parse(await fsPromises.readFile(preferencePath, "utf-8")) as { + startupAutoRetryAbandon?: { reason: string; userMessageId?: string }; + }; + expect(persisted.startupAutoRetryAbandon?.reason).toBe("aborted"); + expect(persisted.startupAutoRetryAbandon?.userMessageId).toBeDefined(); + } finally { + await h.finish(); + } + }); + + test("output arriving while a hard Stop waits behind a wake's acceptance stays owed", async () => { + const h = await createActiveWakeHarness(); + const release = createDeferred(); + try { + const acknowledging = createDeferred(); + const reconcilerInternal = h.reconciler as unknown as { + args: { processManager: BashMonitorWakeReconcilerProcessManager }; + }; + spyOn(reconcilerInternal.args.processManager, "acknowledgeMonitorWake").mockImplementation( + async () => { + acknowledging.resolve(); + await release.promise; + } + ); + const attention = h.addAttention(10); + await acknowledging.promise; + // Acceptance holds the reconciler lock; the Stop snapshots the frontier (10) on entry and waits. + const stop = h.service.interruptStream(h.workspaceId, { retireBashMonitorAttention: true }); + const later = h.addAttention(20); + release.resolve(); + expect((await stop).success).toBe(true); + await Promise.all([attention, later]); + // Only the frontier the Stop saw was retired; the newer output woke the idle agent. + expect(h.requests).toHaveLength(1); } finally { + release.resolve(); await h.finish(); } }); diff --git a/src/node/services/workspaceService.ts b/src/node/services/workspaceService.ts index f390d90ff38..ce673299dee 100644 --- a/src/node/services/workspaceService.ts +++ b/src/node/services/workspaceService.ts @@ -45,7 +45,6 @@ import { CONTEXT_MUTATION_SEND_BLOCKED_MESSAGE, inheritOpenWorkspaceTurnMetadata, type StreamErrorRecoveryOutcome, - WITHDRAWN_WAKE_UNRECORDED_MESSAGE, } from "@/node/services/agentSession"; import type { QueueCutCutter } from "@/node/services/messageQueue"; import { cancelReasonBeforeAcceptance } from "@/node/services/messageQueue"; @@ -700,6 +699,10 @@ const WORKSPACE_IDLE_WAIT_CANCELED_MESSAGE = const IDLE_ONLY_BUSY_SKIP_MESSAGE = "Workspace is busy; idle-only send was skipped."; const BASH_MONITOR_PERSIST_RETRY_DELAYS_MS = [50, 200] as const; +/** Returned by a user Stop whose startup abandon marker could not be written (see interruptStream). */ +export const STOP_UNRECORDED_MESSAGE = + "Stop could not be recorded on disk, so the stopped turn may resume on restart."; + /** Returned when a caller-supplied admission probe (internal.admissionStale) flips mid-send. */ const SEND_ADMISSION_STALE_MESSAGE = "Send refused: the target was stopped or interrupted while the message was being admitted."; @@ -1851,10 +1854,7 @@ export class WorkspaceService extends EventEmitter implements WorkspaceHost { private readonly constructedAtMs = Date.now(); private readonly pendingBashMonitorWakeIdleWaitsByOwner = new Map>(); /** The wake send in flight per owner (at most one: dispatch runs under the history lock). */ - private readonly inFlightBashMonitorWakeSendsByOwner = new Map< - string, - Promise> - >(); + private readonly inFlightBashMonitorWakeSendsByOwner = new Map>(); private readonly bashMonitorHistoryLocks = new MutexMap(); private readonly bashMonitorRecoveryPromise: Promise; private readonly pendingBashMonitorPersistenceByWorkspace = new Map>>(); @@ -2375,7 +2375,7 @@ export class WorkspaceService extends EventEmitter implements WorkspaceHost { pullMonitorWakeSignals: (ownerWorkspaceId) => typeof monitorManager.pullMonitorWakeSignals === "function" ? monitorManager.pullMonitorWakeSignals(ownerWorkspaceId) - : Promise.resolve([]), + : [], getMonitorWakeDeliveryState: (processId, originNotAfterMs) => typeof monitorManager.getMonitorWakeDeliveryState === "function" ? monitorManager.getMonitorWakeDeliveryState(processId, originNotAfterMs) @@ -2511,11 +2511,13 @@ export class WorkspaceService extends EventEmitter implements WorkspaceHost { error, }); }) - .then(() => this.scheduleBashMonitorWakeReconcile(ownerWorkspaceId)) - .finally(() => { + .then(() => { + // Release the slot before scheduling: the reconcile may find a new turn already running + // and must be able to install the next idle wait. if (this.pendingBashMonitorWakeIdleWaitsByOwner.get(ownerWorkspaceId) === promise) { this.pendingBashMonitorWakeIdleWaitsByOwner.delete(ownerWorkspaceId); } + this.scheduleBashMonitorWakeReconcile(ownerWorkspaceId); }); this.pendingBashMonitorWakeIdleWaitsByOwner.set(ownerWorkspaceId, promise); } @@ -11635,8 +11637,9 @@ export class WorkspaceService extends EventEmitter implements WorkspaceHost { // lock ahead of the reconcile this abort's idle transition triggers, so the abort itself // never waits behind acceptance I/O. The durable consumption commits only once the stop // succeeded: a failed stop leaves the agent running, so its output stays owed. Monitors stay - // armed for new output. Best-effort, and never behind the history lock (a wake admission - // holds it across stream construction). + // armed for new output. Retirement I/O that fails does not fail the Stop; the reconciler + // keeps it owed and retries it before any dispatch. Never behind the history lock (a wake + // admission holds it across stream construction). const retiring = options?.retireBashMonitorAttention === true; const withdrawnWakeSend = retiring ? this.inFlightBashMonitorWakeSendsByOwner.get(workspaceId) @@ -11663,14 +11666,12 @@ export class WorkspaceService extends EventEmitter implements WorkspaceHost { // session interrupt above saw idle) records the startup abandon marker for that row on every // exit before it resolves, including a failed goal sync or acceptance (see // abandonWithdrawnSend in AgentSession.sendMessage). Stop is acknowledged after it settles: a - // forced exit right after Stop must not leave the row eligible for startup replay. A marker - // the send could not write fails the Stop below; its other outcomes are the dispatch's to - // report. - const withdrawnWakeResult = await withdrawnWakeSend?.catch(() => undefined); - const withdrawnWakeUnrecorded = - withdrawnWakeResult?.success === false && - withdrawnWakeResult.error.type === "unknown" && - withdrawnWakeResult.error.raw === WITHDRAWN_WAKE_UNRECORDED_MESSAGE; + // forced exit right after Stop must not leave the row eligible for startup replay. The send's + // own result is the dispatch's to report; a marker still unrecorded after the session retried + // the write fails the Stop below, on this and every later Stop, so the obligation is not lost + // with the joined send. + await withdrawnWakeSend?.catch(() => undefined); + const abandonRecorded = !retiring || (await session.recordPendingStartupAutoRetryAbandon()); if (!stopResult.success) { // Interrupt failed, so clear hard-interrupt suppression we set above. if (!options?.soft) { @@ -11720,11 +11721,9 @@ export class WorkspaceService extends EventEmitter implements WorkspaceHost { session.restoreQueueToInput(); } - if (withdrawnWakeUnrecorded) { - log.error("Stop left a withdrawn monitor wake eligible for startup replay", { - workspaceId, - }); - return Err(WITHDRAWN_WAKE_UNRECORDED_MESSAGE); + if (!abandonRecorded) { + log.error("Stop left the stopped turn eligible for startup replay", { workspaceId }); + return Err(STOP_UNRECORDED_MESSAGE); } return Ok(undefined); } catch (error) { From 0508fd5c7688f46c6ac73928b23dbcfd0dd92554 Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Mon, 7 Sep 2026 05:31:19 +0000 Subject: [PATCH 14/31] =?UTF-8?q?=F0=9F=A4=96=20fix:=20fail=20an=20unrecor?= =?UTF-8?q?ded=20Stop=20retirement=20and=20bound=20it=20to=20the=20stop-ti?= =?UTF-8?q?me=20frontier?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A user Stop whose monitor-attention retirement fails on I/O now returns STOP_UNRECORDED_MESSAGE: the owed frontier survives only in memory, so a restart before the retry could wake the agent on the dismissed output. The stream still stops and the reconciler and the next Stop keep retrying the retirement. Retirement against a stop-time frontier no longer merges registry terminal or lost records the process manager produced after the snapshot, and no longer treats a monitor armed since as a dead registry row. The live set is pulled after the registry read on every path so a monitor armed mid-read cannot be mistaken for a dead row either. --- .../bashMonitorWakeReconciler.test.ts | 91 +++++++++++++++++++ .../services/bashMonitorWakeReconciler.ts | 33 +++++-- src/node/services/workspaceService.test.ts | 30 +----- src/node/services/workspaceService.ts | 24 +++-- 4 files changed, 138 insertions(+), 40 deletions(-) diff --git a/src/node/services/bashMonitorWakeReconciler.test.ts b/src/node/services/bashMonitorWakeReconciler.test.ts index 807398b9ef7..7a607af0d25 100644 --- a/src/node/services/bashMonitorWakeReconciler.test.ts +++ b/src/node/services/bashMonitorWakeReconciler.test.ts @@ -222,6 +222,97 @@ describe("BashMonitorWakeReconciler", () => { expect(dispatches[1].cancelSignal.aborted).toBe(false); }); + test("consumeCurrent leaves a settlement recorded after the stop request owed", async () => { + live = [liveSnapshot()]; + await reconciler.reconcile(OWNER); + const stop = Promise.withResolvers(); + const stopRequested = Promise.withResolvers(); + const consuming = reconciler.consumeCurrent(OWNER, () => { + stopRequested.resolve(); + return stop.promise; + }); + await stopRequested.promise; + const terminal: BashMonitorTerminalSummary = { + status: "exited", + exitCode: 0, + settledAt: "2026-08-31T12:00:05.000Z", + wakeOnExit: true, + terminalStatusShown: false, + }; + live = [liveSnapshot({ terminal })]; + rows = [{ ...registryRecord(terminal), processId: "proc", taskId: "bash:proc" }]; + stop.resolve(true); + await consuming; + + await reconciler.reconcile(OWNER); + expect(dispatches).toHaveLength(2); + expect(dispatches[1].muxMetadata.records[0]).toMatchObject({ + processId: "proc", + wakeUpdatedAt: terminal.settledAt, + terminal: { status: "exited", exitCode: 0 }, + }); + }); + + test("consumeCurrent leaves a monitor failure recorded after the stop request owed", async () => { + live = [liveSnapshot()]; + await reconciler.reconcile(OWNER); + const stop = Promise.withResolvers(); + const stopRequested = Promise.withResolvers(); + const consuming = reconciler.consumeCurrent(OWNER, () => { + stopRequested.resolve(); + return stop.promise; + }); + await stopRequested.promise; + live = [liveSnapshot({ retired: true })]; + rows = [ + { + ...registryRecord(), + processId: "proc", + taskId: "bash:proc", + lost: { reason: "runtime-failure", failedAt: "2026-08-31T12:00:05.000Z" }, + }, + ]; + stop.resolve(true); + await consuming; + + await reconciler.reconcile(OWNER); + expect(dispatches).toHaveLength(2); + expect(dispatches[1].muxMetadata.records[0]).toMatchObject({ + processId: "proc", + kind: "monitor-lost", + }); + }); + + test("consumeCurrent keeps the registry row of a monitor armed after the stop request", async () => { + live = [liveSnapshot()]; + await reconciler.reconcile(OWNER); + const stop = Promise.withResolvers(); + const stopRequested = Promise.withResolvers(); + const consuming = reconciler.consumeCurrent(OWNER, () => { + stopRequested.resolve(); + return stop.promise; + }); + await stopRequested.promise; + const later = liveSnapshot({ + processId: "later", + taskId: "bash:later", + createdAt: "2026-08-31T12:00:05.000Z", + }); + live = [liveSnapshot(), later]; + rows = [ + { ...registryRecord(), processId: "later", taskId: "bash:later", createdAt: later.createdAt }, + ]; + stop.resolve(true); + await consuming; + + expect(removed).toEqual([]); + await reconciler.reconcile(OWNER); + expect(dispatches).toHaveLength(2); + expect(dispatches[1].muxMetadata.records).toEqual([ + expect.objectContaining({ processId: "later", kind: "match" }), + ]); + }); + test("keeps dead registry evidence until the queued wake is accepted", async () => { rows = [registryRecord()]; diff --git a/src/node/services/bashMonitorWakeReconciler.ts b/src/node/services/bashMonitorWakeReconciler.ts index d42805fdc1e..265b5bd7bc8 100644 --- a/src/node/services/bashMonitorWakeReconciler.ts +++ b/src/node/services/bashMonitorWakeReconciler.ts @@ -633,36 +633,55 @@ export class BashMonitorWakeReconciler { state.owedRetirement = undefined; } - /** Live monitors merged with their registry rows, plus registry rows whose process is gone. */ + /** + * Live monitors merged with their registry rows, plus registry rows whose process is gone. The + * live set is pulled after the registry read so a monitor armed during the read is never taken + * for a dead row. With `asOf`, a frontier snapshotted earlier, registry state is bounded to that + * moment: terminal and lost records land only after the monitor settled or stopped in memory, so + * a snapshot without terminal was still running and one not retired had not failed, and a row + * live now but absent from the snapshot was armed since. Whatever arose since stays owed. + */ private async candidates( ownerWorkspaceId: string, - live: readonly BashMonitorProcessSnapshot[] = this.args.processManager.pullMonitorWakeSignals( - ownerWorkspaceId - ) + asOf?: readonly BashMonitorProcessSnapshot[] ): Promise> { const registryRows = await this.args.registry.listAll(ownerWorkspaceId); + const current = this.args.processManager.pullMonitorWakeSignals(ownerWorkspaceId); + const live = asOf ?? current; const registryByKey = new Map( registryRows.map((record) => [signalKey(record.processId, record.createdAt), record] as const) ); const liveKeys = new Set( live.map((snapshot) => signalKey(snapshot.processId, snapshot.createdAt)) ); + const armedSince = new Set( + asOf == null + ? [] + : current + .map((snapshot) => signalKey(snapshot.processId, snapshot.createdAt)) + .filter((key) => !liveKeys.has(key)) + ); return [ ...live.map((snapshot) => { const record = registryByKey.get(signalKey(snapshot.processId, snapshot.createdAt)); return { snapshot: { ...snapshot, - ...(snapshot.terminal == null && record?.terminal != null + ...(asOf == null && snapshot.terminal == null && record?.terminal != null ? { terminal: record.terminal } : {}), - ...(record?.lost != null ? { lost: record.lost } : {}), + ...(record?.lost != null && (asOf == null || snapshot.retired) + ? { lost: record.lost } + : {}), }, deadRegistryRow: false, }; }), ...registryRows - .filter((record) => !liveKeys.has(signalKey(record.processId, record.createdAt))) + .filter((record) => { + const key = signalKey(record.processId, record.createdAt); + return !liveKeys.has(key) && !armedSince.has(key); + }) .map((record) => ({ snapshot: this.fromRegistry(record, ownerWorkspaceId), deadRegistryRow: true, diff --git a/src/node/services/workspaceService.test.ts b/src/node/services/workspaceService.test.ts index b05be7329cb..0f1dc9b51c1 100644 --- a/src/node/services/workspaceService.test.ts +++ b/src/node/services/workspaceService.test.ts @@ -652,28 +652,7 @@ describe("WorkspaceService bash monitor wake reconciler wiring", () => { } }); - test("hard Stop succeeds when retiring owed attention fails", async () => { - const h = await createActiveWakeHarness(); - try { - await h.session.sendMessage("original", { model: h.model, agentId: "exec" }); - spyOn(h.reconciler, "consumeCurrent").mockRejectedValueOnce(new Error("watermark write")); - spyOn(h.aiService, "stopStream").mockImplementation(async () => { - h.abort("user"); - await h.session.waitForIdle(); - return Ok(undefined); - }); - spyOn(h.aiService, "isStreaming").mockReturnValue(false); - expect( - (await h.service.interruptStream(h.workspaceId, { retireBashMonitorAttention: true })) - .success - ).toBe(true); - expect(h.stopStream).toHaveBeenCalledTimes(1); - } finally { - await h.finish(); - } - }); - - test("attention a hard Stop failed to retire is retired before any later wake", async () => { + test("a hard Stop whose retirement failed reports it and the retirement lands before any later wake", async () => { const h = await createActiveWakeHarness(); try { await h.session.sendMessage("original", { model: h.model, agentId: "exec" }); @@ -690,10 +669,11 @@ describe("WorkspaceService bash monitor wake reconciler wiring", () => { return Ok(undefined); }); spyOn(h.aiService, "isStreaming").mockReturnValue(false); + // The stream stopped, but the dismissal is only in memory, so the Stop reports it. expect( - (await h.service.interruptStream(h.workspaceId, { retireBashMonitorAttention: true })) - .success - ).toBe(true); + await h.service.interruptStream(h.workspaceId, { retireBashMonitorAttention: true }) + ).toEqual(Err(STOP_UNRECORDED_MESSAGE)); + expect(h.stopStream).toHaveBeenCalledTimes(1); await h.internal.pendingBashMonitorWakeIdleWaitsByOwner.get(h.workspaceId); await h.reconciler.reconcile(h.workspaceId); // The stop's idle reconcile retried the retirement instead of re-dispatching the output. diff --git a/src/node/services/workspaceService.ts b/src/node/services/workspaceService.ts index ce673299dee..f5620777681 100644 --- a/src/node/services/workspaceService.ts +++ b/src/node/services/workspaceService.ts @@ -699,9 +699,12 @@ const WORKSPACE_IDLE_WAIT_CANCELED_MESSAGE = const IDLE_ONLY_BUSY_SKIP_MESSAGE = "Workspace is busy; idle-only send was skipped."; const BASH_MONITOR_PERSIST_RETRY_DELAYS_MS = [50, 200] as const; -/** Returned by a user Stop whose startup abandon marker could not be written (see interruptStream). */ +/** + * Returned by a user Stop whose startup abandon marker or monitor-attention retirement could not + * be written (see interruptStream). + */ export const STOP_UNRECORDED_MESSAGE = - "Stop could not be recorded on disk, so the stopped turn may resume on restart."; + "Stop could not be recorded on disk, so the stopped work may resume on restart."; /** Returned when a caller-supplied admission probe (internal.admissionStale) flips mid-send. */ const SEND_ADMISSION_STALE_MESSAGE = @@ -11637,18 +11640,22 @@ export class WorkspaceService extends EventEmitter implements WorkspaceHost { // lock ahead of the reconcile this abort's idle transition triggers, so the abort itself // never waits behind acceptance I/O. The durable consumption commits only once the stop // succeeded: a failed stop leaves the agent running, so its output stays owed. Monitors stay - // armed for new output. Retirement I/O that fails does not fail the Stop; the reconciler - // keeps it owed and retries it before any dispatch. Never behind the history lock (a wake - // admission holds it across stream construction). + // armed for new output. Retirement I/O that fails keeps the frontier owed in memory (the + // reconciler retries it before any dispatch, as does the next Stop) but fails this Stop + // below: that obligation is not durable, so a restart before the retry could wake the agent + // on the dismissed output. Never behind the history lock (a wake admission holds it across + // stream construction). const retiring = options?.retireBashMonitorAttention === true; const withdrawnWakeSend = retiring ? this.inFlightBashMonitorWakeSendsByOwner.get(workspaceId) : undefined; const stopSettled = Promise.withResolvers(); + let retirementRecorded = true; const retirement = retiring ? this.bashMonitorWakeReconciler .consumeCurrent(workspaceId, () => stopSettled.promise) .catch((error: unknown) => { + retirementRecorded = false; log.warn("Failed to retire bash monitor attention before Stop", { workspaceId, error, @@ -11671,7 +11678,8 @@ export class WorkspaceService extends EventEmitter implements WorkspaceHost { // the write fails the Stop below, on this and every later Stop, so the obligation is not lost // with the joined send. await withdrawnWakeSend?.catch(() => undefined); - const abandonRecorded = !retiring || (await session.recordPendingStartupAutoRetryAbandon()); + const stopRecorded = + !retiring || ((await session.recordPendingStartupAutoRetryAbandon()) && retirementRecorded); if (!stopResult.success) { // Interrupt failed, so clear hard-interrupt suppression we set above. if (!options?.soft) { @@ -11721,8 +11729,8 @@ export class WorkspaceService extends EventEmitter implements WorkspaceHost { session.restoreQueueToInput(); } - if (!abandonRecorded) { - log.error("Stop left the stopped turn eligible for startup replay", { workspaceId }); + if (!stopRecorded) { + log.error("Stop left stopped work eligible to resume on restart", { workspaceId }); return Err(STOP_UNRECORDED_MESSAGE); } return Ok(undefined); From 573bc3f9ddbfd158ac295f8f6bf0e58464ad99ae Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Mon, 7 Sep 2026 05:48:25 +0000 Subject: [PATCH 15/31] =?UTF-8?q?=F0=9F=A4=96=20fix:=20surface=20an=20unre?= =?UTF-8?q?corded=20Stop=20to=20the=20user?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Renderer Stop paths discarded the interruptStream Result, so a Stop the backend could not record on disk looked successful. Route every user Stop through stopStream(), which shows the backend's error in the workspace's chat input via the (generalized) CHAT_ERROR_TOAST event. --- src/browser/features/ChatInput/index.tsx | 6 +- .../Messages/ChatBarrier/RetryBarrier.tsx | 10 ++- .../Messages/ChatBarrier/StreamingBarrier.tsx | 11 +--- src/browser/hooks/useAIViewKeybinds.ts | 8 +-- src/browser/utils/commands/sources.ts | 11 ++-- src/browser/utils/compaction/handler.ts | 6 +- ...pplyWorkspaceChatEventToAggregator.test.ts | 2 +- .../applyWorkspaceChatEventToAggregator.ts | 8 +-- src/browser/utils/stopStream.test.ts | 65 +++++++++++++++++++ src/browser/utils/stopStream.ts | 23 +++++++ src/common/constants/events.ts | 7 +- 11 files changed, 118 insertions(+), 39 deletions(-) create mode 100644 src/browser/utils/stopStream.test.ts create mode 100644 src/browser/utils/stopStream.ts diff --git a/src/browser/features/ChatInput/index.tsx b/src/browser/features/ChatInput/index.tsx index 3eee481a2b0..c50c3c8b1b9 100644 --- a/src/browser/features/ChatInput/index.tsx +++ b/src/browser/features/ChatInput/index.tsx @@ -1472,7 +1472,7 @@ const ChatInputInner: React.FC = (props) => { window.removeEventListener(CUSTOM_EVENTS.THINKING_LEVEL_TOAST, handler as EventListener); }, [variant, props, pushToast]); - // Show the backend's one-shot child-budget warning on the matching parent workspace. + // Error toasts addressed to this workspace (child-budget warnings, unrecorded Stops). useEffect(() => { if (variant !== "workspace") return; @@ -1485,9 +1485,9 @@ const ChatInputInner: React.FC = (props) => { pushToast({ type: "error", message: detail.message }); }; - window.addEventListener(CUSTOM_EVENTS.GOAL_CHILD_BUDGET_TOAST, handler as EventListener); + window.addEventListener(CUSTOM_EVENTS.CHAT_ERROR_TOAST, handler as EventListener); return () => - window.removeEventListener(CUSTOM_EVENTS.GOAL_CHILD_BUDGET_TOAST, handler as EventListener); + window.removeEventListener(CUSTOM_EVENTS.CHAT_ERROR_TOAST, handler as EventListener); }, [variant, workspaceId, pushToast]); // Show toast feedback for analytics rebuild command palette action. diff --git a/src/browser/features/Messages/ChatBarrier/RetryBarrier.tsx b/src/browser/features/Messages/ChatBarrier/RetryBarrier.tsx index 11a9ee87cf7..4b64163aa63 100644 --- a/src/browser/features/Messages/ChatBarrier/RetryBarrier.tsx +++ b/src/browser/features/Messages/ChatBarrier/RetryBarrier.tsx @@ -8,6 +8,7 @@ import { KEYBINDS, formatKeybind } from "@/browser/utils/ui/keybinds"; import { VIM_ENABLED_KEY } from "@/common/constants/storage"; import { getSendOptionsFromStorage } from "@/browser/utils/messages/sendOptions"; import { applyCompactionOverrides } from "@/browser/utils/messages/compactionOptions"; +import { stopStream } from "@/browser/utils/stopStream"; import { formatSendMessageError } from "@/common/utils/errors/formatSendError"; import { getErrorMessage } from "@/common/utils/errors"; @@ -237,12 +238,9 @@ export const RetryBarrier: React.FC = (props) => { setCountdown(0); setManualRetryError(null); void api?.workspace.setAutoRetryEnabled?.({ workspaceId: props.workspaceId, enabled: false }); - // Same Stop as the shortcut shown on the button (useAIViewKeybinds): owed monitor output is - // dismissed rather than launched as a wake once the retry stops. - void api?.workspace.interruptStream({ - workspaceId: props.workspaceId, - options: { retireBashMonitorAttention: true }, - }); + if (api) { + void stopStream(api, props.workspaceId); + } }; const lastMessage = getLastMainRetryCandidateMessage(workspaceState.messages); diff --git a/src/browser/features/Messages/ChatBarrier/StreamingBarrier.tsx b/src/browser/features/Messages/ChatBarrier/StreamingBarrier.tsx index ce5219f8693..383bd9dce9f 100644 --- a/src/browser/features/Messages/ChatBarrier/StreamingBarrier.tsx +++ b/src/browser/features/Messages/ChatBarrier/StreamingBarrier.tsx @@ -14,6 +14,7 @@ import { import { getDefaultModel } from "@/browser/hooks/useModelsFromSettings"; import { useSettings } from "@/browser/contexts/SettingsContext"; import { useAPI } from "@/browser/contexts/API"; +import { stopStream } from "@/browser/utils/stopStream"; type StreamingPhase = | "starting" // Message sent, waiting for stream-start @@ -274,10 +275,7 @@ export const StreamingBarrier: React.FC = ({ return; } - void api.workspace.interruptStream({ - workspaceId, - options: { abandonPartial: true, retireBashMonitorAttention: true }, - }); + void stopStream(api, workspaceId, { abandonPartial: true }); return; } @@ -285,10 +283,7 @@ export const StreamingBarrier: React.FC = ({ storeRaw.setInterrupting(workspaceId); } - void api.workspace.interruptStream({ - workspaceId, - options: { retireBashMonitorAttention: true }, - }); + void stopStream(api, workspaceId); }; // Show settings hint during compaction if no custom compaction model is configured diff --git a/src/browser/hooks/useAIViewKeybinds.ts b/src/browser/hooks/useAIViewKeybinds.ts index 442cd1a19c2..24971e6875b 100644 --- a/src/browser/hooks/useAIViewKeybinds.ts +++ b/src/browser/hooks/useAIViewKeybinds.ts @@ -12,6 +12,7 @@ import { } from "@/browser/utils/ui/keybinds"; import type { StreamingMessageAggregator } from "@/browser/utils/messages/StreamingMessageAggregator"; import { isCompactingStream, cancelCompaction } from "@/browser/utils/compaction/handler"; +import { stopStream } from "@/browser/utils/stopStream"; import { useAPI } from "@/browser/contexts/API"; import type { EditingMessageState } from "@/browser/utils/chatEditing"; @@ -121,10 +122,9 @@ export function useAIViewKeybinds({ if (canInterrupt || showRetryBarrier) { e.preventDefault(); void api?.workspace.setAutoRetryEnabled?.({ workspaceId, enabled: false }); - void api?.workspace.interruptStream({ - workspaceId, - options: { retireBashMonitorAttention: true }, - }); + if (api) { + void stopStream(api, workspaceId); + } return; } } diff --git a/src/browser/utils/commands/sources.ts b/src/browser/utils/commands/sources.ts index a7985de1515..86a887cba0d 100644 --- a/src/browser/utils/commands/sources.ts +++ b/src/browser/utils/commands/sources.ts @@ -35,6 +35,7 @@ import { import { readPersistedState, updatePersistedState } from "@/browser/hooks/usePersistedState"; import { CommandIds } from "@/browser/utils/commandIds"; import { publishAgentPluginsMutated } from "@/browser/utils/agentPluginMutations"; +import { stopStream } from "@/browser/utils/stopStream"; import { publishPluginsSectionIntent } from "@/browser/features/Settings/Sections/pluginsSectionIntents"; import { isTabType, type TabType } from "@/browser/types/rightSidebar"; import { @@ -1217,11 +1218,11 @@ export function buildCoreSources(p: BuildSourcesParams): Array<() => CommandActi if (p.selectedWorkspaceState?.awaitingUserQuestion) { return; } - await p.api?.workspace.setAutoRetryEnabled?.({ workspaceId: id, enabled: false }); - await p.api?.workspace.interruptStream({ - workspaceId: id, - options: { retireBashMonitorAttention: true }, - }); + if (!p.api) { + return; + } + await p.api.workspace.setAutoRetryEnabled?.({ workspaceId: id, enabled: false }); + await stopStream(p.api, id); }, }); list.push({ diff --git a/src/browser/utils/compaction/handler.ts b/src/browser/utils/compaction/handler.ts index 4dbdddddab6..b32ebbcf817 100644 --- a/src/browser/utils/compaction/handler.ts +++ b/src/browser/utils/compaction/handler.ts @@ -8,6 +8,7 @@ import type { StreamingMessageAggregator } from "@/browser/utils/messages/StreamingMessageAggregator"; import { getCompactionFollowUpContent } from "@/common/types/message"; import type { APIClient } from "@/browser/contexts/API"; +import { stopStream } from "@/browser/utils/stopStream"; import { stripStagedAttachmentNotice } from "@/browser/features/ChatInput/stagedAttachments"; import { buildEditingStateFromCompaction, @@ -100,10 +101,7 @@ export async function cancelCompaction( // Interrupt stream with abandonPartial flag // Backend detects this and skips compaction (Ctrl+C flow) - await client.workspace.interruptStream({ - workspaceId, - options: { abandonPartial: true, retireBashMonitorAttention: true }, - }); + await stopStream(client, workspaceId, { abandonPartial: true }); return true; } diff --git a/src/browser/utils/messages/applyWorkspaceChatEventToAggregator.test.ts b/src/browser/utils/messages/applyWorkspaceChatEventToAggregator.test.ts index 9877f5f4b76..975473199c5 100644 --- a/src/browser/utils/messages/applyWorkspaceChatEventToAggregator.test.ts +++ b/src/browser/utils/messages/applyWorkspaceChatEventToAggregator.test.ts @@ -214,7 +214,7 @@ describe("applyWorkspaceChatEventToAggregator", () => { expect(hint).toBe("ignored"); expect(aggregator.calls).toEqual([]); expect(dispatched).toHaveLength(1); - expect(dispatched[0]?.type).toBe(CUSTOM_EVENTS.GOAL_CHILD_BUDGET_TOAST); + expect(dispatched[0]?.type).toBe(CUSTOM_EVENTS.CHAT_ERROR_TOAST); expect((dispatched[0] as CustomEvent).detail).toEqual({ workspaceId: "parent-1", message: "Child workspace exceeded the parent's goal budget.", diff --git a/src/browser/utils/messages/applyWorkspaceChatEventToAggregator.ts b/src/browser/utils/messages/applyWorkspaceChatEventToAggregator.ts index e476b174e20..ae936680cda 100644 --- a/src/browser/utils/messages/applyWorkspaceChatEventToAggregator.ts +++ b/src/browser/utils/messages/applyWorkspaceChatEventToAggregator.ts @@ -94,11 +94,9 @@ function dispatchSkillsRefreshRequested(): void { window.dispatchEvent(new CustomEvent(CUSTOM_EVENTS.SKILLS_REFRESH_REQUESTED)); } -function dispatchGoalChildBudgetToast(workspaceId: string, message: string): void { +function dispatchChatErrorToast(workspaceId: string, message: string): void { if (typeof window === "undefined") return; - window.dispatchEvent( - createCustomEvent(CUSTOM_EVENTS.GOAL_CHILD_BUDGET_TOAST, { workspaceId, message }) - ); + window.dispatchEvent(createCustomEvent(CUSTOM_EVENTS.CHAT_ERROR_TOAST, { workspaceId, message })); } function dispatchMuxGatewaySessionExpired(): void { @@ -206,7 +204,7 @@ export function applyWorkspaceChatEventToAggregator( if (isGoalBudgetLimitedEvent(event)) { if (allowSideEffects && event.causedByChild) { - dispatchGoalChildBudgetToast(event.workspaceId, event.message); + dispatchChatErrorToast(event.workspaceId, event.message); } return "ignored"; } diff --git a/src/browser/utils/stopStream.test.ts b/src/browser/utils/stopStream.test.ts new file mode 100644 index 00000000000..a9a56f9a692 --- /dev/null +++ b/src/browser/utils/stopStream.test.ts @@ -0,0 +1,65 @@ +import { afterEach, beforeEach, describe, expect, test } from "bun:test"; +import { installDom } from "../../../tests/ui/dom"; +import type { APIClient } from "@/browser/contexts/API"; +import { CUSTOM_EVENTS } from "@/common/constants/events"; +import { stopStream } from "./stopStream"; + +describe("stopStream", () => { + let cleanupDom: (() => void) | null = null; + + beforeEach(() => { + cleanupDom = installDom(); + }); + + afterEach(() => { + cleanupDom?.(); + cleanupDom = null; + }); + + function apiReturning( + result: { success: true; data: undefined } | { success: false; error: string } + ): { api: APIClient; calls: unknown[] } { + const calls: unknown[] = []; + const api = { + workspace: { + interruptStream: (input: unknown) => { + calls.push(input); + return Promise.resolve(result); + }, + }, + } as unknown as APIClient; + return { api, calls }; + } + + function collectToasts(): unknown[] { + const toasts: unknown[] = []; + window.addEventListener(CUSTOM_EVENTS.CHAT_ERROR_TOAST, (event) => { + toasts.push((event as CustomEvent).detail); + }); + return toasts; + } + + test("a Stop the backend could not record is shown as a chat error toast", async () => { + const { api } = apiReturning({ success: false, error: "disk full" }); + const toasts = collectToasts(); + + await stopStream(api, "ws-1"); + + expect(toasts).toEqual([{ workspaceId: "ws-1", message: "disk full" }]); + }); + + test("a recorded Stop retires owed monitor output without a toast", async () => { + const { api, calls } = apiReturning({ success: true, data: undefined }); + const toasts = collectToasts(); + + await stopStream(api, "ws-1", { abandonPartial: true }); + + expect(calls).toEqual([ + { + workspaceId: "ws-1", + options: { abandonPartial: true, retireBashMonitorAttention: true }, + }, + ]); + expect(toasts).toEqual([]); + }); +}); diff --git a/src/browser/utils/stopStream.ts b/src/browser/utils/stopStream.ts new file mode 100644 index 00000000000..7789deb2e04 --- /dev/null +++ b/src/browser/utils/stopStream.ts @@ -0,0 +1,23 @@ +import type { APIClient } from "@/browser/contexts/API"; +import { CUSTOM_EVENTS, createCustomEvent } from "@/common/constants/events"; + +/** + * User Stop: interrupts the stream and dismisses owed background monitor output instead of letting + * it wake the agent. A Stop the backend could not record on disk may resume on restart, so its + * failure is shown in the workspace's chat input rather than dropped with the Result. + */ +export async function stopStream( + api: APIClient, + workspaceId: string, + options?: { abandonPartial?: boolean } +): Promise { + const result = await api.workspace.interruptStream({ + workspaceId, + options: { ...options, retireBashMonitorAttention: true }, + }); + if (!result.success) { + window.dispatchEvent( + createCustomEvent(CUSTOM_EVENTS.CHAT_ERROR_TOAST, { workspaceId, message: result.error }) + ); + } +} diff --git a/src/common/constants/events.ts b/src/common/constants/events.ts index 290c0e67525..6c9b08bb3b9 100644 --- a/src/common/constants/events.ts +++ b/src/common/constants/events.ts @@ -124,10 +124,11 @@ export const CUSTOM_EVENTS = { OPEN_GOAL_TAB: "mux:openGoalTab", /** - * Event to show a toast when a child task pushes the parent's goal over budget. + * Event to show an error toast in a workspace's chat input (child goal budget exhaustion, + * a Stop the backend could not record, ...). * Detail: { workspaceId: string, message: string } */ - GOAL_CHILD_BUDGET_TOAST: "mux:goalChildBudgetToast", + CHAT_ERROR_TOAST: "mux:chatErrorToast", REVEAL_TIMELINE_ANCHOR: "mux:revealTimelineAnchor", @@ -201,7 +202,7 @@ export interface CustomEventPayloads { workspaceId: string; openCompleteInput?: boolean; }; - [CUSTOM_EVENTS.GOAL_CHILD_BUDGET_TOAST]: { + [CUSTOM_EVENTS.CHAT_ERROR_TOAST]: { workspaceId: string; message: string; }; From 3ae390538c2e2a973c28022d0cf221e71583560b Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Mon, 7 Sep 2026 06:19:18 +0000 Subject: [PATCH 16/31] Retain chat error toasts per workspace; settle ACP cancel on an unrecorded Stop The Stop error toast was a one-shot window event filtered by workspaceId in ChatInput, so a Stop that settled after the user switched workspaces lost its STOP_UNRECORDED_MESSAGE warning. Chat errors now go through a workspace-keyed store (publishChatError / useChatErrorToasts) that drains when that workspace's input subscribes; the CHAT_ERROR_TOAST event is gone. ACP cancel treated STOP_UNRECORDED_MESSAGE as a failed interrupt and skipped resolveTurn, leaving the prompt() unresolved although the stream had stopped. The sentinel moves to src/common/constants/workspace.ts; cancel settles the prompt as cancelled for Ok and for the sentinel, then reports the durability failure. --- src/browser/features/ChatInput/index.tsx | 19 +---- src/browser/utils/chatErrorToasts.test.tsx | 72 +++++++++++++++++ src/browser/utils/chatErrorToasts.ts | 50 ++++++++++++ ...pplyWorkspaceChatEventToAggregator.test.ts | 77 +++++-------------- .../applyWorkspaceChatEventToAggregator.ts | 8 +- src/browser/utils/stopStream.test.ts | 42 +++------- src/browser/utils/stopStream.ts | 6 +- src/common/constants/events.ts | 11 --- src/common/constants/workspace.ts | 7 ++ src/node/acp/agent.ts | 22 +++--- src/node/services/workspaceService.test.ts | 8 +- src/node/services/workspaceService.ts | 8 +- tests/ipc/acp.promptCorrelation.test.ts | 20 +++++ 13 files changed, 201 insertions(+), 149 deletions(-) create mode 100644 src/browser/utils/chatErrorToasts.test.tsx create mode 100644 src/browser/utils/chatErrorToasts.ts diff --git a/src/browser/features/ChatInput/index.tsx b/src/browser/features/ChatInput/index.tsx index c50c3c8b1b9..838b7e2c9a8 100644 --- a/src/browser/features/ChatInput/index.tsx +++ b/src/browser/features/ChatInput/index.tsx @@ -78,6 +78,7 @@ import { } from "@/browser/utils/workflowRunMessages"; import { Button } from "@/browser/components/Button/Button"; import { CUSTOM_EVENTS } from "@/common/constants/events"; +import { useChatErrorToasts } from "@/browser/utils/chatErrorToasts"; import { EXPERIMENT_IDS } from "@/common/constants/experiments"; import { extractInlineSkillReferenceCandidates } from "@/browser/utils/agentSkills/inlineSkillReferences"; import { @@ -1472,23 +1473,7 @@ const ChatInputInner: React.FC = (props) => { window.removeEventListener(CUSTOM_EVENTS.THINKING_LEVEL_TOAST, handler as EventListener); }, [variant, props, pushToast]); - // Error toasts addressed to this workspace (child-budget warnings, unrecorded Stops). - useEffect(() => { - if (variant !== "workspace") return; - - const handler = (event: Event) => { - const detail = (event as CustomEvent<{ workspaceId: string; message: string }>).detail; - if (detail?.workspaceId !== workspaceId || !detail.message) { - return; - } - - pushToast({ type: "error", message: detail.message }); - }; - - window.addEventListener(CUSTOM_EVENTS.CHAT_ERROR_TOAST, handler as EventListener); - return () => - window.removeEventListener(CUSTOM_EVENTS.CHAT_ERROR_TOAST, handler as EventListener); - }, [variant, workspaceId, pushToast]); + useChatErrorToasts(workspaceId, pushToast); // Show toast feedback for analytics rebuild command palette action. useEffect(() => { diff --git a/src/browser/utils/chatErrorToasts.test.tsx b/src/browser/utils/chatErrorToasts.test.tsx new file mode 100644 index 00000000000..6ea4d259e08 --- /dev/null +++ b/src/browser/utils/chatErrorToasts.test.tsx @@ -0,0 +1,72 @@ +import { afterEach, beforeEach, describe, expect, test } from "bun:test"; +import { act, cleanup, renderHook } from "@testing-library/react"; +import { GlobalWindow } from "happy-dom"; +import { publishChatError, takeChatErrors, useChatErrorToasts } from "./chatErrorToasts"; + +describe("useChatErrorToasts", () => { + beforeEach(() => { + const domWindow = new GlobalWindow() as unknown as Window & typeof globalThis; + globalThis.window = domWindow; + globalThis.document = domWindow.document; + }); + + afterEach(() => { + cleanup(); + globalThis.window = undefined as unknown as Window & typeof globalThis; + globalThis.document = undefined as unknown as Document; + }); + + function mountInput(workspaceId: string | null) { + const shown: string[] = []; + const pushToast = (toast: { message: string }) => { + shown.push(toast.message); + }; + const rendered = renderHook( + (props: { workspaceId: string | null }) => useChatErrorToasts(props.workspaceId, pushToast), + { initialProps: { workspaceId } } + ); + return { shown, ...rendered }; + } + + test("an error published while no input for the workspace is mounted is shown once it mounts", () => { + publishChatError("ws-a", "Stop could not be recorded"); + + const input = mountInput("ws-a"); + + expect(input.shown).toEqual(["Stop could not be recorded"]); + expect(takeChatErrors("ws-a")).toEqual([]); + }); + + test("an error published while the input is mounted is shown immediately", () => { + const input = mountInput("ws-b"); + + act(() => { + publishChatError("ws-b", "Child exceeded the goal budget"); + }); + + expect(input.shown).toEqual(["Child exceeded the goal budget"]); + }); + + test("errors for another workspace stay retained for that workspace's input", () => { + const input = mountInput("ws-c"); + + act(() => { + publishChatError("ws-d", "for d"); + }); + + expect(input.shown).toEqual([]); + input.rerender({ workspaceId: "ws-d" }); + expect(input.shown).toEqual(["for d"]); + }); + + test("an error published after the input unmounts waits for the next mount", () => { + const first = mountInput("ws-e"); + first.unmount(); + + publishChatError("ws-e", "late Stop failure"); + expect(first.shown).toEqual([]); + + const second = mountInput("ws-e"); + expect(second.shown).toEqual(["late Stop failure"]); + }); +}); diff --git a/src/browser/utils/chatErrorToasts.ts b/src/browser/utils/chatErrorToasts.ts new file mode 100644 index 00000000000..f3176c7925e --- /dev/null +++ b/src/browser/utils/chatErrorToasts.ts @@ -0,0 +1,50 @@ +import { useEffect } from "react"; + +/** + * Error toasts addressed to a workspace's chat input (a child exhausting the parent's goal budget, + * a Stop the backend could not record). Retained until that input drains them: the error can land + * after the user switched workspaces (a Stop settles asynchronously), when no input for that + * workspace is mounted to receive a one-shot event. + */ +const pendingByWorkspace = new Map(); +const listenersByWorkspace = new Map void>>(); + +export function publishChatError(workspaceId: string, message: string): void { + const pending = pendingByWorkspace.get(workspaceId) ?? []; + pending.push(message); + pendingByWorkspace.set(workspaceId, pending); + for (const listener of listenersByWorkspace.get(workspaceId) ?? []) { + listener(); + } +} + +export function takeChatErrors(workspaceId: string): string[] { + const pending = pendingByWorkspace.get(workspaceId) ?? []; + pendingByWorkspace.delete(workspaceId); + return pending; +} + +/** Shows the workspace's retained and later chat errors through `pushToast`. */ +export function useChatErrorToasts( + workspaceId: string | null, + pushToast: (toast: { type: "error"; message: string }) => void +): void { + useEffect(() => { + if (workspaceId == null) return; + const drain = () => { + for (const message of takeChatErrors(workspaceId)) { + pushToast({ type: "error", message }); + } + }; + const listeners = listenersByWorkspace.get(workspaceId) ?? new Set<() => void>(); + listenersByWorkspace.set(workspaceId, listeners); + listeners.add(drain); + drain(); + return () => { + listeners.delete(drain); + if (listeners.size === 0) { + listenersByWorkspace.delete(workspaceId); + } + }; + }, [workspaceId, pushToast]); +} diff --git a/src/browser/utils/messages/applyWorkspaceChatEventToAggregator.test.ts b/src/browser/utils/messages/applyWorkspaceChatEventToAggregator.test.ts index 975473199c5..a190f1f623b 100644 --- a/src/browser/utils/messages/applyWorkspaceChatEventToAggregator.test.ts +++ b/src/browser/utils/messages/applyWorkspaceChatEventToAggregator.test.ts @@ -1,6 +1,6 @@ import { describe, expect, test } from "bun:test"; -import { CUSTOM_EVENTS } from "@/common/constants/events"; +import { takeChatErrors } from "@/browser/utils/chatErrorToasts"; import type { DeleteMessage, StreamErrorMessage, WorkspaceChatMessage } from "@/common/orpc/types"; import type { ReasoningDeltaEvent, @@ -94,40 +94,6 @@ class StubAggregator implements WorkspaceChatEventAggregator { } describe("applyWorkspaceChatEventToAggregator", () => { - function withDispatchSpy(run: (dispatched: Event[]) => T): T { - const originalWindow = globalThis.window; - const originalCustomEvent = globalThis.CustomEvent; - const dispatched: Event[] = []; - - // CI bun environment may lack CustomEvent (it was previously provided by happy-dom). - // createCustomEvent() in src/common/constants/events.ts uses `new CustomEvent(...)`. - if (typeof globalThis.CustomEvent === "undefined") { - // Minimal polyfill: only needs to carry .type and .detail for our assertions. - globalThis.CustomEvent = class CustomEvent extends Event { - detail: unknown; - - constructor(type: string, init?: CustomEventInit) { - super(type, init); - this.detail = init?.detail; - } - } as typeof globalThis.CustomEvent; - } - - globalThis.window = { - dispatchEvent: (event: Event) => { - dispatched.push(event); - return true; - }, - } as unknown as Window & typeof globalThis; - - try { - return run(dispatched); - } finally { - globalThis.window = originalWindow; - globalThis.CustomEvent = originalCustomEvent; - } - } - test("stream-start routes to handleStreamStart", () => { const aggregator = new StubAggregator(); @@ -197,29 +163,24 @@ describe("applyWorkspaceChatEventToAggregator", () => { expect(hint).toBe("immediate"); expect(aggregator.calls).toEqual(["handleRuntimeStatus:starting:ssh"]); }); - test("goal-budget-limited child events dispatch a toast without mutating messages", () => { - withDispatchSpy((dispatched) => { - const aggregator = new StubAggregator(); - const event: WorkspaceChatMessage = { - type: "goal-budget-limited", - workspaceId: "parent-1", - goalId: "goal-1", - causedByChild: true, - childWorkspaceId: "child-1", - message: "Child workspace exceeded the parent's goal budget.", - }; - - const hint = applyWorkspaceChatEventToAggregator(aggregator, event); - - expect(hint).toBe("ignored"); - expect(aggregator.calls).toEqual([]); - expect(dispatched).toHaveLength(1); - expect(dispatched[0]?.type).toBe(CUSTOM_EVENTS.CHAT_ERROR_TOAST); - expect((dispatched[0] as CustomEvent).detail).toEqual({ - workspaceId: "parent-1", - message: "Child workspace exceeded the parent's goal budget.", - }); - }); + test("goal-budget-limited child events publish a chat error without mutating messages", () => { + const aggregator = new StubAggregator(); + const event: WorkspaceChatMessage = { + type: "goal-budget-limited", + workspaceId: "parent-1", + goalId: "goal-1", + causedByChild: true, + childWorkspaceId: "child-1", + message: "Child workspace exceeded the parent's goal budget.", + }; + + const hint = applyWorkspaceChatEventToAggregator(aggregator, event); + + expect(hint).toBe("ignored"); + expect(aggregator.calls).toEqual([]); + expect(takeChatErrors("parent-1")).toEqual([ + "Child workspace exceeded the parent's goal budget.", + ]); }); test("stream-abort clears token state before calling handleStreamAbort", () => { diff --git a/src/browser/utils/messages/applyWorkspaceChatEventToAggregator.ts b/src/browser/utils/messages/applyWorkspaceChatEventToAggregator.ts index ae936680cda..e2bc2aeb855 100644 --- a/src/browser/utils/messages/applyWorkspaceChatEventToAggregator.ts +++ b/src/browser/utils/messages/applyWorkspaceChatEventToAggregator.ts @@ -1,6 +1,7 @@ import assert from "@/common/utils/assert"; import { CUSTOM_EVENTS, createCustomEvent } from "@/common/constants/events"; import { MUX_GATEWAY_SESSION_EXPIRED_MESSAGE } from "@/common/constants/muxGatewayOAuth"; +import { publishChatError } from "@/browser/utils/chatErrorToasts"; import type { DeleteMessage, StreamErrorMessage, WorkspaceChatMessage } from "@/common/orpc/types"; import { isBashOutputEvent, @@ -94,11 +95,6 @@ function dispatchSkillsRefreshRequested(): void { window.dispatchEvent(new CustomEvent(CUSTOM_EVENTS.SKILLS_REFRESH_REQUESTED)); } -function dispatchChatErrorToast(workspaceId: string, message: string): void { - if (typeof window === "undefined") return; - window.dispatchEvent(createCustomEvent(CUSTOM_EVENTS.CHAT_ERROR_TOAST, { workspaceId, message })); -} - function dispatchMuxGatewaySessionExpired(): void { if (typeof window === "undefined") return; window.dispatchEvent(createCustomEvent(CUSTOM_EVENTS.MUX_GATEWAY_SESSION_EXPIRED)); @@ -204,7 +200,7 @@ export function applyWorkspaceChatEventToAggregator( if (isGoalBudgetLimitedEvent(event)) { if (allowSideEffects && event.causedByChild) { - dispatchChatErrorToast(event.workspaceId, event.message); + publishChatError(event.workspaceId, event.message); } return "ignored"; } diff --git a/src/browser/utils/stopStream.test.ts b/src/browser/utils/stopStream.test.ts index a9a56f9a692..0ac9cbc15e9 100644 --- a/src/browser/utils/stopStream.test.ts +++ b/src/browser/utils/stopStream.test.ts @@ -1,21 +1,9 @@ -import { afterEach, beforeEach, describe, expect, test } from "bun:test"; -import { installDom } from "../../../tests/ui/dom"; +import { describe, expect, test } from "bun:test"; import type { APIClient } from "@/browser/contexts/API"; -import { CUSTOM_EVENTS } from "@/common/constants/events"; +import { takeChatErrors } from "./chatErrorToasts"; import { stopStream } from "./stopStream"; describe("stopStream", () => { - let cleanupDom: (() => void) | null = null; - - beforeEach(() => { - cleanupDom = installDom(); - }); - - afterEach(() => { - cleanupDom?.(); - cleanupDom = null; - }); - function apiReturning( result: { success: true; data: undefined } | { success: false; error: string } ): { api: APIClient; calls: unknown[] } { @@ -31,35 +19,27 @@ describe("stopStream", () => { return { api, calls }; } - function collectToasts(): unknown[] { - const toasts: unknown[] = []; - window.addEventListener(CUSTOM_EVENTS.CHAT_ERROR_TOAST, (event) => { - toasts.push((event as CustomEvent).detail); - }); - return toasts; - } - - test("a Stop the backend could not record is shown as a chat error toast", async () => { + test("a Stop the backend could not record is retained as the workspace's chat error", async () => { const { api } = apiReturning({ success: false, error: "disk full" }); - const toasts = collectToasts(); - await stopStream(api, "ws-1"); + // No chat input is subscribed (the user may have switched workspaces mid-Stop): the error + // must wait for the workspace's input rather than be dropped with a one-shot event. + await stopStream(api, "ws-unrecorded"); - expect(toasts).toEqual([{ workspaceId: "ws-1", message: "disk full" }]); + expect(takeChatErrors("ws-unrecorded")).toEqual(["disk full"]); }); - test("a recorded Stop retires owed monitor output without a toast", async () => { + test("a recorded Stop retires owed monitor output without a chat error", async () => { const { api, calls } = apiReturning({ success: true, data: undefined }); - const toasts = collectToasts(); - await stopStream(api, "ws-1", { abandonPartial: true }); + await stopStream(api, "ws-recorded", { abandonPartial: true }); expect(calls).toEqual([ { - workspaceId: "ws-1", + workspaceId: "ws-recorded", options: { abandonPartial: true, retireBashMonitorAttention: true }, }, ]); - expect(toasts).toEqual([]); + expect(takeChatErrors("ws-recorded")).toEqual([]); }); }); diff --git a/src/browser/utils/stopStream.ts b/src/browser/utils/stopStream.ts index 7789deb2e04..48dd604a319 100644 --- a/src/browser/utils/stopStream.ts +++ b/src/browser/utils/stopStream.ts @@ -1,5 +1,5 @@ import type { APIClient } from "@/browser/contexts/API"; -import { CUSTOM_EVENTS, createCustomEvent } from "@/common/constants/events"; +import { publishChatError } from "@/browser/utils/chatErrorToasts"; /** * User Stop: interrupts the stream and dismisses owed background monitor output instead of letting @@ -16,8 +16,6 @@ export async function stopStream( options: { ...options, retireBashMonitorAttention: true }, }); if (!result.success) { - window.dispatchEvent( - createCustomEvent(CUSTOM_EVENTS.CHAT_ERROR_TOAST, { workspaceId, message: result.error }) - ); + publishChatError(workspaceId, result.error); } } diff --git a/src/common/constants/events.ts b/src/common/constants/events.ts index 6c9b08bb3b9..be3bf49ee1f 100644 --- a/src/common/constants/events.ts +++ b/src/common/constants/events.ts @@ -123,13 +123,6 @@ export const CUSTOM_EVENTS = { */ OPEN_GOAL_TAB: "mux:openGoalTab", - /** - * Event to show an error toast in a workspace's chat input (child goal budget exhaustion, - * a Stop the backend could not record, ...). - * Detail: { workspaceId: string, message: string } - */ - CHAT_ERROR_TOAST: "mux:chatErrorToast", - REVEAL_TIMELINE_ANCHOR: "mux:revealTimelineAnchor", /** @@ -202,10 +195,6 @@ export interface CustomEventPayloads { workspaceId: string; openCompleteInput?: boolean; }; - [CUSTOM_EVENTS.CHAT_ERROR_TOAST]: { - workspaceId: string; - message: string; - }; [CUSTOM_EVENTS.REVEAL_TIMELINE_ANCHOR]: { workspaceId: string; messageId?: string; diff --git a/src/common/constants/workspace.ts b/src/common/constants/workspace.ts index 7fcf9cfb94e..6fb6ac9203d 100644 --- a/src/common/constants/workspace.ts +++ b/src/common/constants/workspace.ts @@ -9,3 +9,10 @@ export const DEFAULT_RUNTIME_CONFIG: RuntimeConfig = { type: "worktree", srcBaseDir: "~/.xum/src", } as const; + +/** + * Returned by `workspace.interruptStream` for a user Stop whose stream did stop but whose startup + * abandon marker or monitor-attention retirement could not be written. + */ +export const STOP_UNRECORDED_MESSAGE = + "Stop could not be recorded on disk, so the stopped work may resume on restart."; diff --git a/src/node/acp/agent.ts b/src/node/acp/agent.ts index 7b1d47c7a74..de16ec04aa0 100644 --- a/src/node/acp/agent.ts +++ b/src/node/acp/agent.ts @@ -29,6 +29,7 @@ import type { import { RequestError } from "@agentclientprotocol/sdk"; import { resolveXumEnvironmentValue } from "@/common/compat/legacyMux"; import { XUM_PRODUCT_SLUG } from "@/common/constants/product"; +import { STOP_UNRECORDED_MESSAGE } from "@/common/constants/workspace"; import { DEFAULT_COMPACTION_WORD_TARGET, WORDS_TO_TOKENS_RATIO, @@ -620,18 +621,21 @@ export class MuxAgent implements Agent { options: { retireBashMonitorAttention: true }, }); - if (!interruptResult.success) { - throw new Error(`cancel: workspace.interruptStream failed: ${interruptResult.error}`); - } - // Resolve any pending prompt immediately after a successful interrupt request. // Backend abort events can be dropped or synthesized without a messageId when no // active stream exists; waiting exclusively for terminal chat events can leave - // ACP prompt requests hanging indefinitely. - this.resolveTurn(sessionId, { - stopReason: "cancelled", - usage: this.latestUsageBySessionId.get(sessionId), - }); + // ACP prompt requests hanging indefinitely. STOP_UNRECORDED_MESSAGE reports a stream + // that did stop (only its durable Stop records failed), so the prompt settles as + // cancelled before that failure is reported below. + if (interruptResult.success || interruptResult.error === STOP_UNRECORDED_MESSAGE) { + this.resolveTurn(sessionId, { + stopReason: "cancelled", + usage: this.latestUsageBySessionId.get(sessionId), + }); + } + if (!interruptResult.success) { + throw new Error(`cancel: workspace.interruptStream failed: ${interruptResult.error}`); + } } async setSessionConfigOption( diff --git a/src/node/services/workspaceService.test.ts b/src/node/services/workspaceService.test.ts index 0f1dc9b51c1..627abe67732 100644 --- a/src/node/services/workspaceService.test.ts +++ b/src/node/services/workspaceService.test.ts @@ -1,11 +1,7 @@ import type { TurnCompletion } from "./streamManager"; import { describe, expect, test, mock, beforeEach, afterEach, spyOn, type Mock } from "bun:test"; -import { - WorkspaceService, - generateForkBranchName, - generateForkTitle, - STOP_UNRECORDED_MESSAGE, -} from "./workspaceService"; +import { WorkspaceService, generateForkBranchName, generateForkTitle } from "./workspaceService"; +import { STOP_UNRECORDED_MESSAGE } from "@/common/constants/workspace"; import { registerInProcessWorkflowRun } from "@/node/services/workflows/workflowArchiveAdmission"; import type { IdleCompactionOutcome } from "./idleCompactionService"; import type { AgentSession } from "./agentSession"; diff --git a/src/node/services/workspaceService.ts b/src/node/services/workspaceService.ts index f5620777681..bcf866c2672 100644 --- a/src/node/services/workspaceService.ts +++ b/src/node/services/workspaceService.ts @@ -26,6 +26,7 @@ import { reassignPinnedTimestamps, } from "@/common/utils/pin"; import { SCRATCH_PROJECT_CONFIG_KEY } from "@/common/constants/scratch"; +import { STOP_UNRECORDED_MESSAGE } from "@/common/constants/workspace"; import { MULTI_PROJECT_CONFIG_KEY } from "@/common/constants/multiProject"; import type { CompactionCompletionMetadata } from "@/common/types/compaction"; import { ProvidersConfigStore, SecretsStore, type Config } from "@/node/config"; @@ -699,13 +700,6 @@ const WORKSPACE_IDLE_WAIT_CANCELED_MESSAGE = const IDLE_ONLY_BUSY_SKIP_MESSAGE = "Workspace is busy; idle-only send was skipped."; const BASH_MONITOR_PERSIST_RETRY_DELAYS_MS = [50, 200] as const; -/** - * Returned by a user Stop whose startup abandon marker or monitor-attention retirement could not - * be written (see interruptStream). - */ -export const STOP_UNRECORDED_MESSAGE = - "Stop could not be recorded on disk, so the stopped work may resume on restart."; - /** Returned when a caller-supplied admission probe (internal.admissionStale) flips mid-send. */ const SEND_ADMISSION_STALE_MESSAGE = "Send refused: the target was stopped or interrupted while the message was being admitted."; diff --git a/tests/ipc/acp.promptCorrelation.test.ts b/tests/ipc/acp.promptCorrelation.test.ts index fbf4a75be1d..9f34d791789 100644 --- a/tests/ipc/acp.promptCorrelation.test.ts +++ b/tests/ipc/acp.promptCorrelation.test.ts @@ -1,4 +1,5 @@ import { AgentSideConnection, PROTOCOL_VERSION, ndJsonStream } from "@agentclientprotocol/sdk"; +import { STOP_UNRECORDED_MESSAGE } from "../../src/common/constants/workspace"; import type { OnChatMode, WorkspaceChatMessage } from "../../src/common/orpc/types"; import { MuxAgent } from "../../src/node/acp/agent"; import type { ORPCClient, ServerConnection } from "../../src/node/acp/serverConnection"; @@ -893,6 +894,25 @@ describe("ACP prompt stream correlation", () => { await harness.connectionClosed; }); + it("settles pending prompts as cancelled when the Stop stopped the stream but was not recorded", async () => { + const harness = createHarness({ + interruptStream: async () => ({ success: false, error: STOP_UNRECORDED_MESSAGE }), + }); + const { newSessionResponse, promptPromise } = await createDefaultPromptTurn(harness); + + await expect(harness.agent.cancel({ sessionId: newSessionResponse.sessionId })).rejects.toThrow( + STOP_UNRECORDED_MESSAGE + ); + + await expect(promptPromise).resolves.toEqual({ + stopReason: "cancelled", + usage: undefined, + }); + + harness.closeConnection(); + await harness.connectionClosed; + }); + it("accepts correlated terminal events even when messageId is empty", async () => { const harness = createHarness(); const { newSessionResponse, promptPromise, promptCorrelationId } = From 0e40dbf558c9130468fc2a60ad8ee057311e5f45 Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Mon, 7 Sep 2026 07:00:24 +0000 Subject: [PATCH 17/31] Serialize auto-retry state writes; show queued chat errors one at a time; drop Promise.withResolvers from interruptStream - persistAutoRetryState versions each state change and chains its write, so an older unlink cannot land after a newer marker write, and only the newest write clears autoRetryStateUnrecorded. - useChatErrorToasts pushes one retained error at a time and dequeues it only after its toast was rendered and dismissed, so batched pushes and StrictMode replays cannot drop errors. - interruptStream builds its deferred Stop promise with the Promise constructor (Node 20). --- src/browser/features/ChatInput/index.tsx | 2 +- src/browser/utils/chatErrorToasts.test.tsx | 110 ++++++++++++++++-- src/browser/utils/chatErrorToasts.ts | 56 ++++++--- ...pplyWorkspaceChatEventToAggregator.test.ts | 7 +- src/browser/utils/stopStream.test.ts | 8 +- .../agentSession.startupAutoRetry.test.ts | 82 +++++++++++++ src/node/services/agentSession.ts | 26 ++++- src/node/services/workspaceService.ts | 9 +- 8 files changed, 260 insertions(+), 40 deletions(-) diff --git a/src/browser/features/ChatInput/index.tsx b/src/browser/features/ChatInput/index.tsx index 838b7e2c9a8..77128053dab 100644 --- a/src/browser/features/ChatInput/index.tsx +++ b/src/browser/features/ChatInput/index.tsx @@ -1473,7 +1473,7 @@ const ChatInputInner: React.FC = (props) => { window.removeEventListener(CUSTOM_EVENTS.THINKING_LEVEL_TOAST, handler as EventListener); }, [variant, props, pushToast]); - useChatErrorToasts(workspaceId, pushToast); + useChatErrorToasts(workspaceId, toast?.message ?? null, pushToast); // Show toast feedback for analytics rebuild command palette action. useEffect(() => { diff --git a/src/browser/utils/chatErrorToasts.test.tsx b/src/browser/utils/chatErrorToasts.test.tsx index 6ea4d259e08..332df1e32fd 100644 --- a/src/browser/utils/chatErrorToasts.test.tsx +++ b/src/browser/utils/chatErrorToasts.test.tsx @@ -1,7 +1,13 @@ import { afterEach, beforeEach, describe, expect, test } from "bun:test"; import { act, cleanup, renderHook } from "@testing-library/react"; import { GlobalWindow } from "happy-dom"; -import { publishChatError, takeChatErrors, useChatErrorToasts } from "./chatErrorToasts"; +import { StrictMode } from "react"; +import { + dismissChatError, + peekChatError, + publishChatError, + useChatErrorToasts, +} from "./chatErrorToasts"; describe("useChatErrorToasts", () => { beforeEach(() => { @@ -16,16 +22,34 @@ describe("useChatErrorToasts", () => { globalThis.document = undefined as unknown as Document; }); - function mountInput(workspaceId: string | null) { + interface Props { + workspaceId: string | null; + visible: string | null; + } + + /** A chat input whose single toast slot the test drives: `show` renders a toast, `dismiss` clears it. */ + function mountInput(workspaceId: string | null, options?: { strict?: boolean }) { const shown: string[] = []; const pushToast = (toast: { message: string }) => { shown.push(toast.message); }; + const initialProps: Props = { workspaceId, visible: null }; const rendered = renderHook( - (props: { workspaceId: string | null }) => useChatErrorToasts(props.workspaceId, pushToast), - { initialProps: { workspaceId } } + (props: Props) => useChatErrorToasts(props.workspaceId, props.visible, pushToast), + { initialProps, wrapper: options?.strict ? StrictMode : undefined } ); - return { shown, ...rendered }; + return { + shown, + ...rendered, + show: (message: string) => rendered.rerender({ workspaceId, visible: message }), + dismiss: () => rendered.rerender({ workspaceId, visible: null }), + }; + } + + function drain(workspaceId: string) { + for (let next = peekChatError(workspaceId); next != null; next = peekChatError(workspaceId)) { + dismissChatError(workspaceId, next); + } } test("an error published while no input for the workspace is mounted is shown once it mounts", () => { @@ -34,7 +58,11 @@ describe("useChatErrorToasts", () => { const input = mountInput("ws-a"); expect(input.shown).toEqual(["Stop could not be recorded"]); - expect(takeChatErrors("ws-a")).toEqual([]); + // Queued until the toast has been rendered and dismissed, not merely pushed. + expect(peekChatError("ws-a")).toBe("Stop could not be recorded"); + input.show("Stop could not be recorded"); + input.dismiss(); + expect(peekChatError("ws-a")).toBeUndefined(); }); test("an error published while the input is mounted is shown immediately", () => { @@ -45,6 +73,7 @@ describe("useChatErrorToasts", () => { }); expect(input.shown).toEqual(["Child exceeded the goal budget"]); + drain("ws-b"); }); test("errors for another workspace stay retained for that workspace's input", () => { @@ -55,8 +84,9 @@ describe("useChatErrorToasts", () => { }); expect(input.shown).toEqual([]); - input.rerender({ workspaceId: "ws-d" }); - expect(input.shown).toEqual(["for d"]); + const other = mountInput("ws-d"); + expect(other.shown).toEqual(["for d"]); + drain("ws-d"); }); test("an error published after the input unmounts waits for the next mount", () => { @@ -68,5 +98,69 @@ describe("useChatErrorToasts", () => { const second = mountInput("ws-e"); expect(second.shown).toEqual(["late Stop failure"]); + drain("ws-e"); + }); + + test("errors retained together are shown one toast at a time, the next after a dismissal", () => { + publishChatError("ws-f", "first"); + publishChatError("ws-f", "second"); + + const input = mountInput("ws-f"); + expect(input.shown).toEqual(["first"]); + + input.show("first"); + expect(input.shown).toEqual(["first"]); + input.dismiss(); + expect(input.shown).toEqual(["first", "second"]); + + input.show("second"); + input.dismiss(); + expect(input.shown).toEqual(["first", "second"]); + expect(peekChatError("ws-f")).toBeUndefined(); + }); + + test("an error published while another toast is visible waits for that toast to be dismissed", () => { + const input = mountInput("ws-g"); + input.show("Not connected to server"); + + act(() => { + publishChatError("ws-g", "Stop could not be recorded"); + }); + expect(input.shown).toEqual([]); + + input.dismiss(); + expect(input.shown).toEqual(["Stop could not be recorded"]); + input.show("Stop could not be recorded"); + input.dismiss(); + expect(peekChatError("ws-g")).toBeUndefined(); + }); + + test("a pushed error another toast rendered over is pushed again once that toast is dismissed", () => { + const input = mountInput("ws-h"); + act(() => { + publishChatError("ws-h", "lost in a batch"); + }); + expect(input.shown).toEqual(["lost in a batch"]); + + // The input rendered a different toast (a same-tick setToast won the batch), never ours. + input.show("something else"); + input.dismiss(); + + expect(input.shown).toEqual(["lost in a batch", "lost in a batch"]); + input.show("lost in a batch"); + input.dismiss(); + expect(peekChatError("ws-h")).toBeUndefined(); + }); + + test("StrictMode's replayed effect re-pushes the same error instead of consuming the next one", () => { + publishChatError("ws-i", "first"); + publishChatError("ws-i", "second"); + + const input = mountInput("ws-i", { strict: true }); + + expect(input.shown.length).toBeGreaterThan(0); + expect(new Set(input.shown)).toEqual(new Set(["first"])); + expect(peekChatError("ws-i")).toBe("first"); + drain("ws-i"); }); }); diff --git a/src/browser/utils/chatErrorToasts.ts b/src/browser/utils/chatErrorToasts.ts index f3176c7925e..04e2c3a2ed6 100644 --- a/src/browser/utils/chatErrorToasts.ts +++ b/src/browser/utils/chatErrorToasts.ts @@ -1,10 +1,10 @@ -import { useEffect } from "react"; +import { useEffect, useRef } from "react"; /** * Error toasts addressed to a workspace's chat input (a child exhausting the parent's goal budget, - * a Stop the backend could not record). Retained until that input drains them: the error can land - * after the user switched workspaces (a Stop settles asynchronously), when no input for that - * workspace is mounted to receive a one-shot event. + * a Stop the backend could not record). Retained until that input has shown and dismissed them: the + * error can land after the user switched workspaces (a Stop settles asynchronously), when no input + * for that workspace is mounted, and the input renders a single toast at a time. */ const pendingByWorkspace = new Map(); const listenersByWorkspace = new Map void>>(); @@ -18,33 +18,55 @@ export function publishChatError(workspaceId: string, message: string): void { } } -export function takeChatErrors(workspaceId: string): string[] { - const pending = pendingByWorkspace.get(workspaceId) ?? []; - pendingByWorkspace.delete(workspaceId); - return pending; +export function peekChatError(workspaceId: string): string | undefined { + return pendingByWorkspace.get(workspaceId)?.[0]; +} + +export function dismissChatError(workspaceId: string, message: string): void { + const pending = pendingByWorkspace.get(workspaceId); + const index = pending?.indexOf(message) ?? -1; + if (pending == null || index < 0) return; + pending.splice(index, 1); + if (pending.length === 0) pendingByWorkspace.delete(workspaceId); } -/** Shows the workspace's retained and later chat errors through `pushToast`. */ +/** + * Shows the workspace's retained and later chat errors through `pushToast`, one per toast: + * `visibleToastMessage` is the input's current toast and the next error is pushed once it is gone. + * An error leaves the queue only after its toast was rendered and dismissed, so a push that never + * rendered (React batched another toast over it, or StrictMode replayed the effect) is pushed again. + */ export function useChatErrorToasts( workspaceId: string | null, + visibleToastMessage: string | null, pushToast: (toast: { type: "error"; message: string }) => void ): void { + const pushedRef = useRef<{ message: string; displayed: boolean } | null>(null); useEffect(() => { if (workspaceId == null) return; - const drain = () => { - for (const message of takeChatErrors(workspaceId)) { - pushToast({ type: "error", message }); - } + const pushed = pushedRef.current; + if (visibleToastMessage != null) { + if (pushed?.message === visibleToastMessage) pushed.displayed = true; + return; + } + if (pushed?.displayed) dismissChatError(workspaceId, pushed.message); + pushedRef.current = null; + const showNext = () => { + if (pushedRef.current != null) return; + const message = peekChatError(workspaceId); + if (message == null) return; + pushedRef.current = { message, displayed: false }; + pushToast({ type: "error", message }); }; const listeners = listenersByWorkspace.get(workspaceId) ?? new Set<() => void>(); listenersByWorkspace.set(workspaceId, listeners); - listeners.add(drain); - drain(); + listeners.add(showNext); + showNext(); return () => { - listeners.delete(drain); + listeners.delete(showNext); if (listeners.size === 0) { listenersByWorkspace.delete(workspaceId); } }; - }, [workspaceId, pushToast]); + }, [workspaceId, visibleToastMessage, pushToast]); } diff --git a/src/browser/utils/messages/applyWorkspaceChatEventToAggregator.test.ts b/src/browser/utils/messages/applyWorkspaceChatEventToAggregator.test.ts index a190f1f623b..cfa187aa910 100644 --- a/src/browser/utils/messages/applyWorkspaceChatEventToAggregator.test.ts +++ b/src/browser/utils/messages/applyWorkspaceChatEventToAggregator.test.ts @@ -1,6 +1,6 @@ import { describe, expect, test } from "bun:test"; -import { takeChatErrors } from "@/browser/utils/chatErrorToasts"; +import { dismissChatError, peekChatError } from "@/browser/utils/chatErrorToasts"; import type { DeleteMessage, StreamErrorMessage, WorkspaceChatMessage } from "@/common/orpc/types"; import type { ReasoningDeltaEvent, @@ -178,9 +178,8 @@ describe("applyWorkspaceChatEventToAggregator", () => { expect(hint).toBe("ignored"); expect(aggregator.calls).toEqual([]); - expect(takeChatErrors("parent-1")).toEqual([ - "Child workspace exceeded the parent's goal budget.", - ]); + expect(peekChatError("parent-1")).toBe("Child workspace exceeded the parent's goal budget."); + dismissChatError("parent-1", "Child workspace exceeded the parent's goal budget."); }); test("stream-abort clears token state before calling handleStreamAbort", () => { diff --git a/src/browser/utils/stopStream.test.ts b/src/browser/utils/stopStream.test.ts index 0ac9cbc15e9..25f7d664a9f 100644 --- a/src/browser/utils/stopStream.test.ts +++ b/src/browser/utils/stopStream.test.ts @@ -1,6 +1,6 @@ import { describe, expect, test } from "bun:test"; import type { APIClient } from "@/browser/contexts/API"; -import { takeChatErrors } from "./chatErrorToasts"; +import { dismissChatError, peekChatError } from "./chatErrorToasts"; import { stopStream } from "./stopStream"; describe("stopStream", () => { @@ -26,7 +26,9 @@ describe("stopStream", () => { // must wait for the workspace's input rather than be dropped with a one-shot event. await stopStream(api, "ws-unrecorded"); - expect(takeChatErrors("ws-unrecorded")).toEqual(["disk full"]); + expect(peekChatError("ws-unrecorded")).toBe("disk full"); + dismissChatError("ws-unrecorded", "disk full"); + expect(peekChatError("ws-unrecorded")).toBeUndefined(); }); test("a recorded Stop retires owed monitor output without a chat error", async () => { @@ -40,6 +42,6 @@ describe("stopStream", () => { options: { abandonPartial: true, retireBashMonitorAttention: true }, }, ]); - expect(takeChatErrors("ws-recorded")).toEqual([]); + expect(peekChatError("ws-recorded")).toBeUndefined(); }); }); diff --git a/src/node/services/agentSession.startupAutoRetry.test.ts b/src/node/services/agentSession.startupAutoRetry.test.ts index 2abd4af325c..1699c47d367 100644 --- a/src/node/services/agentSession.startupAutoRetry.test.ts +++ b/src/node/services/agentSession.startupAutoRetry.test.ts @@ -2,6 +2,8 @@ import type { TurnCoordinator } from "./turnCoordinator"; import { runSessionTerminalPolicy } from "./agentSession.testHarness"; import { afterEach, describe, expect, mock, spyOn, test } from "bun:test"; import { EventEmitter } from "events"; +import * as fsPromises from "fs/promises"; +import path from "path"; import { AgentSession, clearProviderConfigFixableAbandonMarkers, @@ -1226,6 +1228,86 @@ describe("AgentSession startup auto-retry recovery", () => { expect(events.some((event) => event.type === "auto-retry-scheduled")).toBe(false); }); + test("a marker recorded while an older clear is still unlinking is written after it and acknowledged once written", async () => { + const workspaceId = "startup-retry-serialized-abandon-writes"; + const { session, cleanup } = await createSessionBundle(workspaceId); + cleanups.push(cleanup); + + const privateSession = session as unknown as { + persistStartupAutoRetryAbandon: (reason: string, userMessageId?: string) => Promise; + clearStartupAutoRetryAbandon: () => Promise; + getAutoRetryPreferencePath: () => string; + }; + const preferencePath = privateSession.getAutoRetryPreferencePath(); + + // An in-memory preference file whose unlink and marker write the test holds open, so the clear's + // unlink and the Stop's marker write can be ordered exactly (real I/O would race them). + let fileContent: string | null = null; + let markerWrites = 0; + let holdMarkerWrites = false; + const unlinkEntered = Promise.withResolvers(); + const releaseUnlink = Promise.withResolvers(); + const releaseWrite = Promise.withResolvers(); + const macrotask = () => new Promise((resolve) => setTimeout(resolve, 0)); + const { unlink, mkdir, writeFile } = fsPromises; + const spies = [ + spyOn(fsPromises, "unlink").mockImplementation(async (target) => { + if (target !== preferencePath) return unlink(target); + unlinkEntered.resolve(); + await releaseUnlink.promise; + fileContent = null; + }), + spyOn(fsPromises, "mkdir").mockImplementation(async (target, options) => { + if (target !== path.dirname(preferencePath)) await mkdir(target, options); + }), + spyOn(fsPromises, "writeFile").mockImplementation(async (target, data, options) => { + if (target !== preferencePath || typeof data !== "string") { + return writeFile(target, data, options); + } + if (holdMarkerWrites) { + markerWrites += 1; + await releaseWrite.promise; + } + fileContent = data; + }), + ]; + try { + await privateSession.persistStartupAutoRetryAbandon("authentication", "user-1"); + holdMarkerWrites = true; + const clearing = privateSession.clearStartupAutoRetryAbandon(); + await unlinkEntered.promise; + const recording = privateSession.persistStartupAutoRetryAbandon("aborted", "user-2"); + // A macrotask drains every microtask-resolved fake step the recording could have taken: the + // marker write waits for the clear's unlink instead of racing it. + await macrotask(); + expect(markerWrites).toBe(0); + releaseUnlink.resolve(); + await clearing; + + // The clear's completion does not acknowledge the marker that is still being written. + let acknowledged: boolean | undefined; + const ack = session.recordPendingStartupAutoRetryAbandon().then((recorded) => { + acknowledged = recorded; + return recorded; + }); + await macrotask(); + expect(acknowledged).toBeUndefined(); + releaseWrite.resolve(); + expect(await ack).toBe(true); + await recording; + expect(fileContent).not.toBeNull(); + const persisted = JSON.parse(fileContent!) as { + startupAutoRetryAbandon?: { reason: string; userMessageId?: string }; + }; + expect(persisted.startupAutoRetryAbandon).toEqual({ + reason: "aborted", + userMessageId: "user-2", + }); + } finally { + for (const spy of spies) spy.mockRestore(); + } + }); + test("provider config changes preserve non-fixable abandon state without starting a stream", async () => { const workspaceId = "startup-retry-keep-abandon-on-provider-config"; const { session, aiService, events, cleanup } = await createSessionBundle(workspaceId); diff --git a/src/node/services/agentSession.ts b/src/node/services/agentSession.ts index 6251f784157..8b05cb0f6af 100644 --- a/src/node/services/agentSession.ts +++ b/src/node/services/agentSession.ts @@ -754,6 +754,8 @@ export class AgentSession { private startupAutoRetryAbandon: { reason: string; userMessageId?: string } | null = null; // The preference file may not reflect memory after a failed write (see persistAutoRetryState). private autoRetryStateUnrecorded = false; + private autoRetryStateVersion = 0; + private autoRetryStateWrites: Promise = Promise.resolve(); /** Latest context-usage snapshot used for on-send compaction checks. */ private lastUsageState?: AutoCompactionUsageState; @@ -1496,11 +1498,22 @@ export class AgentSession { // Best-effort: a failed write only sets autoRetryStateUnrecorded, which the one caller that must // not acknowledge an unrecorded write (a user Stop) checks via recordPendingStartupAutoRetryAbandon. - private async persistAutoRetryState(): Promise { + // Writes are serialized and only the latest state change's write marks it recorded, so an older + // unlink cannot land after a newer marker write with the flag already cleared. + private persistAutoRetryState(): Promise { + const version = ++this.autoRetryStateVersion; + this.autoRetryStateUnrecorded = true; + this.autoRetryStateWrites = this.autoRetryStateWrites.then(() => + this.writeAutoRetryState(version) + ); + return this.autoRetryStateWrites; + } + + private async writeAutoRetryState(version: number): Promise { + if (version !== this.autoRetryStateVersion) return; const preferencePath = this.getAutoRetryPreferencePath(); const enabled = this.autoRetryEnabledPreference !== false; const hasStartupAbandonState = this.startupAutoRetryAbandon !== null; - this.autoRetryStateUnrecorded = true; if (enabled && !hasStartupAbandonState) { try { @@ -1518,7 +1531,7 @@ export class AgentSession { return; } } - this.autoRetryStateUnrecorded = false; + this.markAutoRetryStateRecorded(version); return; } @@ -1538,7 +1551,7 @@ export class AgentSession { try { await mkdir(path.dirname(preferencePath), { recursive: true }); await writeFile(preferencePath, JSON.stringify(payload) + "\n", "utf-8"); - this.autoRetryStateUnrecorded = false; + this.markAutoRetryStateRecorded(version); } catch (error) { log.warn("Failed to persist auto-retry preference", { workspaceId: this.workspaceId, @@ -1547,6 +1560,11 @@ export class AgentSession { } } + private markAutoRetryStateRecorded(version: number): void { + // A state change made while this write ran has its own queued write; disk still lags memory. + if (version === this.autoRetryStateVersion) this.autoRetryStateUnrecorded = false; + } + /** * A user Stop is acknowledged only once the startup abandon marker its stopped turn relies on is * on disk; otherwise the trailing row stays eligible for startup replay. A marker write that failed diff --git a/src/node/services/workspaceService.ts b/src/node/services/workspaceService.ts index bcf866c2672..92f5536581d 100644 --- a/src/node/services/workspaceService.ts +++ b/src/node/services/workspaceService.ts @@ -11643,11 +11643,14 @@ export class WorkspaceService extends EventEmitter implements WorkspaceHost { const withdrawnWakeSend = retiring ? this.inFlightBashMonitorWakeSendsByOwner.get(workspaceId) : undefined; - const stopSettled = Promise.withResolvers(); + let settleStop!: (stopped: boolean) => void; + const stopSettled = new Promise((resolve) => { + settleStop = resolve; + }); let retirementRecorded = true; const retirement = retiring ? this.bashMonitorWakeReconciler - .consumeCurrent(workspaceId, () => stopSettled.promise) + .consumeCurrent(workspaceId, () => stopSettled) .catch((error: unknown) => { retirementRecorded = false; log.warn("Failed to retire bash monitor attention before Stop", { @@ -11660,7 +11663,7 @@ export class WorkspaceService extends EventEmitter implements WorkspaceHost { try { stopResult = await session.interruptStream(options); } finally { - stopSettled.resolve(stopResult?.success === true); + settleStop(stopResult?.success === true); } await retirement; // A wake withdrawn past its point of no return (durable row, not yet PREPARING, so the From bf730072fbc28d0547c515eec79afa81e075be22 Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Mon, 7 Sep 2026 07:30:56 +0000 Subject: [PATCH 18/31] Load the auto-retry preference once and make every state change wait for it A marker set while the first preference read was still pending was overwritten by the stale load, and the write built from unloaded defaults dropped the file's opt-out. The file is now read once per session (loadAutoRetryState) and readers, mutators and the Stop acknowledgement await that read before touching the in-memory state. --- .../agentSession.startupAutoRetry.test.ts | 54 +++++++++++++++++++ src/node/services/agentSession.ts | 31 ++++++++--- 2 files changed, 77 insertions(+), 8 deletions(-) diff --git a/src/node/services/agentSession.startupAutoRetry.test.ts b/src/node/services/agentSession.startupAutoRetry.test.ts index 1699c47d367..bd025ddb381 100644 --- a/src/node/services/agentSession.startupAutoRetry.test.ts +++ b/src/node/services/agentSession.startupAutoRetry.test.ts @@ -1308,6 +1308,60 @@ describe("AgentSession startup auto-retry recovery", () => { } }); + test("a marker recorded while the preference file is still loading survives the load and keeps the file's opt-out", async () => { + const workspaceId = "startup-retry-marker-during-preference-load"; + const { session, cleanup } = await createSessionBundle(workspaceId); + cleanups.push(cleanup); + + const privateSession = session as unknown as { + persistStartupAutoRetryAbandon: (reason: string, userMessageId?: string) => Promise; + loadAutoRetryEnabledPreference: () => Promise; + getAutoRetryPreferencePath: () => string; + startupAutoRetryAbandon: { reason: string; userMessageId?: string } | null; + }; + const preferencePath = privateSession.getAutoRetryPreferencePath(); + await fsPromises.mkdir(path.dirname(preferencePath), { recursive: true }); + await fsPromises.writeFile(preferencePath, JSON.stringify({ enabled: false }) + "\n", "utf-8"); + + // The first preference read is held open, as in a fresh session whose startup check is still + // reading the file when a Stop withdraws an accepted wake. + const readEntered = Promise.withResolvers(); + const releaseRead = Promise.withResolvers(); + const readFile = fsPromises.readFile.bind(fsPromises); + const readSpy = spyOn(fsPromises, "readFile").mockImplementation((async ( + ...args: Parameters + ) => { + const raw = await readFile(...args); + if (args[0] !== preferencePath) return raw; + readEntered.resolve(); + await releaseRead.promise; + return raw; + }) as typeof fsPromises.readFile); + try { + const loading = privateSession.loadAutoRetryEnabledPreference(); + await readEntered.promise; + const recording = privateSession.persistStartupAutoRetryAbandon("aborted", "user-2"); + await new Promise((resolve) => setTimeout(resolve, 0)); + // Nothing is written from unloaded state while the read is pending. + expect(JSON.parse(await Bun.file(preferencePath).text())).toEqual({ enabled: false }); + releaseRead.resolve(); + expect(await loading).toBe(false); + await recording; + + expect(privateSession.startupAutoRetryAbandon).toEqual({ + reason: "aborted", + userMessageId: "user-2", + }); + expect(JSON.parse(await Bun.file(preferencePath).text())).toEqual({ + enabled: false, + startupAutoRetryAbandon: { reason: "aborted", userMessageId: "user-2" }, + }); + expect(await session.recordPendingStartupAutoRetryAbandon()).toBe(true); + } finally { + readSpy.mockRestore(); + } + }); + test("provider config changes preserve non-fixable abandon state without starting a stream", async () => { const workspaceId = "startup-retry-keep-abandon-on-provider-config"; const { session, aiService, events, cleanup } = await createSessionBundle(workspaceId); diff --git a/src/node/services/agentSession.ts b/src/node/services/agentSession.ts index 8b05cb0f6af..db765766921 100644 --- a/src/node/services/agentSession.ts +++ b/src/node/services/agentSession.ts @@ -756,6 +756,7 @@ export class AgentSession { private autoRetryStateUnrecorded = false; private autoRetryStateVersion = 0; private autoRetryStateWrites: Promise = Promise.resolve(); + private autoRetryStateLoad: Promise | null = null; /** Latest context-usage snapshot used for on-send compaction checks. */ private lastUsageState?: AutoCompactionUsageState; @@ -1446,11 +1447,22 @@ export class AgentSession { }; } + /** + * The preference file is read once per session, and every reader and writer of the in-memory + * auto-retry state waits for that read: a load that lands late cannot overwrite a newer change, + * and a write never rebuilds the file from unloaded defaults. + */ + private loadAutoRetryState(): Promise { + this.autoRetryStateLoad ??= this.readAutoRetryState(); + return this.autoRetryStateLoad; + } + private async loadAutoRetryEnabledPreference(): Promise { - if (this.autoRetryEnabledPreference !== null) { - return this.autoRetryEnabledPreference; - } + await this.loadAutoRetryState(); + return this.autoRetryEnabledPreference !== false; + } + private async readAutoRetryState(): Promise { const preferencePath = this.getAutoRetryPreferencePath(); try { const raw = await readFile(preferencePath, "utf-8"); @@ -1465,7 +1477,6 @@ export class AgentSession { parsed.startupAutoRetryAbandon ); this.retryManager.setEnabled(enabled); - return enabled; } catch (error) { // Missing preference file is the default path. Use any legacy frontend hint // (captured at onChat subscribe time) before falling back to enabled. @@ -1483,7 +1494,8 @@ export class AgentSession { if (errno === "ENOENT" && defaultEnabled === false) { // Persist migrated legacy opt-out so restart behavior no longer depends - // on renderer localStorage keys. + // on renderer localStorage keys. This write runs inside the load, so + // writeAutoRetryState must not wait for loadAutoRetryState. await this.persistAutoRetryState(); } else if (errno !== "ENOENT") { log.warn("Failed to load auto-retry preference; defaulting to enabled", { @@ -1491,15 +1503,14 @@ export class AgentSession { error: getErrorMessage(error), }); } - - return defaultEnabled; } } // Best-effort: a failed write only sets autoRetryStateUnrecorded, which the one caller that must // not acknowledge an unrecorded write (a user Stop) checks via recordPendingStartupAutoRetryAbandon. // Writes are serialized and only the latest state change's write marks it recorded, so an older - // unlink cannot land after a newer marker write with the flag already cleared. + // unlink cannot land after a newer marker write with the flag already cleared. Callers change the + // state only after loadAutoRetryState settled, so the file is never rebuilt from unloaded defaults. private persistAutoRetryState(): Promise { const version = ++this.autoRetryStateVersion; this.autoRetryStateUnrecorded = true; @@ -1572,12 +1583,14 @@ export class AgentSession { * the Stop that first reported it. */ async recordPendingStartupAutoRetryAbandon(): Promise { + await this.loadAutoRetryState(); if (this.startupAutoRetryAbandon === null) return true; if (this.autoRetryStateUnrecorded) await this.persistAutoRetryState(); return !this.autoRetryStateUnrecorded; } private async persistAutoRetryEnabledPreference(enabled: boolean): Promise { + await this.loadAutoRetryState(); this.autoRetryEnabledPreference = enabled; await this.persistAutoRetryState(); } @@ -1586,6 +1599,7 @@ export class AgentSession { reason: string, userMessageId?: string ): Promise { + await this.loadAutoRetryState(); this.startupAutoRetryAbandon = { reason, ...(userMessageId ? { userMessageId } : {}), @@ -1594,6 +1608,7 @@ export class AgentSession { } private async clearStartupAutoRetryAbandon(): Promise { + await this.loadAutoRetryState(); if (this.startupAutoRetryAbandon === null) { return; } From e16a04d30e1292201b74c801edf5a7632c0fb4e0 Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Mon, 7 Sep 2026 08:12:22 +0000 Subject: [PATCH 19/31] fix: retire wakes on CLI budget stops, gate Stop on unrecorded opt-outs, keep replaced error toasts - xum run --budget stops through WorkspaceService.interruptStream with retireBashMonitorAttention so a deferred monitor wake cannot start another billed turn once the workspace goes idle. - recordPendingAutoRetryState (was recordPendingStartupAutoRetryAbandon) also refuses to acknowledge a Stop while a RetryBarrier opt-out ({ enabled: false }) is still unwritten, and retries that write. - useChatErrorToasts marks an error displayed only while its toast is the visible one, so a later toast replacing it re-queues the error instead of dismissing it when the slot clears. --- src/browser/utils/chatErrorToasts.test.tsx | 16 +++++++++ src/browser/utils/chatErrorToasts.ts | 7 ++-- src/cli/run.ts | 16 +++++++-- .../agentSession.startupAutoRetry.test.ts | 34 +++++++++++++++++-- src/node/services/agentSession.ts | 18 ++++++---- src/node/services/workspaceService.ts | 8 ++--- 6 files changed, 82 insertions(+), 17 deletions(-) diff --git a/src/browser/utils/chatErrorToasts.test.tsx b/src/browser/utils/chatErrorToasts.test.tsx index 332df1e32fd..3776f70aa2f 100644 --- a/src/browser/utils/chatErrorToasts.test.tsx +++ b/src/browser/utils/chatErrorToasts.test.tsx @@ -152,6 +152,22 @@ describe("useChatErrorToasts", () => { expect(peekChatError("ws-h")).toBeUndefined(); }); + test("a displayed error that another toast replaced is pushed again once that toast is dismissed", () => { + const input = mountInput("ws-j"); + act(() => { + publishChatError("ws-j", "Stop could not be recorded"); + }); + input.show("Stop could not be recorded"); + // A later toast took the slot before the user dismissed ours. + input.show("Thinking level: high"); + input.dismiss(); + + expect(input.shown).toEqual(["Stop could not be recorded", "Stop could not be recorded"]); + input.show("Stop could not be recorded"); + input.dismiss(); + expect(peekChatError("ws-j")).toBeUndefined(); + }); + test("StrictMode's replayed effect re-pushes the same error instead of consuming the next one", () => { publishChatError("ws-i", "first"); publishChatError("ws-i", "second"); diff --git a/src/browser/utils/chatErrorToasts.ts b/src/browser/utils/chatErrorToasts.ts index 04e2c3a2ed6..811e6f98291 100644 --- a/src/browser/utils/chatErrorToasts.ts +++ b/src/browser/utils/chatErrorToasts.ts @@ -34,7 +34,8 @@ export function dismissChatError(workspaceId: string, message: string): void { * Shows the workspace's retained and later chat errors through `pushToast`, one per toast: * `visibleToastMessage` is the input's current toast and the next error is pushed once it is gone. * An error leaves the queue only after its toast was rendered and dismissed, so a push that never - * rendered (React batched another toast over it, or StrictMode replayed the effect) is pushed again. + * rendered (React batched another toast over it, or StrictMode replayed the effect) or that another + * toast replaced is pushed again. */ export function useChatErrorToasts( workspaceId: string | null, @@ -46,7 +47,9 @@ export function useChatErrorToasts( if (workspaceId == null) return; const pushed = pushedRef.current; if (visibleToastMessage != null) { - if (pushed?.message === visibleToastMessage) pushed.displayed = true; + // Dismissal is inferred from the slot clearing, so only a toast still showing this error + // counts; one that replaced it (a later success toast) means the error must show again. + if (pushed != null) pushed.displayed = pushed.message === visibleToastMessage; return; } if (pushed?.displayed) dismissChatError(workspaceId, pushed.message); diff --git a/src/cli/run.ts b/src/cli/run.ts index e9d669d2e1d..8e6708eb77d 100644 --- a/src/cli/run.ts +++ b/src/cli/run.ts @@ -999,6 +999,18 @@ async function main(): Promise { // Budget tracking state let budgetExceeded = false; + // The budget cap is the user's Stop: go through the retiring interrupt so owed background-process + // attention is dismissed, or the after-idle reconcile would start another billed turn before + // teardown. The Err case is a stop that did not persist, not a stop that failed. + const stopForBudget = (): void => { + void workspaceService + .interruptStream(workspaceId, { abandonPartial: false, retireBashMonitorAttention: true }) + .then((result) => { + if (!result.success) { + log.warn("Budget stop was not recorded", { workspaceId, error: result.error }); + } + }); + }; // Centralized output type tracking for spacing type OutputType = "none" | "text" | "thinking" | "tool"; @@ -1368,7 +1380,7 @@ async function main(): Promise { const msg = `Budget exceeded ($${cost.toFixed(2)} of $${budget.toFixed(2)}) - stopping`; emitJsonLine({ type: "budget-exceeded", spent: cost, budget }); writeHumanLineClosed(`\n${chalk.yellow(msg)}`); - void session.interruptStream({ abandonPartial: false }); + stopForBudget(); } } return; @@ -1416,7 +1428,7 @@ async function main(): Promise { const msg = `Budget exceeded ($${cost.toFixed(2)} of $${budget.toFixed(2)}) - stopping`; emitJsonLine({ type: "budget-exceeded", spent: cost, budget }); writeHumanLineClosed(`\n${chalk.yellow(msg)}`); - void session.interruptStream({ abandonPartial: false }); + stopForBudget(); } } return; diff --git a/src/node/services/agentSession.startupAutoRetry.test.ts b/src/node/services/agentSession.startupAutoRetry.test.ts index 30d31f58753..8b8014c46a3 100644 --- a/src/node/services/agentSession.startupAutoRetry.test.ts +++ b/src/node/services/agentSession.startupAutoRetry.test.ts @@ -1290,7 +1290,7 @@ describe("AgentSession startup auto-retry recovery", () => { // The clear's completion does not acknowledge the marker that is still being written. let acknowledged: boolean | undefined; - const ack = session.recordPendingStartupAutoRetryAbandon().then((recorded) => { + const ack = session.recordPendingAutoRetryState().then((recorded) => { acknowledged = recorded; return recorded; }); @@ -1360,7 +1360,7 @@ describe("AgentSession startup auto-retry recovery", () => { enabled: false, startupAutoRetryAbandon: { reason: "aborted", userMessageId: "user-2" }, }); - expect(await session.recordPendingStartupAutoRetryAbandon()).toBe(true); + expect(await session.recordPendingAutoRetryState()).toBe(true); } finally { readSpy.mockRestore(); } @@ -1430,6 +1430,36 @@ describe("AgentSession startup auto-retry recovery", () => { expect(await Bun.file(preferencePath).exists()).toBe(true); }); + test("an auto-retry opt-out whose write failed is not acknowledged as recorded until it is written", async () => { + const workspaceId = "startup-retry-unrecorded-opt-out"; + const { session, cleanup } = await createSessionBundle(workspaceId); + cleanups.push(cleanup); + const preferencePath = ( + session as unknown as { getAutoRetryPreferencePath: () => string } + ).getAutoRetryPreferencePath(); + + let failWrites = true; + const { writeFile } = fsPromises; + const writeSpy = spyOn(fsPromises, "writeFile").mockImplementation( + async (target, data, options) => { + if (target === preferencePath && failWrites) throw new Error("EIO"); + return writeFile(target, data, options); + } + ); + try { + // A RetryBarrier Stop with no active stream: the opt-out is the only state it relies on. + await session.setAutoRetryEnabled(false); + expect(await Bun.file(preferencePath).exists()).toBe(false); + expect(await session.recordPendingAutoRetryState()).toBe(false); + + failWrites = false; + expect(await session.recordPendingAutoRetryState()).toBe(true); + expect(JSON.parse(await Bun.file(preferencePath).text())).toEqual({ enabled: false }); + } finally { + writeSpy.mockRestore(); + } + }); + test("provider config sweep keeps a persisted auto-retry opt-out while clearing the marker", async () => { const workspaceId = "startup-retry-sweep-keep-opt-out"; const { session, config, cleanup } = await createSessionBundle(workspaceId); diff --git a/src/node/services/agentSession.ts b/src/node/services/agentSession.ts index a3055f030e3..6bc741234a3 100644 --- a/src/node/services/agentSession.ts +++ b/src/node/services/agentSession.ts @@ -1611,7 +1611,7 @@ export class AgentSession { } // Best-effort: a failed write only sets autoRetryStateUnrecorded, which the one caller that must - // not acknowledge an unrecorded write (a user Stop) checks via recordPendingStartupAutoRetryAbandon. + // not acknowledge an unrecorded write (a user Stop) checks via recordPendingAutoRetryState. // Writes are serialized and only the latest state change's write marks it recorded, so an older // unlink cannot land after a newer marker write with the flag already cleared. Callers change the // state only after loadAutoRetryState settled, so the file is never rebuilt from unloaded defaults. @@ -1681,14 +1681,18 @@ export class AgentSession { } /** - * A user Stop is acknowledged only once the startup abandon marker its stopped turn relies on is - * on disk; otherwise the trailing row stays eligible for startup replay. A marker write that failed - * earlier (a withdrawn monitor wake, an aborted stream) is retried here, so the obligation survives - * the Stop that first reported it. + * A user Stop is acknowledged only once the auto-retry state its stopped turn relies on is on + * disk: the startup abandon marker, or the opt-out a RetryBarrier Stop records while no stream is + * active. Otherwise the trailing row stays eligible for startup replay. A write that failed earlier + * (a withdrawn monitor wake, an aborted stream, that opt-out) is retried here, so the obligation + * survives the Stop that first reported it. The default state owes disk nothing: a file that + * outlived a failed unlink can only disable retries or suppress a replay. */ - async recordPendingStartupAutoRetryAbandon(): Promise { + async recordPendingAutoRetryState(): Promise { await this.loadAutoRetryState(); - if (this.startupAutoRetryAbandon === null) return true; + if (this.autoRetryEnabledPreference !== false && this.startupAutoRetryAbandon === null) { + return true; + } if (this.autoRetryStateUnrecorded) await this.persistAutoRetryState(); return !this.autoRetryStateUnrecorded; } diff --git a/src/node/services/workspaceService.ts b/src/node/services/workspaceService.ts index 7e484608db6..2ee987d0101 100644 --- a/src/node/services/workspaceService.ts +++ b/src/node/services/workspaceService.ts @@ -11723,12 +11723,12 @@ export class WorkspaceService extends EventEmitter implements WorkspaceHost { // exit before it resolves, including a failed goal sync or acceptance (see // abandonWithdrawnSend in AgentSession.sendMessage). Stop is acknowledged after it settles: a // forced exit right after Stop must not leave the row eligible for startup replay. The send's - // own result is the dispatch's to report; a marker still unrecorded after the session retried - // the write fails the Stop below, on this and every later Stop, so the obligation is not lost - // with the joined send. + // own result is the dispatch's to report; a marker (or a RetryBarrier Stop's opt-out) still + // unrecorded after the session retried the write fails the Stop below, on this and every later + // Stop, so the obligation is not lost with the joined send. await withdrawnWakeSend?.catch(() => undefined); const stopRecorded = - !retiring || ((await session.recordPendingStartupAutoRetryAbandon()) && retirementRecorded); + !retiring || ((await session.recordPendingAutoRetryState()) && retirementRecorded); if (!stopResult.success) { // Interrupt failed, so clear hard-interrupt suppression we set above. if (!options?.soft) { From 7584e9cc5607cd4491fb45e947053efca13857e2 Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Mon, 7 Sep 2026 08:37:15 +0000 Subject: [PATCH 20/31] fix: await the CLI budget stop before teardown The stream abort resolves sendAndAwait before WorkspaceService.interruptStream has committed monitor retirement and auto-retry state, so run() could dispose the session and exit while that work was still in flight. Retain the stop promise and await it as the first cleanup step. --- src/cli/run.ts | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/src/cli/run.ts b/src/cli/run.ts index 8e6708eb77d..490c99ae1ad 100644 --- a/src/cli/run.ts +++ b/src/cli/run.ts @@ -999,11 +999,13 @@ async function main(): Promise { // Budget tracking state let budgetExceeded = false; + let budgetStop: Promise | null = null; // The budget cap is the user's Stop: go through the retiring interrupt so owed background-process // attention is dismissed, or the after-idle reconcile would start another billed turn before - // teardown. The Err case is a stop that did not persist, not a stop that failed. + // teardown. The Err case is a stop that did not persist, not a stop that failed. The stream abort + // settles the run before retirement is durable, so teardown awaits this promise first. const stopForBudget = (): void => { - void workspaceService + budgetStop ??= workspaceService .interruptStream(workspaceId, { abandonPartial: false, retireBashMonitorAttention: true }) .then((result) => { if (!result.success) { @@ -1582,6 +1584,7 @@ async function main(): Promise { // Contain each step, report it, and keep going. await runBestEffortCleanup( [ + { name: "budgetStop", run: () => budgetStop ?? undefined }, { name: "unsubscribe", run: () => unsubscribe() }, // Suppress monitor:stopped before session.dispose() triggers cleanup() so persisted // armed-monitor registry records survive shutdown (post-restart "monitor lost" wakes). From 9be951f01e063e3ed54c1b926aa9ca66aa1e5151 Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Mon, 7 Sep 2026 08:58:08 +0000 Subject: [PATCH 21/31] fix: land the RetryBarrier opt-out before issuing Stop The Stop is acknowledged only once the session's auto-retry state is on disk, so an opt-out still in flight when interruptStream ran its durability check could fail to write undetected. Await setAutoRetryEnabled first; surface an opt-out request that itself failed in the chat input. --- .../ChatBarrier/RetryBarrier.test.tsx | 33 +++++++++++++++++-- .../Messages/ChatBarrier/RetryBarrier.tsx | 22 ++++++++++--- 2 files changed, 48 insertions(+), 7 deletions(-) diff --git a/src/browser/features/Messages/ChatBarrier/RetryBarrier.test.tsx b/src/browser/features/Messages/ChatBarrier/RetryBarrier.test.tsx index 5488334c716..ab03ede772e 100644 --- a/src/browser/features/Messages/ChatBarrier/RetryBarrier.test.tsx +++ b/src/browser/features/Messages/ChatBarrier/RetryBarrier.test.tsx @@ -398,7 +398,36 @@ describe("RetryBarrier", () => { expect(resumeStream).toHaveBeenCalledTimes(1); }); - test("the Stop button issues the same attention-retiring Stop as its shortcut", () => { + test("the Stop button issues Stop only after the retry opt-out has landed", async () => { + currentWorkspaceState = createWorkspaceState({ + autoRetryStatus: { + type: "auto-retry-scheduled", + attempt: 1, + delayMs: 5_000, + scheduledAt: Date.now(), + }, + }); + let settleOptOut!: () => void; + setAutoRetryEnabled.mockImplementationOnce( + () => + new Promise((resolve) => { + settleOptOut = () => + resolve({ success: true as const, data: { previousEnabled: true, enabled: false } }); + }) + ); + + const view = render(); + fireEvent.click(view.getByRole("button", { name: /^Stop/ })); + await Promise.resolve(); + + expect(setAutoRetryEnabled).toHaveBeenCalledTimes(1); + expect(interruptStream).not.toHaveBeenCalled(); + + settleOptOut(); + await waitFor(() => expect(interruptStream).toHaveBeenCalledTimes(1)); + }); + + test("the Stop button issues the same attention-retiring Stop as its shortcut", async () => { currentWorkspaceState = createWorkspaceState({ autoRetryStatus: { type: "auto-retry-scheduled", @@ -413,7 +442,7 @@ describe("RetryBarrier", () => { fireEvent.click(view.getByRole("button", { name: /^Stop/ })); expect(setAutoRetryEnabled).toHaveBeenCalledWith({ workspaceId: "ws-1", enabled: false }); - expect(interruptStream).toHaveBeenCalledTimes(1); + await waitFor(() => expect(interruptStream).toHaveBeenCalledTimes(1)); expect(interruptStream).toHaveBeenCalledWith({ workspaceId: "ws-1", options: { retireBashMonitorAttention: true }, diff --git a/src/browser/features/Messages/ChatBarrier/RetryBarrier.tsx b/src/browser/features/Messages/ChatBarrier/RetryBarrier.tsx index 4b64163aa63..2bd774a8e57 100644 --- a/src/browser/features/Messages/ChatBarrier/RetryBarrier.tsx +++ b/src/browser/features/Messages/ChatBarrier/RetryBarrier.tsx @@ -9,6 +9,7 @@ import { VIM_ENABLED_KEY } from "@/common/constants/storage"; import { getSendOptionsFromStorage } from "@/browser/utils/messages/sendOptions"; import { applyCompactionOverrides } from "@/browser/utils/messages/compactionOptions"; import { stopStream } from "@/browser/utils/stopStream"; +import { publishChatError } from "@/browser/utils/chatErrorToasts"; import { formatSendMessageError } from "@/common/utils/errors/formatSendError"; import { getErrorMessage } from "@/common/utils/errors"; @@ -234,13 +235,22 @@ export const RetryBarrier: React.FC = (props) => { } }; - const handleStopAutoRetry = () => { + const handleStopAutoRetry = async () => { setCountdown(0); setManualRetryError(null); - void api?.workspace.setAutoRetryEnabled?.({ workspaceId: props.workspaceId, enabled: false }); - if (api) { - void stopStream(api, props.workspaceId); + if (!api) return; + // The Stop is acknowledged only once the session's auto-retry state is on disk, so the opt-out + // must reach the session first or its write escapes that check. + try { + const optOut = await api.workspace.setAutoRetryEnabled?.({ + workspaceId: props.workspaceId, + enabled: false, + }); + if (optOut != null && !optOut.success) publishChatError(props.workspaceId, optOut.error); + } catch (error) { + publishChatError(props.workspaceId, getErrorMessage(error)); } + await stopStream(api, props.workspaceId); }; const lastMessage = getLastMainRetryCandidateMessage(workspaceState.messages); @@ -305,7 +315,9 @@ export const RetryBarrier: React.FC = (props) => { actionButton = ( From 24d22537f966ed833f0f4f53ed05b234ae64b372 Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Mon, 7 Sep 2026 09:24:16 +0000 Subject: [PATCH 22/31] Finalize a cancelable wake refused as stale past the rollback horizon A requireIdle monitor wake whose admission goes stale after its row is durable (a manual send entered preflight during goal sync) returned without accept(), leaving the row in history while its watermark stayed owed, so the dispatcher redelivered the same attention after the manual turn. --- .../agentSession.queueDispatch.test.ts | 79 +++++++++++++++++++ src/node/services/agentSession.ts | 25 ++++-- 2 files changed, 96 insertions(+), 8 deletions(-) diff --git a/src/node/services/agentSession.queueDispatch.test.ts b/src/node/services/agentSession.queueDispatch.test.ts index a4470b0a92f..5c816edf55c 100644 --- a/src/node/services/agentSession.queueDispatch.test.ts +++ b/src/node/services/agentSession.queueDispatch.test.ts @@ -1223,6 +1223,85 @@ describe("AgentSession queued message tool-call dispatch", () => { } }); + test("a wake whose admission goes stale during goal sync is finalized, not left owed", async () => { + const workspaceId = "queue-dispatch-stale-after-goal-sync"; + let markSyncStarted: () => void = () => undefined; + const syncStarted = new Promise((resolve) => { + markSyncStarted = resolve; + }); + let releaseSync: () => void = () => undefined; + const syncRelease = new Promise((resolve) => { + releaseSync = resolve; + }); + const syncGoalModeWithChatTail = mock(async () => { + markSyncStarted(); + await syncRelease; + return null; + }); + const workspaceGoalService = { + assertPricedModelForBudgetedGoal: mock(() => Promise.resolve(Ok(undefined))), + syncGoalModeWithChatTail, + } as unknown as WorkspaceGoalService; + const streamMessage = mock(() => Promise.resolve(Ok(createStartedTurnHandle()))); + const { session, cleanup, historyService } = await createAgentSessionHarness({ + workspaceId, + workspaceGoalService, + aiServiceOverrides: { streamMessage }, + }); + + try { + const controller = new AbortController(); + // Stands in for the requireIdle preflight probe: a manual send enters preflight while the + // wake's durable row is already past the rollback horizon. + let manualSendInPreflight = false; + let accepted = false; + let preStreamFailures = 0; + const sendPromise = session.sendMessage( + "Background monitor wake", + { model: TEST_MODEL, agentId: "exec" }, + { + synthetic: true, + agentInitiated: true, + cancelSignal: controller.signal, + admissionStale: () => manualSendInPreflight, + onAccepted: () => { + accepted = true; + }, + onAcceptedPreStreamFailure: () => { + preStreamFailures += 1; + }, + } + ); + + await syncStarted; + manualSendInPreflight = true; + releaseSync(); + const result = await sendPromise; + + expect(result.success).toBe(false); + expect(accepted).toBe(true); + expect(preStreamFailures).toBe(1); + expect(streamMessage).not.toHaveBeenCalled(); + expect(session.isBusy()).toBe(false); + + const history = await historyService.getHistoryFromLatestBoundary(workspaceId); + expect(history.success).toBe(true); + if (history.success) { + expect( + history.data.some((message) => + message.parts.some( + (part) => part.type === "text" && part.text === "Background monitor wake" + ) + ) + ).toBe(true); + } + } finally { + releaseSync(); + session.dispose(); + await cleanup(); + } + }); + test("disposed sessions finalize durable wakes after goal sync completes", async () => { const workspaceId = "queue-dispatch-disposed-after-goal-sync"; let markSyncStarted: () => void = () => undefined; diff --git a/src/node/services/agentSession.ts b/src/node/services/agentSession.ts index 6bc741234a3..b093ecd5682 100644 --- a/src/node/services/agentSession.ts +++ b/src/node/services/agentSession.ts @@ -4221,6 +4221,19 @@ export class AgentSession { await this.updateStartupAutoRetryAbandonFromAbort("user", userMessage.id); } }; + // A stale refusal past this point keeps the durable row, which the manual turn that made the + // admission stale consumes as context. A cancelable wake is therefore finalized here rather + // than left owed: unaccepted, its dispatcher would deliver the same attention again once idle. + const refuseStaleDurableSend = async (): Promise> => { + if (cancelSignal != null) { + try { + await accept(); + } finally { + await abandonWithdrawnSend(); + } + } + return Err(createUnknownSendMessageError(CONTEXT_MUTATION_SEND_BLOCKED_MESSAGE)); + }; // r54: the pre-turn batch is now irrevocable — rollbackPersistedTurnRows // is never invoked past this point, so even a failure in goal sync or // acceptance leaves the payload + trigger rows durable in the transcript. @@ -4267,8 +4280,7 @@ export class AgentSession { // await, so a slider change during PREPARING (runtime warmup, model // creation) lands in the holder the stream's prepareStep will read. const turnThinkingOverride: ActiveTurnThinkingOverride = {}; - if (isAdmissionStale()) - return Err(createUnknownSendMessageError(CONTEXT_MUTATION_SEND_BLOCKED_MESSAGE)); + if (isAdmissionStale()) return refuseStaleDurableSend(); this.coordinator.acceptThinkingOverride( turnThinkingOverride, attempt.owner ?? attempt.expectedTurn @@ -4317,11 +4329,9 @@ export class AgentSession { if (isManualUserMessage) { // A fresh accepted user send supersedes any persisted startup-abandon // classification from previous turns. - if (isAdmissionStale()) - return Err(createUnknownSendMessageError(CONTEXT_MUTATION_SEND_BLOCKED_MESSAGE)); + if (isAdmissionStale()) return refuseStaleDurableSend(); await this.clearStartupAutoRetryAbandon(); - if (isAdmissionStale()) - return Err(createUnknownSendMessageError(CONTEXT_MUTATION_SEND_BLOCKED_MESSAGE)); + if (isAdmissionStale()) return refuseStaleDurableSend(); this.retryManager.cancel(); this.retryManager.setEnabled(true); await this.persistAutoRetryEnabledPreference(true); @@ -4329,8 +4339,7 @@ export class AgentSession { // Same-session retry should resume the exact accepted request we just finalized // in history, even if runtime warmup fails before streamWithHistory() starts. - if (isAdmissionStale()) - return Err(createUnknownSendMessageError(CONTEXT_MUTATION_SEND_BLOCKED_MESSAGE)); + if (isAdmissionStale()) return refuseStaleDurableSend(); this.setAutoRetryResumeState(optionsForStream, agentInitiated, goalKind, internal?.goalId); try { await accept(); From 6e68e0702a980064f36e03718be863b2c4d90057 Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Mon, 7 Sep 2026 09:55:36 +0000 Subject: [PATCH 23/31] Retry failed wake acceptance I/O ahead of dispatch instead of redelivering An accepted wake's row is durable, so when its watermark or acknowledgement I/O fails the consumption is kept owed in reconciler state and retried at the top of the next reconcile (retry backoff) before any dispatch. Acceptance no longer rejects the send, and the dispatch is cleared after the I/O either way, so a failed finalization can neither suppress the owner nor redeliver signals the transcript already carries. --- .../bashMonitorWakeReconciler.test.ts | 24 +++++++++++ .../services/bashMonitorWakeReconciler.ts | 40 +++++++++++++++---- 2 files changed, 56 insertions(+), 8 deletions(-) diff --git a/src/node/services/bashMonitorWakeReconciler.test.ts b/src/node/services/bashMonitorWakeReconciler.test.ts index 7a607af0d25..d6e7c1f3cc9 100644 --- a/src/node/services/bashMonitorWakeReconciler.test.ts +++ b/src/node/services/bashMonitorWakeReconciler.test.ts @@ -374,6 +374,30 @@ describe("BashMonitorWakeReconciler", () => { expect(dispatches).toHaveLength(2); }); + test("failed acceptance I/O is retried ahead of dispatch instead of redelivering the wake", async () => { + live = [liveSnapshot()]; + await reconciler.reconcile(OWNER); + const wake = dispatches[0]; + + acknowledgeGate = Promise.withResolvers(); + acknowledgeGate.promise.catch(() => undefined); + acknowledgeGate.reject(new Error("transient acknowledgement failure")); + // The accepted row is durable, so acceptance resolves and only the consumption stays owed. + await wake.onAccepted(); + await expect(reconciler.reconcile(OWNER)).rejects.toThrow("transient acknowledgement failure"); + expect(dispatches).toHaveLength(1); + + acknowledgeGate = undefined; + await reconciler.reconcile(OWNER); + expect(dispatches).toHaveLength(1); + + live = [ + liveSnapshot({ match: { throughOffset: 24, lines: ["READY again"], totalMatches: 2 } }), + ]; + await reconciler.reconcile(OWNER); + expect(dispatches).toHaveLength(2); + }); + test("full history clear consumes signals present both before and during the clear", async () => { live = [liveSnapshot()]; const token = await reconciler.beginFullHistoryClear(OWNER); diff --git a/src/node/services/bashMonitorWakeReconciler.ts b/src/node/services/bashMonitorWakeReconciler.ts index 265b5bd7bc8..70857a5b2df 100644 --- a/src/node/services/bashMonitorWakeReconciler.ts +++ b/src/node/services/bashMonitorWakeReconciler.ts @@ -11,6 +11,7 @@ import type { BashMonitorRegistryRecord, BashMonitorTerminalSummary, } from "@/node/services/bashMonitorRegistryStore"; +import { log } from "@/node/services/log"; import { MutexMap } from "@/node/utils/concurrency/mutexMap"; import { stripAnsiControlChars } from "@/node/utils/ansi"; import { isErrnoWithCode } from "@/node/utils/fs"; @@ -161,6 +162,8 @@ interface ReconcileState { scheduled: boolean; promise?: Promise; dispatch?: DispatchState; + /** Signals of an accepted wake whose consumption I/O has not succeeded; applied before any dispatch. */ + owedAcceptance?: readonly DerivedSignal[]; /** Frontier a committed stop still has to retire; applied before any dispatch. */ owedRetirement?: readonly BashMonitorProcessSnapshot[]; } @@ -505,10 +508,13 @@ export class BashMonitorWakeReconciler { private async reconcileOnce(ownerWorkspaceId: string): Promise { const dispatch = await this.locks.withLock(ownerWorkspaceId, async () => { - // A stop's retirement that failed on transient I/O is retried here first (a throw lands in - // the reconcile retry backoff), so dismissed attention never dispatches when the stop's own - // idle transition reconciles. - await this.retireOwed(ownerWorkspaceId, this.state(ownerWorkspaceId)); + // Consumption that failed on transient I/O is retried here first (a throw lands in the + // reconcile retry backoff): an accepted wake's signals are never redelivered over the row + // the transcript already carries, and dismissed attention never dispatches when the stop's + // own idle transition reconciles. + const owed = this.state(ownerWorkspaceId); + await this.acceptOwed(ownerWorkspaceId, owed); + await this.retireOwed(ownerWorkspaceId, owed); const collected = await this.collect(ownerWorkspaceId, true); for (const readSettled of collected.deferredReads) { void readSettled.finally(() => this.scheduleReconcile(ownerWorkspaceId)); @@ -579,15 +585,33 @@ export class BashMonitorWakeReconciler { await this.locks.withLock(ownerWorkspaceId, async () => { if (dispatch.accepted || dispatch.controller.signal.aborted) return; dispatch.accepted = true; - const watermarks = await this.readWatermarks(ownerWorkspaceId); - await this.advanceWatermarks(ownerWorkspaceId, watermarks, dispatch.signals); - await this.cleanup(dispatch.signals); const state = this.state(ownerWorkspaceId); - if (state.dispatch === dispatch) state.dispatch = undefined; + // The accepted row is durable, so its consumption stays owed when this I/O fails: the next + // reconcile retries it ahead of any dispatch instead of failing the turn or redelivering. + state.owedAcceptance = [...(state.owedAcceptance ?? []), ...dispatch.signals]; + try { + await this.acceptOwed(ownerWorkspaceId, state); + } catch (error) { + log.warn("Bash monitor wake acceptance I/O failed; retrying before the next dispatch", { + ownerWorkspaceId, + error, + }); + } finally { + // Still registered during the I/O so a Stop landing then can withdraw the wake. + if (state.dispatch === dispatch) state.dispatch = undefined; + } }); this.scheduleReconcile(ownerWorkspaceId); } + private async acceptOwed(ownerWorkspaceId: string, state: ReconcileState): Promise { + if (state.owedAcceptance == null) return; + const watermarks = await this.readWatermarks(ownerWorkspaceId); + await this.advanceWatermarks(ownerWorkspaceId, watermarks, state.owedAcceptance); + await this.cleanup(state.owedAcceptance); + state.owedAcceptance = undefined; + } + private abortDispatch(ownerWorkspaceId: string): void { const state = this.state(ownerWorkspaceId); state.dispatch?.controller.abort(); From 551e2c1675d44cd3f199c7809232252962c6f992 Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Mon, 7 Sep 2026 13:45:25 +0000 Subject: [PATCH 24/31] Consume a wake the transcript already carries instead of redelivering it When an accepted wake's consumption I/O keeps failing until the app exits, the durable transcript row is the only record of delivery: on the next run the signal derives as outstanding again and the in-memory owed acceptance is gone. The reconciler now asks the owner's history for wake records before dispatching and treats a signal whose (processId, wakeUpdatedAt) key is already in the transcript as delivered, advancing its watermark and acknowledging it with no new row. The lookup scans full history backward, compaction archive included, stopping once a chunk predates every process being checked, and is memoized per outstanding key so an unchanged frontier reconciles without another read. A failed history read rejects so dispatch stays held in the retry backoff. Also retires the wake-wiring test harness's never-completed stream handles on session close, which #4118's awaitable dispose otherwise drains forever. --- .../bashMonitorWakeReconciler.test.ts | 68 ++++++++++++++ .../services/bashMonitorWakeReconciler.ts | 88 +++++++++++++++---- src/node/services/workspaceService.test.ts | 50 +++++++++++ src/node/services/workspaceService.ts | 37 ++++++++ 4 files changed, 227 insertions(+), 16 deletions(-) diff --git a/src/node/services/bashMonitorWakeReconciler.test.ts b/src/node/services/bashMonitorWakeReconciler.test.ts index d6e7c1f3cc9..112bb94a71d 100644 --- a/src/node/services/bashMonitorWakeReconciler.test.ts +++ b/src/node/services/bashMonitorWakeReconciler.test.ts @@ -2,6 +2,7 @@ import * as fsPromises from "node:fs/promises"; import * as os from "node:os"; import * as path from "node:path"; +import type { BashMonitorWakeDisplayRecord } from "@/common/types/message"; import { classifyMachineTurnPromptKind } from "@/common/utils/machineTurnPrompts"; import type { BashMonitorRegistryRecord, @@ -61,6 +62,7 @@ describe("BashMonitorWakeReconciler", () => { let dropped: string[]; let droppedGenerations: Array; let acknowledgeGate: ReturnType> | undefined; + let transcript: BashMonitorWakeDisplayRecord[]; let reconciler: BashMonitorWakeReconciler; beforeEach(async () => { @@ -76,6 +78,7 @@ describe("BashMonitorWakeReconciler", () => { dropped = []; droppedGenerations = []; acknowledgeGate = undefined; + transcript = []; reconciler = new BashMonitorWakeReconciler({ sessionsDir: root, processManager: { @@ -110,6 +113,7 @@ describe("BashMonitorWakeReconciler", () => { }, recordTerminal: () => undefined, }, + deliveredWakes: () => Promise.resolve(transcript), onWake: (dispatch) => { dispatches.push(dispatch); return dispatchOutcome; @@ -398,6 +402,66 @@ describe("BashMonitorWakeReconciler", () => { expect(dispatches).toHaveLength(2); }); + test("a wake the transcript already carries is consumed after restart, not redelivered", async () => { + live = [liveSnapshot()]; + await reconciler.reconcile(OWNER); + // The row landed but its consumption I/O never succeeded before the app exited. + transcript.push(...dispatches[0].muxMetadata.records); + await reconciler.dispose(OWNER); + + let transcriptReads = 0; + let transcriptReadFails = true; + const afterRestart: BashMonitorWakeDispatch[] = []; + const restarted = new BashMonitorWakeReconciler({ + sessionsDir: root, + processManager: { + pullMonitorWakeSignals: () => live, + getMonitorWakeDeliveryState: () => Promise.resolve(deliveryState), + acknowledgeMonitorWake: (processId, _generation, matchedThroughOffset) => { + acknowledged.push({ + processId, + ...(matchedThroughOffset != null ? { matchedThroughOffset } : {}), + }); + }, + dropRetiredMonitor: () => undefined, + }, + registry: { + listAll: () => Promise.resolve(rows), + remove: () => undefined, + recordTerminal: () => undefined, + }, + deliveredWakes: () => { + transcriptReads++; + return transcriptReadFails + ? Promise.reject(new Error("transient transcript read")) + : Promise.resolve(transcript); + }, + onWake: (dispatch) => { + afterRestart.push(dispatch); + return "in-flight"; + }, + }); + + await expect(restarted.reconcile(OWNER)).rejects.toThrow("transient transcript read"); + expect(afterRestart).toEqual([]); + + transcriptReadFails = false; + await restarted.reconcile(OWNER); + expect(afterRestart).toEqual([]); + expect(acknowledged).toEqual([{ processId: "proc", matchedThroughOffset: 12 }]); + + live = [ + liveSnapshot({ match: { throughOffset: 24, lines: ["READY again"], totalMatches: 2 } }), + ]; + await restarted.reconcile(OWNER); + await restarted.reconcile(OWNER); + expect(afterRestart).toHaveLength(1); + expect(afterRestart[0].prompt).toContain("READY again"); + // One lookup per new outstanding key; an unchanged frontier reconciles without another read. + expect(transcriptReads).toBe(3); + await restarted.dispose(OWNER); + }); + test("full history clear consumes signals present both before and during the clear", async () => { live = [liveSnapshot()]; const token = await reconciler.beginFullHistoryClear(OWNER); @@ -473,6 +537,7 @@ describe("BashMonitorWakeReconciler", () => { }, recordTerminal: () => undefined, }, + deliveredWakes: () => Promise.resolve(transcript), onWake: (dispatch) => { restartedDispatches.push(dispatch); return "in-flight"; @@ -555,6 +620,7 @@ describe("BashMonitorWakeReconciler", () => { remove: () => undefined, recordTerminal: () => undefined, }, + deliveredWakes: () => Promise.resolve(transcript), onWake: (dispatch) => { afterRestart.push(dispatch); return "in-flight"; @@ -821,6 +887,7 @@ describe("BashMonitorWakeReconciler", () => { }, recordTerminal: () => undefined, }, + deliveredWakes: () => Promise.resolve(transcript), onWake: (dispatch) => { afterRestart.push(dispatch); return "in-flight"; @@ -960,6 +1027,7 @@ describe("BashMonitorWakeReconciler", () => { remove: () => undefined, recordTerminal: () => undefined, }, + deliveredWakes: () => Promise.resolve(transcript), onWake: (dispatch) => { retryDispatches.push(dispatch); return "in-flight"; diff --git a/src/node/services/bashMonitorWakeReconciler.ts b/src/node/services/bashMonitorWakeReconciler.ts index 70857a5b2df..a250370f944 100644 --- a/src/node/services/bashMonitorWakeReconciler.ts +++ b/src/node/services/bashMonitorWakeReconciler.ts @@ -3,7 +3,7 @@ import { randomUUID } from "node:crypto"; import * as fsPromises from "node:fs/promises"; import * as path from "node:path"; -import type { MuxMessageMetadata } from "@/common/types/message"; +import type { BashMonitorWakeDisplayRecord, MuxMessageMetadata } from "@/common/types/message"; import assert from "@/common/utils/assert"; import { BASH_MONITOR_WAKE_HEADINGS } from "@/common/utils/machineTurnPrompts"; import type { @@ -166,12 +166,27 @@ interface ReconcileState { owedAcceptance?: readonly DerivedSignal[]; /** Frontier a committed stop still has to retire; applied before any dispatch. */ owedRetirement?: readonly BashMonitorProcessSnapshot[]; + /** Outstanding wake keys already looked up in the transcript (see deliveredSignals). */ + transcriptChecked?: ReadonlySet; } function signalKey(processId: string, createdAt: string): string { return processId + "\u0000" + createdAt; } +/** Identifies one wake of a process; changes whenever the process has new attention to report. */ +function wakeUpdatedAt(signal: DerivedSignal): string { + return ( + signal.lost?.failedAt ?? + signal.terminal?.settledAt ?? + (signal.matchOffset != null ? signal.createdAt + ":" + signal.matchOffset : signal.createdAt) + ); +} + +function wakeKey(processId: string, updatedAt: string): string { + return processId + "\u0000" + updatedAt; +} + function normalizedTerminalStatus( terminal: BashMonitorTerminalSummary ): "exited" | "killed" | "failed" { @@ -332,12 +347,7 @@ function buildMetadata( type: "bash-monitor-wake", records: signals.map((signal) => ({ processId: signal.processId, - wakeUpdatedAt: - signal.lost?.failedAt ?? - signal.terminal?.settledAt ?? - (signal.matchOffset != null - ? signal.createdAt + ":" + signal.matchOffset - : signal.createdAt), + wakeUpdatedAt: wakeUpdatedAt(signal), kind: signal.kind === "monitor-lost" ? "monitor-lost" : "match", displayName: signal.displayName ?? signal.processId, filter: signal.filter, @@ -370,6 +380,14 @@ export class BashMonitorWakeReconciler { sessionsDir: string; processManager: BashMonitorWakeReconcilerProcessManager; registry: BashMonitorWakeReconcilerRegistry; + /** + * Wake records the owner's transcript carries in rows stamped at or after `since`, the + * creation time of the oldest process being checked. A rejection holds dispatch. + */ + deliveredWakes( + ownerWorkspaceId: string, + since: string + ): Promise; onWake( dispatch: BashMonitorWakeDispatch ): Promise | BashMonitorWakeDispatchOutcome; @@ -512,25 +530,27 @@ export class BashMonitorWakeReconciler { // reconcile retry backoff): an accepted wake's signals are never redelivered over the row // the transcript already carries, and dismissed attention never dispatches when the stop's // own idle transition reconciles. - const owed = this.state(ownerWorkspaceId); - await this.acceptOwed(ownerWorkspaceId, owed); - await this.retireOwed(ownerWorkspaceId, owed); + const state = this.state(ownerWorkspaceId); + await this.acceptOwed(ownerWorkspaceId, state); + await this.retireOwed(ownerWorkspaceId, state); const collected = await this.collect(ownerWorkspaceId, true); for (const readSettled of collected.deferredReads) { void readSettled.finally(() => this.scheduleReconcile(ownerWorkspaceId)); } - await this.advanceWatermarks(ownerWorkspaceId, collected.watermarks, collected.autoConsumed); - await this.cleanup(collected.autoConsumed); + const delivered = await this.deliveredSignals(ownerWorkspaceId, state, collected.signals); + const consumed = [...collected.autoConsumed, ...delivered]; + await this.advanceWatermarks(ownerWorkspaceId, collected.watermarks, consumed); + await this.cleanup(consumed); - const state = this.state(ownerWorkspaceId); - if (collected.signals.length === 0) { + const signals = collected.signals.filter((signal) => !delivered.includes(signal)); + if (signals.length === 0) { state.dispatch?.controller.abort(); state.dispatch = undefined; return undefined; } const signature = JSON.stringify( - collected.signals.map((signal) => [ + signals.map((signal) => [ signal.key, signal.kind, signal.matchOffset, @@ -546,7 +566,7 @@ export class BashMonitorWakeReconciler { id: randomUUID(), signature, controller: new AbortController(), - signals: collected.signals, + signals, accepted: false, }; state.dispatch = next; @@ -612,6 +632,42 @@ export class BashMonitorWakeReconciler { state.owedAcceptance = undefined; } + /** + * Outstanding signals whose wake row the transcript already carries. An acceptance whose + * consumption I/O kept failing until the app exited leaves the durable row as the only record + * of delivery; on the next run the signal derives as outstanding again and is consumed here + * instead of redelivered. Only this reconciler's own accepts add wake rows, so each outstanding + * key is looked up once and the result holds until the key leaves the outstanding set. + */ + private async deliveredSignals( + ownerWorkspaceId: string, + state: ReconcileState, + signals: readonly DerivedSignal[] + ): Promise { + const keyOf = (signal: DerivedSignal) => wakeKey(signal.processId, wakeUpdatedAt(signal)); + const checked = state.transcriptChecked ?? new Set(); + let delivered: DerivedSignal[] = []; + if (signals.some((signal) => !checked.has(keyOf(signal)))) { + const since = signals.reduce( + (oldest, signal) => (signal.createdAt < oldest ? signal.createdAt : oldest), + signals[0].createdAt + ); + const rows = await this.args.deliveredWakes(ownerWorkspaceId, since); + const inTranscript = new Set( + rows.flatMap((row) => + row.processId != null && row.wakeUpdatedAt != null + ? [wakeKey(row.processId, row.wakeUpdatedAt)] + : [] + ) + ); + delivered = signals.filter((signal) => inTranscript.has(keyOf(signal))); + } + state.transcriptChecked = new Set( + signals.filter((signal) => !delivered.includes(signal)).map(keyOf) + ); + return delivered; + } + private abortDispatch(ownerWorkspaceId: string): void { const state = this.state(ownerWorkspaceId); state.dispatch?.controller.abort(); diff --git a/src/node/services/workspaceService.test.ts b/src/node/services/workspaceService.test.ts index 4963a2b67d5..6ef012ca63f 100644 --- a/src/node/services/workspaceService.test.ts +++ b/src/node/services/workspaceService.test.ts @@ -299,6 +299,13 @@ describe("WorkspaceService bash monitor wake reconciler wiring", () => { requests.push(request); const completion = Promise.withResolvers(); completions.push(completion); + // Session shutdown retires an in-flight handle (as createStartedTurnHandle does), so + // finish() can drain a turn the test never completed. + harness.session.closingSignal.addEventListener( + "abort", + () => completion.resolve({ status: "aborted", abortReason: "user" }), + { once: true } + ); streaming = true; const messageId = "assistant-" + requests.length; aiEmitter.emit("stream-start", { @@ -416,6 +423,49 @@ describe("WorkspaceService bash monitor wake reconciler wiring", () => { }; } + test("a wake row already in history is consumed without a dispatch, even behind a compaction boundary", async () => { + const h = await createActiveWakeHarness(); + const acknowledged = spyOn(h.backgroundProcessManager, "acknowledgeMonitorWake"); + try { + // The row is durable but its acceptance never reached the watermark (I/O failed until exit); + // a later compaction moved it out of the window the model sees. + await h.historyService.appendToHistory( + h.workspaceId, + createMuxMessage("wake-delivered", "user", "Monitor matched", { + timestamp: Date.now(), + muxMetadata: { + type: "bash-monitor-wake", + records: ["first", "second"].map((processId) => ({ + processId, + wakeUpdatedAt: "2026-01-01T00:00:00.000Z:7", + kind: "match" as const, + displayName: processId, + filter: "READY", + filterExclude: false, + })), + }, + }) + ); + await h.historyService.appendToHistory( + h.workspaceId, + createMuxMessage("summary-1", "assistant", "Summary", { + timestamp: Date.now(), + compactionBoundary: true, + compacted: true, + compactionEpoch: 1, + muxMetadata: { type: "compaction-summary" }, + }) + ); + await h.addAttention(7); + expect(h.dispatch).not.toHaveBeenCalled(); + expect(acknowledged).toHaveBeenCalledTimes(2); + await h.addAttention(12); + expect(h.dispatch).toHaveBeenCalledTimes(1); + } finally { + await h.finish(); + } + }); + test("the SDK answers in the original stream after repeated owed wakes are consumed", async () => { const h = await createActiveWakeHarness(); let step = 0; diff --git a/src/node/services/workspaceService.ts b/src/node/services/workspaceService.ts index 16874d17d3f..9aac59b21bb 100644 --- a/src/node/services/workspaceService.ts +++ b/src/node/services/workspaceService.ts @@ -195,6 +195,7 @@ import { getCompactionFollowUpContent, parseWorkspaceTurnTaskCorrelation, pickPreservedSendOptions, + type BashMonitorWakeDisplayRecord, type CompactionFollowUpRequest, type MuxMessageMetadata, type MuxMessage, @@ -2400,6 +2401,8 @@ export class WorkspaceService extends EventEmitter implements WorkspaceHost { : undefined, }, registry: this.bashMonitorRegistryStore, + deliveredWakes: (ownerWorkspaceId, since) => + this.listDeliveredBashMonitorWakes(ownerWorkspaceId, since), onWake: (dispatch) => this.dispatchBashMonitorWake(dispatch), }); if (typeof this.backgroundProcessManager.on === "function") { @@ -2560,6 +2563,40 @@ export class WorkspaceService extends EventEmitter implements WorkspaceHost { this.pendingBashMonitorWakeIdleWaitsByOwner.set(ownerWorkspaceId, promise); } + /** + * Wake records in transcript rows written since `since`, scanning newest-first across the + * compaction archive too because a delivered row stays proof of delivery after it leaves the + * window the model sees. A failed read rejects so the reconciler holds dispatch in its retry + * backoff instead of risking a duplicate row. + */ + private async listDeliveredBashMonitorWakes( + ownerWorkspaceId: string, + since: string + ): Promise { + const oldest = Date.parse(since); + const records: BashMonitorWakeDisplayRecord[] = []; + const result = await this.historyService.iterateFullHistory( + ownerWorkspaceId, + "backward", + (messages) => { + // A wake row is appended after its process was created, so once a whole chunk predates + // every process being checked the rest of history cannot carry one. + let predatesAll = messages.length > 0; + for (const message of messages) { + const muxMetadata = message.metadata?.muxMetadata; + if (message.role === "user" && muxMetadata?.type === "bash-monitor-wake") { + records.push(...muxMetadata.records); + } + const timestamp = message.metadata?.timestamp; + if (!(typeof timestamp === "number" && timestamp < oldest)) predatesAll = false; + } + return !predatesAll; + } + ); + if (!result.success) throw new Error(result.error); + return records; + } + private async dispatchBashMonitorWake( dispatch: BashMonitorWakeDispatch ): Promise { From 4dfe2b90265eefcfbf75bbbd4503294f57016967 Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Mon, 7 Sep 2026 14:45:27 +0000 Subject: [PATCH 25/31] Accept a wake the moment its row is durable; filter malformed wake rows The transcript lookup treats a persisted wake row as proof of acceptance, so acceptance must not trail the row: a cancelable wake now calls accept() right after markRowsDurable(), ahead of goal sync, and accept() is idempotent so the later exits only record the abandon marker. To keep a Stop able to withdraw the wake anywhere before its stream starts, the reconciler no longer releases an accepted dispatch's slot at acceptance; the slot is released once the send has also settled (onWake returned), and abortDispatch can still reach it meanwhile. listDeliveredBashMonitorWakes validates persisted metadata instead of spreading it: a malformed row degrades to "not delivered" rather than failing the scan and holding every wake of that owner in the retry backoff. --- .../agentSession.queueDispatch.test.ts | 4 +- src/node/services/agentSession.ts | 49 ++++++++---------- .../bashMonitorWakeReconciler.test.ts | 51 +++++++++++++++++++ .../services/bashMonitorWakeReconciler.ts | 35 +++++++++++-- src/node/services/workspaceService.test.ts | 22 +++++++- src/node/services/workspaceService.ts | 22 ++++++-- 6 files changed, 143 insertions(+), 40 deletions(-) diff --git a/src/node/services/agentSession.queueDispatch.test.ts b/src/node/services/agentSession.queueDispatch.test.ts index d0a484aceac..535b4eaeff7 100644 --- a/src/node/services/agentSession.queueDispatch.test.ts +++ b/src/node/services/agentSession.queueDispatch.test.ts @@ -1412,6 +1412,9 @@ describe("AgentSession queued message tool-call dispatch", () => { ); await syncStarted; + // Accepted before goal sync began: a crash anywhere past the durable row leaves an accepted + // row, never one the reconciler's transcript lookup would misread as delivered. + expect(accepted).toBe(true); releaseSync(); let syncError: unknown; try { @@ -1422,7 +1425,6 @@ describe("AgentSession queued message tool-call dispatch", () => { expect(syncError).toBeInstanceOf(Error); expect((syncError as Error).message).toContain("injected goal sync failure"); - expect(accepted).toBe(true); expect(canceledReasons).toEqual([]); expect(cancelState.canceledBeforeAcceptance).toBe(false); const history = await historyService.getHistoryFromLatestBoundary(workspaceId); diff --git a/src/node/services/agentSession.ts b/src/node/services/agentSession.ts index dc63f88e54f..36142e6b013 100644 --- a/src/node/services/agentSession.ts +++ b/src/node/services/agentSession.ts @@ -3345,6 +3345,7 @@ export class AgentSession { if ((internal?.preTurnMessages?.length ?? 0) > 0) internal?.onPreTurnRowsPersisted?.(); }; const accept = async (): Promise => { + if (attempt.durability === "accepted") return; await internal?.onAccepted?.(); attempt.durability = "accepted"; }; @@ -4184,36 +4185,33 @@ export class AgentSession { await this.updateStartupAutoRetryAbandonFromAbort("user", userMessage.id); } }; - // A stale refusal past this point keeps the durable row, which the manual turn that made the - // admission stale consumes as context. A cancelable wake is therefore finalized here rather - // than left owed: unaccepted, its dispatcher would deliver the same attention again once idle. + // A stale refusal past this point keeps the durable, already accepted row, which the manual + // turn that made the admission stale consumes as context. const refuseStaleDurableSend = async (): Promise> => { - if (cancelSignal != null) { - try { - await accept(); - } finally { - await abandonWithdrawnSend(); - } - } + await abandonWithdrawnSend(); return Err(createUnknownSendMessageError(CONTEXT_MUTATION_SEND_BLOCKED_MESSAGE)); }; // r54: the pre-turn batch is now irrevocable — rollbackPersistedTurnRows // is never invoked past this point, so even a failure in goal sync or // acceptance leaves the payload + trigger rows durable in the transcript. markRowsDurable(); + // A cancelable wake is accepted the moment its row is durable, before goal sync: its + // dispatcher treats the transcript row as proof of acceptance (a restart consumes a signal + // whose row is already there), so no later await may leave a durable, unaccepted row behind + // a crash. Startup recovery resumes the row without redelivering it, unless a Stop withdrew + // the wake. + if (cancelSignal != null) { + try { + await accept(); + } catch (error) { + await abandonWithdrawnSend(); + return Err(createUnknownSendMessageError(getErrorMessage(error))); + } + } try { await this.workspaceGoalService?.syncGoalModeWithChatTail(this.workspaceId); } catch (error) { - if (cancelSignal != null) { - // The durable row crossed the point of no return, so every later goal-sync failure must still - // finalize this monitor wake. Startup recovery can resume the row without redelivering it, - // unless a Stop withdrew the wake. - try { - await accept(); - } finally { - await abandonWithdrawnSend(); - } - } + await abandonWithdrawnSend(); throw error; } @@ -4225,16 +4223,9 @@ export class AgentSession { } // Workspace may be tearing down while we await filesystem IO. - // If so, skip event emission + streaming to avoid races with dispose(). A cancelable monitor - // wake past the point of no return is already durable, so finalize it before leaving. + // If so, skip event emission + streaming to avoid races with dispose(). if (this.coordinator.disposed) { - if (cancelSignal != null && cancellationDisabled) { - try { - await accept(); - } finally { - await abandonWithdrawnSend(); - } - } + await abandonWithdrawnSend(); return Ok(undefined); } diff --git a/src/node/services/bashMonitorWakeReconciler.test.ts b/src/node/services/bashMonitorWakeReconciler.test.ts index 112bb94a71d..a704d54a791 100644 --- a/src/node/services/bashMonitorWakeReconciler.test.ts +++ b/src/node/services/bashMonitorWakeReconciler.test.ts @@ -13,6 +13,7 @@ import { type BashMonitorProcessSnapshot, type BashMonitorWakeDeliveryState, type BashMonitorWakeDispatch, + type BashMonitorWakeDispatchOutcome, } from "@/node/services/bashMonitorWakeReconciler"; const OWNER = "owner"; @@ -190,6 +191,56 @@ describe("BashMonitorWakeReconciler", () => { expect(dispatches).toHaveLength(1); }); + test("an accepted wake stays withdrawable until its send settles", async () => { + live = [liveSnapshot()]; + const send = Promise.withResolvers(); + const inFlight: BashMonitorWakeDispatch[] = []; + const held = new BashMonitorWakeReconciler({ + sessionsDir: root, + processManager: { + pullMonitorWakeSignals: () => live, + getMonitorWakeDeliveryState: () => Promise.resolve(deliveryState), + acknowledgeMonitorWake: (processId, _generation, matchedThroughOffset) => { + acknowledged.push({ + processId, + ...(matchedThroughOffset != null ? { matchedThroughOffset } : {}), + }); + }, + dropRetiredMonitor: () => undefined, + }, + registry: { + listAll: () => Promise.resolve(rows), + remove: () => undefined, + recordTerminal: () => undefined, + }, + deliveredWakes: () => Promise.resolve(transcript), + onWake: (dispatch) => { + inFlight.push(dispatch); + return send.promise; + }, + }); + const reconciling = held.reconcile(OWNER); + while (inFlight.length === 0) { + await new Promise((resolve) => setTimeout(resolve, 1)); + } + // The row is durable, so the wake is accepted while its send is still in preflight. + await inFlight[0].onAccepted(); + expect(acknowledged).toEqual([{ processId: "proc", matchedThroughOffset: 12 }]); + // A Stop landing before the stream starts still withdraws it. + await held.consumeCurrent(OWNER); + expect(inFlight[0].cancelSignal.aborted).toBe(true); + send.resolve("in-flight"); + await reconciling; + + live = [ + liveSnapshot({ match: { throughOffset: 24, lines: ["READY again"], totalMatches: 2 } }), + ]; + await held.reconcile(OWNER); + expect(inFlight).toHaveLength(2); + expect(inFlight[1].cancelSignal.aborted).toBe(false); + await held.dispose(OWNER); + }); + test("consumeCurrent withdraws the wake but keeps signals owed when the commit is refused", async () => { live = [liveSnapshot()]; await reconciler.reconcile(OWNER); diff --git a/src/node/services/bashMonitorWakeReconciler.ts b/src/node/services/bashMonitorWakeReconciler.ts index a250370f944..c706b3baf47 100644 --- a/src/node/services/bashMonitorWakeReconciler.ts +++ b/src/node/services/bashMonitorWakeReconciler.ts @@ -154,9 +154,15 @@ interface DispatchState { signature: string; controller: AbortController; signals: readonly DerivedSignal[]; + /** Row durable and consumption at least owed. */ accepted: boolean; + /** onWake returned: the send is streaming or has exited and can no longer be withdrawn. */ + settled: boolean; } +/** Identity a persisted wake row carries per process, see buildMetadata. */ +export type DeliveredWakeRecord = Pick; + interface ReconcileState { requested: boolean; scheduled: boolean; @@ -381,13 +387,13 @@ export class BashMonitorWakeReconciler { processManager: BashMonitorWakeReconcilerProcessManager; registry: BashMonitorWakeReconcilerRegistry; /** - * Wake records the owner's transcript carries in rows stamped at or after `since`, the + * Wake identities the owner's transcript carries in rows stamped at or after `since`, the * creation time of the oldest process being checked. A rejection holds dispatch. */ deliveredWakes( ownerWorkspaceId: string, since: string - ): Promise; + ): Promise; onWake( dispatch: BashMonitorWakeDispatch ): Promise | BashMonitorWakeDispatchOutcome; @@ -568,6 +574,7 @@ export class BashMonitorWakeReconciler { controller: new AbortController(), signals, accepted: false, + settled: false, }; state.dispatch = next; return next; @@ -584,6 +591,7 @@ export class BashMonitorWakeReconciler { onDeferred: async () => this.defer(ownerWorkspaceId, dispatch), }); if (outcome === "deferred") await this.defer(ownerWorkspaceId, dispatch); + else await this.settle(ownerWorkspaceId, dispatch); } catch (error) { await this.locks.withLock(ownerWorkspaceId, () => { const state = this.state(ownerWorkspaceId); @@ -601,6 +609,14 @@ export class BashMonitorWakeReconciler { return Promise.resolve(); }); } + private async settle(ownerWorkspaceId: string, dispatch: DispatchState): Promise { + await this.locks.withLock(ownerWorkspaceId, () => { + dispatch.settled = true; + this.release(ownerWorkspaceId, dispatch); + return Promise.resolve(); + }); + if (dispatch.accepted) this.scheduleReconcile(ownerWorkspaceId); + } private async accept(ownerWorkspaceId: string, dispatch: DispatchState): Promise { await this.locks.withLock(ownerWorkspaceId, async () => { if (dispatch.accepted || dispatch.controller.signal.aborted) return; @@ -617,11 +633,20 @@ export class BashMonitorWakeReconciler { error, }); } finally { - // Still registered during the I/O so a Stop landing then can withdraw the wake. - if (state.dispatch === dispatch) state.dispatch = undefined; + this.release(ownerWorkspaceId, dispatch); } }); - this.scheduleReconcile(ownerWorkspaceId); + if (dispatch.settled) this.scheduleReconcile(ownerWorkspaceId); + } + /** + * Under the lock. An accepted wake keeps its slot until its send settles (onWake returned), so + * a Stop landing anywhere before the stream starts can still withdraw it through abortDispatch. + */ + private release(ownerWorkspaceId: string, dispatch: DispatchState): void { + const state = this.states.get(ownerWorkspaceId); + if (state?.dispatch === dispatch && dispatch.accepted && dispatch.settled) { + state.dispatch = undefined; + } } private async acceptOwed(ownerWorkspaceId: string, state: ReconcileState): Promise { diff --git a/src/node/services/workspaceService.test.ts b/src/node/services/workspaceService.test.ts index 6ef012ca63f..1f2cc1c21c9 100644 --- a/src/node/services/workspaceService.test.ts +++ b/src/node/services/workspaceService.test.ts @@ -61,7 +61,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 { SendMessageOptions, WorkspaceChatMessage } from "@/common/orpc/types"; -import { createMuxMessage } from "@/common/types/message"; +import { createMuxMessage, type MuxMessageMetadata } from "@/common/types/message"; import { buildStagedAttachmentNotice } from "@/browser/features/ChatInput/stagedAttachments"; import { WORKFLOW_RESULT_METADATA_TYPE, @@ -466,6 +466,26 @@ describe("WorkspaceService bash monitor wake reconciler wiring", () => { } }); + test("malformed wake metadata in history neither stalls nor consumes an outstanding wake", async () => { + const h = await createActiveWakeHarness(); + try { + await h.historyService.appendToHistory( + h.workspaceId, + createMuxMessage("wake-corrupt", "user", "Monitor matched", { + timestamp: Date.now(), + muxMetadata: { + type: "bash-monitor-wake", + records: [null, "junk", { processId: "first" }], + } as unknown as MuxMessageMetadata, + }) + ); + await h.addAttention(7); + expect(h.dispatch).toHaveBeenCalledTimes(1); + } finally { + await h.finish(); + } + }); + test("the SDK answers in the original stream after repeated owed wakes are consumed", async () => { const h = await createActiveWakeHarness(); let step = 0; diff --git a/src/node/services/workspaceService.ts b/src/node/services/workspaceService.ts index 9aac59b21bb..f7a694ba383 100644 --- a/src/node/services/workspaceService.ts +++ b/src/node/services/workspaceService.ts @@ -118,6 +118,7 @@ import { sliceMessagesForProviderFromLatestContextBoundary, } from "@/common/utils/messages/compactionBoundary"; import { isNonNegativeInteger, isPositiveInteger } from "@/common/utils/numbers"; +import { isPlainObject } from "@/common/utils/isPlainObject"; import { deriveTodoStatus } from "@/common/utils/todoList"; import { createContextResetBoundaryMessageId } from "@/node/services/utils/messageIds"; import { fileExists } from "@/node/utils/runtime/fileExists"; @@ -195,7 +196,6 @@ import { getCompactionFollowUpContent, parseWorkspaceTurnTaskCorrelation, pickPreservedSendOptions, - type BashMonitorWakeDisplayRecord, type CompactionFollowUpRequest, type MuxMessageMetadata, type MuxMessage, @@ -320,6 +320,7 @@ import { type BashMonitorWakeDispatch, type BashMonitorWakeDispatchOutcome, type BashMonitorWakeReconcilerSnapshot, + type DeliveredWakeRecord, } from "@/node/services/bashMonitorWakeReconciler"; import type { WorkspaceLifecycleHooks } from "@/node/services/workspaceLifecycleHooks"; import { @@ -2572,9 +2573,9 @@ export class WorkspaceService extends EventEmitter implements WorkspaceHost { private async listDeliveredBashMonitorWakes( ownerWorkspaceId: string, since: string - ): Promise { + ): Promise { const oldest = Date.parse(since); - const records: BashMonitorWakeDisplayRecord[] = []; + const records: DeliveredWakeRecord[] = []; const result = await this.historyService.iterateFullHistory( ownerWorkspaceId, "backward", @@ -2585,7 +2586,20 @@ export class WorkspaceService extends EventEmitter implements WorkspaceHost { for (const message of messages) { const muxMetadata = message.metadata?.muxMetadata; if (message.role === "user" && muxMetadata?.type === "bash-monitor-wake") { - records.push(...muxMetadata.records); + // Persisted metadata is unvalidated: a malformed row must degrade to "not delivered" + // rather than fail the scan, which would hold every wake of this owner. + const rows: unknown = muxMetadata.records; + if (Array.isArray(rows)) { + for (const row of rows) { + if ( + isPlainObject(row) && + typeof row.processId === "string" && + typeof row.wakeUpdatedAt === "string" + ) { + records.push({ processId: row.processId, wakeUpdatedAt: row.wakeUpdatedAt }); + } + } + } } const timestamp = message.metadata?.timestamp; if (!(typeof timestamp === "number" && timestamp < oldest)) predatesAll = false; From 7ad183fa44330db0d526f4824600bd6548f2add3 Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Mon, 7 Sep 2026 15:24:04 +0000 Subject: [PATCH 26/31] Land the retry opt-out inside stopStream; key withdrawn-wake markers to the durable row Every renderer Stop that also opts out of auto-retry (Escape and Ctrl+C, the streaming and retry barriers, compaction cancel, the palette command) now does so through stopStream({ disableAutoRetry: true }), which awaits the opt-out and surfaces its failure before issuing the Stop. The Stop is acknowledged only once the session's auto-retry state is on disk, so an opt-out still in flight escaped that check and left the trailing row replayable after a restart. A withdrawn wake records its abandon marker against the row that was actually persisted: under on-send compaction that is the compaction request, not the user message that is deliberately never appended, so startup recovery no longer misses the marker and resumes the withdrawn wake. The queue badge derives from the entry the next drain actually sends, matching the dispatch readers that already skip withdrawn entries; the raw FIFO head reader had no other caller and is removed. --- .../Messages/ChatBarrier/RetryBarrier.tsx | 14 +--- .../ChatBarrier/StreamingBarrier.test.tsx | 41 +++++----- .../Messages/ChatBarrier/StreamingBarrier.tsx | 6 +- src/browser/hooks/useAIViewKeybinds.test.tsx | 67 +++++++++++++++-- src/browser/hooks/useAIViewKeybinds.ts | 4 +- src/browser/utils/commands/sources.ts | 3 +- src/browser/utils/compaction/handler.ts | 2 +- src/browser/utils/stopStream.test.ts | 29 ++++++++ src/browser/utils/stopStream.ts | 18 ++++- .../agentSession.queueDispatch.test.ts | 74 +++++++++++++++++++ src/node/services/agentSession.ts | 7 +- src/node/services/messageQueue.test.ts | 12 +-- src/node/services/messageQueue.ts | 11 ++- 13 files changed, 226 insertions(+), 62 deletions(-) diff --git a/src/browser/features/Messages/ChatBarrier/RetryBarrier.tsx b/src/browser/features/Messages/ChatBarrier/RetryBarrier.tsx index 2bd774a8e57..514736647d2 100644 --- a/src/browser/features/Messages/ChatBarrier/RetryBarrier.tsx +++ b/src/browser/features/Messages/ChatBarrier/RetryBarrier.tsx @@ -9,7 +9,6 @@ import { VIM_ENABLED_KEY } from "@/common/constants/storage"; import { getSendOptionsFromStorage } from "@/browser/utils/messages/sendOptions"; import { applyCompactionOverrides } from "@/browser/utils/messages/compactionOptions"; import { stopStream } from "@/browser/utils/stopStream"; -import { publishChatError } from "@/browser/utils/chatErrorToasts"; import { formatSendMessageError } from "@/common/utils/errors/formatSendError"; import { getErrorMessage } from "@/common/utils/errors"; @@ -239,18 +238,7 @@ export const RetryBarrier: React.FC = (props) => { setCountdown(0); setManualRetryError(null); if (!api) return; - // The Stop is acknowledged only once the session's auto-retry state is on disk, so the opt-out - // must reach the session first or its write escapes that check. - try { - const optOut = await api.workspace.setAutoRetryEnabled?.({ - workspaceId: props.workspaceId, - enabled: false, - }); - if (optOut != null && !optOut.success) publishChatError(props.workspaceId, optOut.error); - } catch (error) { - publishChatError(props.workspaceId, getErrorMessage(error)); - } - await stopStream(api, props.workspaceId); + await stopStream(api, props.workspaceId, { disableAutoRetry: true }); }; const lastMessage = getLastMainRetryCandidateMessage(workspaceState.messages); diff --git a/src/browser/features/Messages/ChatBarrier/StreamingBarrier.test.tsx b/src/browser/features/Messages/ChatBarrier/StreamingBarrier.test.tsx index a8dcda1e5d5..74424cbd2f3 100644 --- a/src/browser/features/Messages/ChatBarrier/StreamingBarrier.test.tsx +++ b/src/browser/features/Messages/ChatBarrier/StreamingBarrier.test.tsx @@ -1,5 +1,5 @@ import { afterEach, beforeEach, describe, expect, mock, test } from "bun:test"; -import { cleanup, fireEvent, render } from "@testing-library/react"; +import { cleanup, fireEvent, render, waitFor } from "@testing-library/react"; import { GlobalWindow } from "happy-dom"; import type * as WorkspaceStoreModule from "@/browser/stores/WorkspaceStore"; @@ -163,7 +163,7 @@ describe("StreamingBarrier", () => { globalThis.document = undefined as unknown as Document; }); - test("clicking stop during normal streaming interrupts with default options", () => { + test("clicking stop during normal streaming interrupts with default options", async () => { currentWorkspaceState = createWorkspaceState({ canInterrupt: true, isCompacting: false, @@ -177,13 +177,15 @@ describe("StreamingBarrier", () => { expect(setAutoRetryEnabled).toHaveBeenCalledWith({ workspaceId: "ws-1", enabled: false }); expect(setInterrupting).toHaveBeenCalledWith("ws-1"); - expect(interruptStream).toHaveBeenCalledWith({ - workspaceId: "ws-1", - options: { retireBashMonitorAttention: true }, - }); + await waitFor(() => + expect(interruptStream).toHaveBeenCalledWith({ + workspaceId: "ws-1", + options: { retireBashMonitorAttention: true }, + }) + ); }); - test("clicking stop during stream-start interrupts without setting interrupting state", () => { + test("clicking stop during stream-start interrupts without setting interrupting state", async () => { currentWorkspaceState = createWorkspaceState({ canInterrupt: false, pendingStreamStartTime: Date.now(), @@ -200,10 +202,12 @@ describe("StreamingBarrier", () => { expect(setAutoRetryEnabled).toHaveBeenCalledWith({ workspaceId: "ws-1", enabled: false }); expect(setInterrupting).not.toHaveBeenCalled(); - expect(interruptStream).toHaveBeenCalledWith({ - workspaceId: "ws-1", - options: { retireBashMonitorAttention: true }, - }); + await waitFor(() => + expect(interruptStream).toHaveBeenCalledWith({ + workspaceId: "ws-1", + options: { retireBashMonitorAttention: true }, + }) + ); }); test("shows the barrier immediately on first appearance", () => { @@ -364,13 +368,14 @@ describe("StreamingBarrier", () => { fireEvent.click(view.getByRole("button", { name: "Stop streaming" })); - expect(setAutoRetryEnabled).toHaveBeenCalledWith({ workspaceId: "ws-1", enabled: false }); + // The compaction-cancel flow owns the retry opt-out along with its Stop. expect(onCancelCompaction).toHaveBeenCalledTimes(1); + expect(setAutoRetryEnabled).not.toHaveBeenCalled(); expect(setInterrupting).not.toHaveBeenCalled(); expect(interruptStream).not.toHaveBeenCalled(); }); - test("clicking stop during compaction falls back to abandonPartial interrupt", () => { + test("clicking stop during compaction falls back to abandonPartial interrupt", async () => { currentWorkspaceState = createWorkspaceState({ canInterrupt: true, isCompacting: true, @@ -382,10 +387,12 @@ describe("StreamingBarrier", () => { expect(setAutoRetryEnabled).toHaveBeenCalledWith({ workspaceId: "ws-1", enabled: false }); expect(setInterrupting).not.toHaveBeenCalled(); - expect(interruptStream).toHaveBeenCalledWith({ - workspaceId: "ws-1", - options: { abandonPartial: true, retireBashMonitorAttention: true }, - }); + await waitFor(() => + expect(interruptStream).toHaveBeenCalledWith({ + workspaceId: "ws-1", + options: { abandonPartial: true, retireBashMonitorAttention: true }, + }) + ); }); test("resets to new workspace text immediately on workspace switch", () => { diff --git a/src/browser/features/Messages/ChatBarrier/StreamingBarrier.tsx b/src/browser/features/Messages/ChatBarrier/StreamingBarrier.tsx index 383bd9dce9f..24f4c9316f7 100644 --- a/src/browser/features/Messages/ChatBarrier/StreamingBarrier.tsx +++ b/src/browser/features/Messages/ChatBarrier/StreamingBarrier.tsx @@ -265,8 +265,6 @@ export const StreamingBarrier: React.FC = ({ return; } - void api.workspace.setAutoRetryEnabled?.({ workspaceId, enabled: false }); - if (phase === "compacting") { // Reuse the established compaction-cancel flow from keyboard shortcuts so we keep // edit restoration + follow-up content behavior consistent across input methods. @@ -275,7 +273,7 @@ export const StreamingBarrier: React.FC = ({ return; } - void stopStream(api, workspaceId, { abandonPartial: true }); + void stopStream(api, workspaceId, { abandonPartial: true, disableAutoRetry: true }); return; } @@ -283,7 +281,7 @@ export const StreamingBarrier: React.FC = ({ storeRaw.setInterrupting(workspaceId); } - void stopStream(api, workspaceId); + void stopStream(api, workspaceId, { disableAutoRetry: true }); }; // Show settings hint during compaction if no custom compaction model is configured diff --git a/src/browser/hooks/useAIViewKeybinds.test.tsx b/src/browser/hooks/useAIViewKeybinds.test.tsx index 8e47a1534ab..3c20f10abc3 100644 --- a/src/browser/hooks/useAIViewKeybinds.test.tsx +++ b/src/browser/hooks/useAIViewKeybinds.test.tsx @@ -1,6 +1,6 @@ import type { ReactNode, RefObject } from "react"; import { afterEach, beforeEach, describe, expect, mock, test } from "bun:test"; -import { cleanup, renderHook } from "@testing-library/react"; +import { cleanup, renderHook, waitFor } from "@testing-library/react"; import { copyFile, readFile, rm, writeFile } from "node:fs/promises"; import { randomUUID } from "node:crypto"; import { dirname, join } from "node:path"; @@ -90,7 +90,7 @@ describe("useAIViewKeybinds", () => { isolatedModulePaths = []; }); - test("Escape interrupts an active stream in normal mode", () => { + test("Escape interrupts an active stream in normal mode", async () => { const interruptStream = mock(() => Promise.resolve({ success: true as const, data: undefined }) ); @@ -124,7 +124,7 @@ describe("useAIViewKeybinds", () => { }) ); - expect(interruptStream.mock.calls.length).toBe(1); + await waitFor(() => expect(interruptStream.mock.calls.length).toBe(1)); }); test("Escape does not interrupt when the event target is an ", () => { @@ -168,7 +168,7 @@ describe("useAIViewKeybinds", () => { expect(interruptStream.mock.calls.length).toBe(0); }); - test("Escape interrupts when an editable element opts in", () => { + test("Escape interrupts when an editable element opts in", async () => { const interruptStream = mock(() => Promise.resolve({ success: true as const, data: undefined }) ); @@ -207,10 +207,10 @@ describe("useAIViewKeybinds", () => { }) ); - expect(interruptStream.mock.calls.length).toBe(1); + await waitFor(() => expect(interruptStream.mock.calls.length).toBe(1)); }); - test("Ctrl+C interrupts in vim mode even when an is focused", () => { + test("Ctrl+C interrupts in vim mode even when an is focused", async () => { const interruptStream = mock(() => Promise.resolve({ success: true as const, data: undefined }) ); @@ -249,7 +249,60 @@ describe("useAIViewKeybinds", () => { }) ); - expect(interruptStream.mock.calls.length).toBe(1); + await waitFor(() => expect(interruptStream.mock.calls.length).toBe(1)); + }); + + test("Escape on the retry barrier issues Stop only after the retry opt-out has landed", async () => { + const interruptStream = mock(() => + Promise.resolve({ success: true as const, data: undefined }) + ); + let settleOptOut!: () => void; + const setAutoRetryEnabled = mock( + () => + new Promise<{ success: true; data: { previousEnabled: boolean; enabled: boolean } }>( + (resolve) => { + settleOptOut = () => + resolve({ success: true, data: { previousEnabled: true, enabled: false } }); + } + ) + ); + currentClientMock = { + workspace: { + interruptStream, + setAutoRetryEnabled, + }, + }; + + const chatInputAPI: RefObject = { current: null }; + + renderUseAIViewKeybinds({ + workspaceId: "ws", + canInterrupt: false, + showRetryBarrier: true, + chatInputAPI, + jumpToBottom: () => undefined, + loadOlderHistory: null, + handleOpenTerminal: () => undefined, + handleOpenInEditor: () => undefined, + aggregator: undefined, + setEditingMessage: () => undefined, + vimEnabled: false, + }); + + document.body.dispatchEvent( + new window.KeyboardEvent("keydown", { + key: "Escape", + bubbles: true, + cancelable: true, + }) + ); + await Promise.resolve(); + + expect(setAutoRetryEnabled).toHaveBeenCalledWith({ workspaceId: "ws", enabled: false }); + expect(interruptStream.mock.calls.length).toBe(0); + + settleOptOut(); + await waitFor(() => expect(interruptStream.mock.calls.length).toBe(1)); }); test.each([ diff --git a/src/browser/hooks/useAIViewKeybinds.ts b/src/browser/hooks/useAIViewKeybinds.ts index 24971e6875b..a328bbf45cc 100644 --- a/src/browser/hooks/useAIViewKeybinds.ts +++ b/src/browser/hooks/useAIViewKeybinds.ts @@ -112,7 +112,6 @@ export function useAIViewKeybinds({ if (api) { void cancelCompaction(api, workspaceId, aggregator, setEditingMessage); } - void api?.workspace.setAutoRetryEnabled?.({ workspaceId, enabled: false }); return; } @@ -121,9 +120,8 @@ export function useAIViewKeybinds({ // Non-vim mode: Esc interrupts (except when typing in inputs, unless explicitly opted in) if (canInterrupt || showRetryBarrier) { e.preventDefault(); - void api?.workspace.setAutoRetryEnabled?.({ workspaceId, enabled: false }); if (api) { - void stopStream(api, workspaceId); + void stopStream(api, workspaceId, { disableAutoRetry: true }); } return; } diff --git a/src/browser/utils/commands/sources.ts b/src/browser/utils/commands/sources.ts index c8eb8678e04..1112a6ce93b 100644 --- a/src/browser/utils/commands/sources.ts +++ b/src/browser/utils/commands/sources.ts @@ -1222,8 +1222,7 @@ export function buildCoreSources(p: BuildSourcesParams): Array<() => CommandActi if (!p.api) { return; } - await p.api.workspace.setAutoRetryEnabled?.({ workspaceId: id, enabled: false }); - await stopStream(p.api, id); + await stopStream(p.api, id, { disableAutoRetry: true }); }, }); list.push({ diff --git a/src/browser/utils/compaction/handler.ts b/src/browser/utils/compaction/handler.ts index c408378e972..9a27956cd78 100644 --- a/src/browser/utils/compaction/handler.ts +++ b/src/browser/utils/compaction/handler.ts @@ -100,7 +100,7 @@ export async function cancelCompaction( // Interrupt stream with abandonPartial flag // Backend detects this and skips compaction (Ctrl+C flow) - await stopStream(client, workspaceId, { abandonPartial: true }); + await stopStream(client, workspaceId, { abandonPartial: true, disableAutoRetry: true }); return true; } diff --git a/src/browser/utils/stopStream.test.ts b/src/browser/utils/stopStream.test.ts index 25f7d664a9f..3da1e3a1748 100644 --- a/src/browser/utils/stopStream.test.ts +++ b/src/browser/utils/stopStream.test.ts @@ -31,6 +31,35 @@ describe("stopStream", () => { expect(peekChatError("ws-unrecorded")).toBeUndefined(); }); + test("disableAutoRetry lands the opt-out, and its failure, before the Stop is issued", async () => { + const calls: string[] = []; + let settleOptOut!: () => void; + const api = { + workspace: { + setAutoRetryEnabled: () => { + calls.push("opt-out"); + return new Promise((resolve) => { + settleOptOut = () => resolve({ success: false, error: "preference unwritable" }); + }); + }, + interruptStream: () => { + calls.push("interrupt"); + return Promise.resolve({ success: true, data: undefined }); + }, + }, + } as unknown as APIClient; + + const stopping = stopStream(api, "ws-opt-out", { disableAutoRetry: true }); + await Promise.resolve(); + expect(calls).toEqual(["opt-out"]); + + settleOptOut(); + await stopping; + expect(calls).toEqual(["opt-out", "interrupt"]); + expect(peekChatError("ws-opt-out")).toBe("preference unwritable"); + dismissChatError("ws-opt-out", "preference unwritable"); + }); + test("a recorded Stop retires owed monitor output without a chat error", async () => { const { api, calls } = apiReturning({ success: true, data: undefined }); diff --git a/src/browser/utils/stopStream.ts b/src/browser/utils/stopStream.ts index 48dd604a319..c52b233ce91 100644 --- a/src/browser/utils/stopStream.ts +++ b/src/browser/utils/stopStream.ts @@ -1,19 +1,33 @@ import type { APIClient } from "@/browser/contexts/API"; import { publishChatError } from "@/browser/utils/chatErrorToasts"; +import { getErrorMessage } from "@/common/utils/errors"; /** * User Stop: interrupts the stream and dismisses owed background monitor output instead of letting * it wake the agent. A Stop the backend could not record on disk may resume on restart, so its * failure is shown in the workspace's chat input rather than dropped with the Result. + * + * `disableAutoRetry` lands the retry opt-out before the Stop: the Stop is acknowledged only once + * the session's auto-retry state is on disk, so an opt-out still in flight would escape that check + * and a trailing row could replay on restart. */ export async function stopStream( api: APIClient, workspaceId: string, - options?: { abandonPartial?: boolean } + options?: { abandonPartial?: boolean; disableAutoRetry?: boolean } ): Promise { + const { disableAutoRetry, ...interruptOptions } = options ?? {}; + if (disableAutoRetry) { + try { + const optOut = await api.workspace.setAutoRetryEnabled?.({ workspaceId, enabled: false }); + if (optOut != null && !optOut.success) publishChatError(workspaceId, optOut.error); + } catch (error) { + publishChatError(workspaceId, getErrorMessage(error)); + } + } const result = await api.workspace.interruptStream({ workspaceId, - options: { ...options, retireBashMonitorAttention: true }, + options: { ...interruptOptions, retireBashMonitorAttention: true }, }); if (!result.success) { publishChatError(workspaceId, result.error); diff --git a/src/node/services/agentSession.queueDispatch.test.ts b/src/node/services/agentSession.queueDispatch.test.ts index 535b4eaeff7..1ca32a7cf39 100644 --- a/src/node/services/agentSession.queueDispatch.test.ts +++ b/src/node/services/agentSession.queueDispatch.test.ts @@ -2,6 +2,7 @@ import type { StreamAbortEvent } from "@/common/types/stream"; import { runSessionTerminalPolicy } from "./agentSession.testHarness"; import { describe, expect, mock, spyOn, test } from "bun:test"; import { EventEmitter } from "node:events"; +import * as fsPromises from "node:fs/promises"; import { createDisplayUsage } from "@/common/utils/tokens/displayUsage"; import { getTotalCost } from "@/common/utils/tokens/usageAggregator"; @@ -10,6 +11,7 @@ import { Err, Ok } from "@/common/types/result"; import type { WorkspaceGoalService } from "./workspaceGoalService"; import { createAgentSessionHarness, createStartedTurnHandle } from "./agentSession.testHarness"; import type { AIService } from "./aiService"; +import type { CompactionMonitor } from "./compactionMonitor"; import type { TurnCompletion } from "./streamManager"; const TEST_MODEL = "anthropic:claude-sonnet-4-5"; @@ -1308,6 +1310,78 @@ describe("AgentSession queued message tool-call dispatch", () => { } }); + test("a wake withdrawn under on-send compaction records the persisted compaction row as abandoned", async () => { + const workspaceId = "queue-dispatch-withdrawn-compaction-row"; + let markSyncStarted: () => void = () => undefined; + const syncStarted = new Promise((resolve) => { + markSyncStarted = resolve; + }); + let releaseSync: () => void = () => undefined; + const syncRelease = new Promise((resolve) => { + releaseSync = resolve; + }); + const workspaceGoalService = { + assertPricedModelForBudgetedGoal: mock(() => Promise.resolve(Ok(undefined))), + syncGoalModeWithChatTail: mock(async () => { + markSyncStarted(); + await syncRelease; + return null; + }), + } as unknown as WorkspaceGoalService; + const streamMessage = mock(() => + Promise.resolve(Ok(createStartedTurnHandle(session.closingSignal))) + ); + const { session, cleanup, historyService } = await createAgentSessionHarness({ + workspaceId, + workspaceGoalService, + aiServiceOverrides: { streamMessage }, + }); + const internals = session as unknown as { + compactionMonitor: CompactionMonitor; + getAutoRetryPreferencePath(): string; + }; + internals.compactionMonitor = { + checkBeforeSend: () => ({ + shouldShowWarning: true, + shouldForceCompact: true, + usagePercentage: 99, + thresholdPercentage: 85, + }), + checkMidStream: () => false, + resetForNewStream: () => undefined, + setThreshold: () => undefined, + getThreshold: () => 0.85, + } as unknown as CompactionMonitor; + + try { + const controller = new AbortController(); + const sendPromise = session.sendMessage( + "Background monitor wake", + { model: TEST_MODEL, agentId: "exec" }, + { synthetic: true, agentInitiated: true, cancelSignal: controller.signal } + ); + await syncStarted; + // A Stop withdraws the wake past the point of no return. + controller.abort(); + releaseSync(); + expect((await sendPromise).success).toBe(true); + expect(streamMessage).not.toHaveBeenCalled(); + + const history = await historyService.getHistoryFromLatestBoundary(workspaceId); + if (!history.success) throw new Error(history.error); + const trailing = history.data.at(-1); + expect(trailing?.metadata?.muxMetadata?.type).toBe("compaction-request"); + const persisted = JSON.parse( + await fsPromises.readFile(internals.getAutoRetryPreferencePath(), "utf-8") + ) as { startupAutoRetryAbandon?: { userMessageId?: string } }; + expect(persisted.startupAutoRetryAbandon?.userMessageId).toBe(trailing?.id); + } finally { + releaseSync(); + await session.dispose(); + await cleanup(); + } + }); + test("disposed sessions finalize durable wakes after goal sync completes", async () => { const workspaceId = "queue-dispatch-disposed-after-goal-sync"; let markSyncStarted: () => void = () => undefined; diff --git a/src/node/services/agentSession.ts b/src/node/services/agentSession.ts index 36142e6b013..e21302d6d73 100644 --- a/src/node/services/agentSession.ts +++ b/src/node/services/agentSession.ts @@ -4182,7 +4182,12 @@ export class AgentSession { // runs this check after its last other await. const abandonWithdrawnSend = async (): Promise => { if (cancelSignal?.aborted === true) { - await this.updateStartupAutoRetryAbandonFromAbort("user", userMessage.id); + // Startup recovery matches the marker against the trailing durable row, which under on-send + // compaction is the compaction request, not the never-persisted user message. + await this.updateStartupAutoRetryAbandonFromAbort( + "user", + (autoCompactionMessage ?? userMessage).id + ); } }; // A stale refusal past this point keeps the durable, already accepted row, which the manual diff --git a/src/node/services/messageQueue.test.ts b/src/node/services/messageQueue.test.ts index 9ea10f22bc9..ebf05fad884 100644 --- a/src/node/services/messageQueue.test.ts +++ b/src/node/services/messageQueue.test.ts @@ -415,12 +415,12 @@ describe("MessageQueue", () => { expect(queue.setVisibleQueueDispatchMode("turn-end")).toBe(true); expect(queue.getVisibleQueueDispatchMode()).toBe("turn-end"); - expect(queue.getNextQueueDispatchMode()).toBe("turn-end"); + expect(queue.getNextDispatchableMode()).toBe("turn-end"); expect(queue.getQueueDispatchMode()).toBe("tool-end"); expect(queue.getMessages()).toEqual(["visible first", "visible second", "hidden wake"]); queue.dequeueNext(); - expect(queue.getNextQueueDispatchMode()).toBe("turn-end"); + expect(queue.getNextDispatchableMode()).toBe("turn-end"); }); it("reports a hidden predecessor's effective mode until the user reprioritizes the visible card", () => { @@ -440,7 +440,7 @@ describe("MessageQueue", () => { expect(queue.setVisibleQueueDispatchMode("tool-end")).toBe(true); expect(queue.getMessages()).toEqual(["visible follow-up", "hidden predecessor"]); expect(queue.getVisibleQueueDispatchMode()).toBe("tool-end"); - expect(queue.getNextQueueDispatchMode()).toBe("tool-end"); + expect(queue.getNextDispatchableMode()).toBe("tool-end"); }); it("reports the first visible entry mode instead of a later visible tool-end entry", () => { @@ -476,9 +476,9 @@ describe("MessageQueue", () => { ); expect(queue.getQueueDispatchMode()).toBe("tool-end"); - expect(queue.getNextQueueDispatchMode()).toBe("turn-end"); + expect(queue.getNextDispatchableMode()).toBe("turn-end"); queue.dequeueNext(); - expect(queue.getNextQueueDispatchMode()).toBe("tool-end"); + expect(queue.getNextDispatchableMode()).toBe("tool-end"); }); it("does not update a queue containing only hidden entries", () => { @@ -504,7 +504,7 @@ describe("MessageQueue", () => { queue.add("follow up", { ...validOptions, queueDispatchMode: "turn-end" }); expect(queue.getNextDispatchableMode()).toBe("turn-end"); - expect(queue.getNextQueueDispatchMode()).toBe("tool-end"); + expect(queue.getVisibleQueueDispatchMode()).toBe("turn-end"); }); it("should reset mode to tool-end when cleared", () => { diff --git a/src/node/services/messageQueue.ts b/src/node/services/messageQueue.ts index ae7edfd1444..872b9149720 100644 --- a/src/node/services/messageQueue.ts +++ b/src/node/services/messageQueue.ts @@ -278,11 +278,6 @@ export class MessageQueue { return entries.some((entry) => entry.dispatchMode === "tool-end") ? "tool-end" : "turn-end"; } - /** Dispatch boundary for the FIFO head entry — the only entry the next drain can send. */ - getNextQueueDispatchMode(): QueueDispatchMode { - return this.entries[0]?.dispatchMode ?? "tool-end"; - } - /** * The first entry whose cancel signal has not fired. Aborted entries still drain FIFO (as no-ops that fire * onCanceled), but they are not pending work or continuations of a turn. @@ -417,9 +412,13 @@ export class MessageQueue { /** * Dispatch mode for user-visible entries only. Backend-initiated maintenance/wake * messages should not change the queue badge shown beside the user's own follow-up. + * Derived from the entry the next drain actually sends, so a withdrawn head cannot show a + * boundary the live message will not dispatch at. */ getVisibleQueueDispatchMode(): QueueDispatchMode { - return this.getVisibleEntries().length > 0 ? this.getNextQueueDispatchMode() : "tool-end"; + return this.getVisibleEntries().length > 0 + ? (this.getNextDispatchableMode() ?? "tool-end") + : "tool-end"; } /** From f00391c20f8a4ac418fc8fed50e61c4db7a2b59c Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Mon, 7 Sep 2026 15:58:13 +0000 Subject: [PATCH 27/31] Hold archived owners' wakes; read compacted wake rows; keep discard and revalidation off accepted or withdrawn entries An archived owner has no session to wait on and sendMessage refuses it, so the after-idle retry spun through history scans and refused sends until unarchive. dispatchBashMonitorWake now defers such wakes without rescheduling and unarchive reconciles them. The transcript scan also reads the wake identity nested in a compaction-request row's follow-up, the only durable row when a wake triggered on-send compaction. A discarded process no longer withdraws an accepted wake (its row is durable and only a user Stop joins the send and verifies the abandon marker), and queue correlation revalidation skips withdrawn entries like the other readers, so a canceled no-op cannot strip a promoted continuation's correlation. --- .../bashMonitorWakeReconciler.test.ts | 41 ++++++++++++ .../services/bashMonitorWakeReconciler.ts | 7 +- src/node/services/messageQueue.test.ts | 36 ++++++++++ src/node/services/messageQueue.ts | 3 + src/node/services/workspaceService.test.ts | 65 +++++++++++++++++++ src/node/services/workspaceService.ts | 20 +++++- 6 files changed, 168 insertions(+), 4 deletions(-) diff --git a/src/node/services/bashMonitorWakeReconciler.test.ts b/src/node/services/bashMonitorWakeReconciler.test.ts index a704d54a791..047c1e015dc 100644 --- a/src/node/services/bashMonitorWakeReconciler.test.ts +++ b/src/node/services/bashMonitorWakeReconciler.test.ts @@ -241,6 +241,47 @@ describe("BashMonitorWakeReconciler", () => { await held.dispose(OWNER); }); + test("a discarded process withdraws an unaccepted wake but leaves an accepted one to stream", async () => { + live = [liveSnapshot()]; + await reconciler.reconcile(OWNER); + const unaccepted = dispatches[0]; + await reconciler.discardProcess(OWNER, "proc", CREATED_AT); + expect(unaccepted.cancelSignal.aborted).toBe(true); + + // The accepted wake's send is still in flight (row durable, stream not yet started). + const send = Promise.withResolvers(); + const inFlight: BashMonitorWakeDispatch[] = []; + const held = new BashMonitorWakeReconciler({ + sessionsDir: root, + processManager: { + pullMonitorWakeSignals: () => live, + getMonitorWakeDeliveryState: () => Promise.resolve(deliveryState), + acknowledgeMonitorWake: () => undefined, + dropRetiredMonitor: () => undefined, + }, + registry: { + listAll: () => Promise.resolve(rows), + remove: () => undefined, + recordTerminal: () => undefined, + }, + deliveredWakes: () => Promise.resolve(transcript), + onWake: (dispatch) => { + inFlight.push(dispatch); + return send.promise; + }, + }); + const reconciling = held.reconcile(OWNER); + while (inFlight.length === 0) { + await new Promise((resolve) => setTimeout(resolve, 1)); + } + await inFlight[0].onAccepted(); + await held.discardProcess(OWNER, "proc", CREATED_AT); + expect(inFlight[0].cancelSignal.aborted).toBe(false); + send.resolve("in-flight"); + await reconciling; + await held.dispose(OWNER); + }); + test("consumeCurrent withdraws the wake but keeps signals owed when the commit is refused", async () => { live = [liveSnapshot()]; await reconciler.reconcile(OWNER); diff --git a/src/node/services/bashMonitorWakeReconciler.ts b/src/node/services/bashMonitorWakeReconciler.ts index c706b3baf47..f67278b8a60 100644 --- a/src/node/services/bashMonitorWakeReconciler.ts +++ b/src/node/services/bashMonitorWakeReconciler.ts @@ -455,10 +455,13 @@ export class BashMonitorWakeReconciler { ): Promise { await this.locks.withLock(ownerWorkspaceId, () => { const state = this.state(ownerWorkspaceId); + // An accepted wake's row is already durable; only a user Stop withdraws it (it joins the send + // and verifies the abandon marker), so a discarded process leaves it to stream. if ( - state.dispatch?.signals.some( + state.dispatch?.accepted === false && + state.dispatch.signals.some( (signal) => signal.processId === processId && signal.createdAt === createdAt - ) === true + ) ) { state.dispatch.controller.abort(); state.dispatch = undefined; diff --git a/src/node/services/messageQueue.test.ts b/src/node/services/messageQueue.test.ts index ebf05fad884..a48141387ce 100644 --- a/src/node/services/messageQueue.test.ts +++ b/src/node/services/messageQueue.test.ts @@ -655,6 +655,42 @@ describe("MessageQueue", () => { expect(skipped.internal?.onCanceled).toBe(peerCanceled); }); + it("ignores a withdrawn predecessor when revalidating correlations after a promotion", () => { + const turnMetadata: MuxMessageMetadata = { + type: "workspace-turn-task", + taskHandleId: "wst_parent", + ownerWorkspaceId: "grandparent", + turnId: "turn-1", + }; + const withdrawn = new AbortController(); + queue.add( + "withdrawn wake", + { ...validOptions, queueDispatchMode: "tool-end" }, + { ...hidden, cancelSignal: withdrawn.signal } + ); + withdrawn.abort(); + const peerCanceled = () => undefined; + queue.add( + "peer message", + { ...validOptions, queueDispatchMode: "turn-end", muxMetadata: turnMetadata }, + { ...hidden, workspaceTurnContinuation: true, onCanceled: peerCanceled } + ); + queue.add( + "progress report", + { ...validOptions, queueDispatchMode: "tool-end", muxMetadata: turnMetadata }, + { ...hidden, workspaceTurnContinuation: true, promoteAheadOfHiddenTurnEnd: true } + ); + + expect(queue.dequeueNext().message).toBe("withdrawn wake"); + const promoted = queue.dequeueNext(); + expect(promoted.message).toBe("progress report"); + expect(promoted.options?.muxMetadata).toEqual(turnMetadata); + const skipped = queue.dequeueNext(); + expect(skipped.message).toBe("peer message"); + expect(skipped.options?.muxMetadata).toEqual(turnMetadata); + expect(skipped.internal?.onCanceled).toBe(peerCanceled); + }); + it("ignores the hidden turn-end entries a promoted report overtakes when judging its correlation", () => { // A queued heartbeat (hidden, turn-end, uncorrelated) would make the plain check report a // superseding predecessor and strip the report's correlation before enqueue — yet the diff --git a/src/node/services/messageQueue.ts b/src/node/services/messageQueue.ts index 872b9149720..bbb4e57daee 100644 --- a/src/node/services/messageQueue.ts +++ b/src/node/services/messageQueue.ts @@ -433,6 +433,9 @@ export class MessageQueue { let priorCorrelation: WorkspaceTurnMetadata | undefined; for (const entry of this.entries) { + // Withdrawn entries drain as no-ops: neither predecessors nor correlation holders, as in + // hasAllWorkspaceTurnContinuations. + if (entry.cancelSignal?.aborted === true) continue; const metadata = isWorkspaceTurnMetadata(entry.muxMetadata) ? entry.muxMetadata : undefined; const matchesPriorCorrelation = metadata != null && diff --git a/src/node/services/workspaceService.test.ts b/src/node/services/workspaceService.test.ts index 1f2cc1c21c9..66aa1f8baad 100644 --- a/src/node/services/workspaceService.test.ts +++ b/src/node/services/workspaceService.test.ts @@ -466,6 +466,71 @@ describe("WorkspaceService bash monitor wake reconciler wiring", () => { } }); + test("a wake persisted as an on-send compaction request is consumed without a dispatch", async () => { + const h = await createActiveWakeHarness(); + const acknowledged = spyOn(h.backgroundProcessManager, "acknowledgeMonitorWake"); + try { + await h.historyService.appendToHistory( + h.workspaceId, + createMuxMessage("wake-compaction", "user", "/compact", { + timestamp: Date.now(), + muxMetadata: { + type: "compaction-request", + rawCommand: "/compact", + parsed: { + followUpContent: { + text: "Monitor matched", + model: h.model, + agentId: "exec", + muxMetadata: { + type: "bash-monitor-wake", + records: ["first", "second"].map((processId) => ({ + processId, + wakeUpdatedAt: "2026-01-01T00:00:00.000Z:7", + kind: "match" as const, + displayName: processId, + filter: "READY", + filterExclude: false, + })), + }, + }, + }, + }, + }) + ); + await h.addAttention(7); + expect(h.dispatch).not.toHaveBeenCalled(); + expect(acknowledged).toHaveBeenCalledTimes(2); + } finally { + await h.finish(); + } + }); + + test("an archived owner's wake is held without an idle-retry loop and dispatches on unarchive", async () => { + const h = await createActiveWakeHarness(); + const send = spyOn(h.service, "sendMessage"); + try { + await h.config.editConfig((config) => { + for (const project of config.projects.values()) { + for (const workspace of project.workspaces) { + if (workspace.id === h.workspaceId) workspace.archivedAt = new Date().toISOString(); + } + } + return config; + }); + await h.addAttention(7); + expect(send).not.toHaveBeenCalled(); + expect(h.internal.pendingBashMonitorWakeIdleWaitsByOwner.has(h.workspaceId)).toBe(false); + expect((await h.reconciler.snapshot(h.workspaceId)).pendingWakeKinds.size).toBe(2); + + expect((await h.service.unarchive(h.workspaceId)).success).toBe(true); + await h.reconciler.reconcile(h.workspaceId); + expect(h.requests).toHaveLength(1); + } finally { + await h.finish(); + } + }); + test("malformed wake metadata in history neither stalls nor consumes an outstanding wake", async () => { const h = await createActiveWakeHarness(); try { diff --git a/src/node/services/workspaceService.ts b/src/node/services/workspaceService.ts index f7a694ba383..f0059e7864c 100644 --- a/src/node/services/workspaceService.ts +++ b/src/node/services/workspaceService.ts @@ -2585,10 +2585,16 @@ export class WorkspaceService extends EventEmitter implements WorkspaceHost { let predatesAll = messages.length > 0; for (const message of messages) { const muxMetadata = message.metadata?.muxMetadata; - if (message.role === "user" && muxMetadata?.type === "bash-monitor-wake") { + // A wake that triggered on-send compaction persists only the compaction request, with + // the wake's metadata nested as its follow-up. + const wake = + muxMetadata?.type === "bash-monitor-wake" + ? muxMetadata + : getCompactionFollowUpContent(muxMetadata)?.muxMetadata; + if (message.role === "user" && wake?.type === "bash-monitor-wake") { // Persisted metadata is unvalidated: a malformed row must degrade to "not delivered" // rather than fail the scan, which would hold every wake of this owner. - const rows: unknown = muxMetadata.records; + const rows: unknown = wake.records; if (Array.isArray(rows)) { for (const row of rows) { if ( @@ -2622,6 +2628,14 @@ export class WorkspaceService extends EventEmitter implements WorkspaceHost { this.notifyBashMonitorWakeStateChanged(ownerWorkspaceId); return "in-flight"; } + // sendMessage refuses archived workspaces and no session exists to wait on, so an after-idle + // retry would spin; the wake stays owed and unarchive reconciles it. + if ( + this.archivingWorkspaces.has(ownerWorkspaceId) || + isWorkspaceArchived(entry.workspace.archivedAt, entry.workspace.unarchivedAt) + ) { + return "deferred"; + } const hasPendingTurn = this.hasPendingQueuedOrPreparingTurn(ownerWorkspaceId); // Pending mid-stream compaction counts as turn work: the session reads idle between the // stopped stream and its compaction request, which the session sends directly. @@ -9005,6 +9019,8 @@ export class WorkspaceService extends EventEmitter implements WorkspaceHost { if (!didUnarchive) { return Ok(undefined); } + // Monitor attention held while archived (see dispatchBashMonitorWake) wakes now. + this.scheduleBashMonitorWakeReconcile(workspaceId); // Emit updated metadata const allMetadata = await this.config.getAllWorkspaceMetadata(); From acbf95881327d4b6a48344ac1ab8587e938c800d Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Mon, 7 Sep 2026 16:25:16 +0000 Subject: [PATCH 28/31] Move the retry opt-out into the Stop transaction; wake held monitor attention only after unarchive restores Disabling auto-retry releases the idle gate a pending monitor wake may wait behind, so an opt-out issued ahead of the Stop let that wake enter sendMessage before the Stop reserved its attention. interruptStream now takes disableAutoRetry and persists the opt-out after consumeCurrent has withdrawn the dispatch and reserved the reconciler lock, before the session interrupt, and verifies it with the abandon marker before acknowledging. stopStream passes the flag through instead of making a separate call. Unarchive schedules the held monitor reconcile after snapshot restoration and lifecycle startup succeed, alongside the workflow reconciliation, so a wake cannot run against a half-restored checkout or survive a failed restoration's rollback to archived. --- .../ChatBarrier/RetryBarrier.test.tsx | 36 ++------------ .../ChatBarrier/StreamingBarrier.test.tsx | 10 ++-- src/browser/hooks/useAIViewKeybinds.test.tsx | 26 +++++----- src/browser/utils/compaction/handler.test.ts | 2 +- src/browser/utils/stopStream.test.ts | 34 ++----------- src/browser/utils/stopStream.ts | 18 ++----- src/common/orpc/schemas/api.ts | 3 ++ src/node/services/workspaceService.test.ts | 48 +++++++++++++++++++ src/node/services/workspaceService.ts | 22 +++++++-- 9 files changed, 97 insertions(+), 102 deletions(-) diff --git a/src/browser/features/Messages/ChatBarrier/RetryBarrier.test.tsx b/src/browser/features/Messages/ChatBarrier/RetryBarrier.test.tsx index ab03ede772e..60242e66661 100644 --- a/src/browser/features/Messages/ChatBarrier/RetryBarrier.test.tsx +++ b/src/browser/features/Messages/ChatBarrier/RetryBarrier.test.tsx @@ -398,36 +398,7 @@ describe("RetryBarrier", () => { expect(resumeStream).toHaveBeenCalledTimes(1); }); - test("the Stop button issues Stop only after the retry opt-out has landed", async () => { - currentWorkspaceState = createWorkspaceState({ - autoRetryStatus: { - type: "auto-retry-scheduled", - attempt: 1, - delayMs: 5_000, - scheduledAt: Date.now(), - }, - }); - let settleOptOut!: () => void; - setAutoRetryEnabled.mockImplementationOnce( - () => - new Promise((resolve) => { - settleOptOut = () => - resolve({ success: true as const, data: { previousEnabled: true, enabled: false } }); - }) - ); - - const view = render(); - fireEvent.click(view.getByRole("button", { name: /^Stop/ })); - await Promise.resolve(); - - expect(setAutoRetryEnabled).toHaveBeenCalledTimes(1); - expect(interruptStream).not.toHaveBeenCalled(); - - settleOptOut(); - await waitFor(() => expect(interruptStream).toHaveBeenCalledTimes(1)); - }); - - test("the Stop button issues the same attention-retiring Stop as its shortcut", async () => { + test("the Stop button opts out of auto-retry inside the same attention-retiring Stop as its shortcut", async () => { currentWorkspaceState = createWorkspaceState({ autoRetryStatus: { type: "auto-retry-scheduled", @@ -441,11 +412,12 @@ describe("RetryBarrier", () => { fireEvent.click(view.getByRole("button", { name: /^Stop/ })); - expect(setAutoRetryEnabled).toHaveBeenCalledWith({ workspaceId: "ws-1", enabled: false }); await waitFor(() => expect(interruptStream).toHaveBeenCalledTimes(1)); expect(interruptStream).toHaveBeenCalledWith({ workspaceId: "ws-1", - options: { retireBashMonitorAttention: true }, + options: { disableAutoRetry: true, retireBashMonitorAttention: true }, }); + // No separate opt-out call: it would release the retry idle gate ahead of the Stop. + expect(setAutoRetryEnabled).not.toHaveBeenCalled(); }); }); diff --git a/src/browser/features/Messages/ChatBarrier/StreamingBarrier.test.tsx b/src/browser/features/Messages/ChatBarrier/StreamingBarrier.test.tsx index 74424cbd2f3..fa6cd89c640 100644 --- a/src/browser/features/Messages/ChatBarrier/StreamingBarrier.test.tsx +++ b/src/browser/features/Messages/ChatBarrier/StreamingBarrier.test.tsx @@ -175,14 +175,14 @@ describe("StreamingBarrier", () => { fireEvent.click(view.getByRole("button", { name: "Stop streaming" })); - expect(setAutoRetryEnabled).toHaveBeenCalledWith({ workspaceId: "ws-1", enabled: false }); expect(setInterrupting).toHaveBeenCalledWith("ws-1"); await waitFor(() => expect(interruptStream).toHaveBeenCalledWith({ workspaceId: "ws-1", - options: { retireBashMonitorAttention: true }, + options: { disableAutoRetry: true, retireBashMonitorAttention: true }, }) ); + expect(setAutoRetryEnabled).not.toHaveBeenCalled(); }); test("clicking stop during stream-start interrupts without setting interrupting state", async () => { @@ -200,12 +200,11 @@ describe("StreamingBarrier", () => { fireEvent.click(stopButton); - expect(setAutoRetryEnabled).toHaveBeenCalledWith({ workspaceId: "ws-1", enabled: false }); expect(setInterrupting).not.toHaveBeenCalled(); await waitFor(() => expect(interruptStream).toHaveBeenCalledWith({ workspaceId: "ws-1", - options: { retireBashMonitorAttention: true }, + options: { disableAutoRetry: true, retireBashMonitorAttention: true }, }) ); }); @@ -385,12 +384,11 @@ describe("StreamingBarrier", () => { fireEvent.click(view.getByRole("button", { name: "Stop streaming" })); - expect(setAutoRetryEnabled).toHaveBeenCalledWith({ workspaceId: "ws-1", enabled: false }); expect(setInterrupting).not.toHaveBeenCalled(); await waitFor(() => expect(interruptStream).toHaveBeenCalledWith({ workspaceId: "ws-1", - options: { abandonPartial: true, retireBashMonitorAttention: true }, + options: { abandonPartial: true, disableAutoRetry: true, retireBashMonitorAttention: true }, }) ); }); diff --git a/src/browser/hooks/useAIViewKeybinds.test.tsx b/src/browser/hooks/useAIViewKeybinds.test.tsx index 3c20f10abc3..9eff8111663 100644 --- a/src/browser/hooks/useAIViewKeybinds.test.tsx +++ b/src/browser/hooks/useAIViewKeybinds.test.tsx @@ -252,19 +252,15 @@ describe("useAIViewKeybinds", () => { await waitFor(() => expect(interruptStream.mock.calls.length).toBe(1)); }); - test("Escape on the retry barrier issues Stop only after the retry opt-out has landed", async () => { + test("Escape on the retry barrier opts out of auto-retry inside the Stop itself", async () => { const interruptStream = mock(() => Promise.resolve({ success: true as const, data: undefined }) ); - let settleOptOut!: () => void; - const setAutoRetryEnabled = mock( - () => - new Promise<{ success: true; data: { previousEnabled: boolean; enabled: boolean } }>( - (resolve) => { - settleOptOut = () => - resolve({ success: true, data: { previousEnabled: true, enabled: false } }); - } - ) + const setAutoRetryEnabled = mock(() => + Promise.resolve({ + success: true as const, + data: { previousEnabled: true, enabled: false }, + }) ); currentClientMock = { workspace: { @@ -296,13 +292,13 @@ describe("useAIViewKeybinds", () => { cancelable: true, }) ); - await Promise.resolve(); - - expect(setAutoRetryEnabled).toHaveBeenCalledWith({ workspaceId: "ws", enabled: false }); - expect(interruptStream.mock.calls.length).toBe(0); - settleOptOut(); await waitFor(() => expect(interruptStream.mock.calls.length).toBe(1)); + expect(interruptStream).toHaveBeenCalledWith({ + workspaceId: "ws", + options: { disableAutoRetry: true, retireBashMonitorAttention: true }, + }); + expect(setAutoRetryEnabled).not.toHaveBeenCalled(); }); test.each([ diff --git a/src/browser/utils/compaction/handler.test.ts b/src/browser/utils/compaction/handler.test.ts index c590907ba8e..bafbb63ffb4 100644 --- a/src/browser/utils/compaction/handler.test.ts +++ b/src/browser/utils/compaction/handler.test.ts @@ -62,7 +62,7 @@ describe("cancelCompaction", () => { }); expect(interruptStream).toHaveBeenCalledWith({ workspaceId: "ws-1", - options: { abandonPartial: true, retireBashMonitorAttention: true }, + options: { abandonPartial: true, disableAutoRetry: true, retireBashMonitorAttention: true }, }); expect(calls).toEqual(["edit", "interrupt"]); }); diff --git a/src/browser/utils/stopStream.test.ts b/src/browser/utils/stopStream.test.ts index 3da1e3a1748..33f9ec7b151 100644 --- a/src/browser/utils/stopStream.test.ts +++ b/src/browser/utils/stopStream.test.ts @@ -31,44 +31,16 @@ describe("stopStream", () => { expect(peekChatError("ws-unrecorded")).toBeUndefined(); }); - test("disableAutoRetry lands the opt-out, and its failure, before the Stop is issued", async () => { - const calls: string[] = []; - let settleOptOut!: () => void; - const api = { - workspace: { - setAutoRetryEnabled: () => { - calls.push("opt-out"); - return new Promise((resolve) => { - settleOptOut = () => resolve({ success: false, error: "preference unwritable" }); - }); - }, - interruptStream: () => { - calls.push("interrupt"); - return Promise.resolve({ success: true, data: undefined }); - }, - }, - } as unknown as APIClient; - - const stopping = stopStream(api, "ws-opt-out", { disableAutoRetry: true }); - await Promise.resolve(); - expect(calls).toEqual(["opt-out"]); - - settleOptOut(); - await stopping; - expect(calls).toEqual(["opt-out", "interrupt"]); - expect(peekChatError("ws-opt-out")).toBe("preference unwritable"); - dismissChatError("ws-opt-out", "preference unwritable"); - }); - test("a recorded Stop retires owed monitor output without a chat error", async () => { const { api, calls } = apiReturning({ success: true, data: undefined }); - await stopStream(api, "ws-recorded", { abandonPartial: true }); + // The retry opt-out is part of the same Stop, never a separate call ahead of it. + await stopStream(api, "ws-recorded", { abandonPartial: true, disableAutoRetry: true }); expect(calls).toEqual([ { workspaceId: "ws-recorded", - options: { abandonPartial: true, retireBashMonitorAttention: true }, + options: { abandonPartial: true, disableAutoRetry: true, retireBashMonitorAttention: true }, }, ]); expect(peekChatError("ws-recorded")).toBeUndefined(); diff --git a/src/browser/utils/stopStream.ts b/src/browser/utils/stopStream.ts index c52b233ce91..777d4b66f8a 100644 --- a/src/browser/utils/stopStream.ts +++ b/src/browser/utils/stopStream.ts @@ -1,33 +1,23 @@ import type { APIClient } from "@/browser/contexts/API"; import { publishChatError } from "@/browser/utils/chatErrorToasts"; -import { getErrorMessage } from "@/common/utils/errors"; /** * User Stop: interrupts the stream and dismisses owed background monitor output instead of letting * it wake the agent. A Stop the backend could not record on disk may resume on restart, so its * failure is shown in the workspace's chat input rather than dropped with the Result. * - * `disableAutoRetry` lands the retry opt-out before the Stop: the Stop is acknowledged only once - * the session's auto-retry state is on disk, so an opt-out still in flight would escape that check - * and a trailing row could replay on restart. + * `disableAutoRetry` rides inside the Stop rather than as a separate call: the backend persists + * the opt-out after retiring monitor attention and before acknowledging, so it can neither escape + * the Stop's durability check nor release the retry idle gate to a pending wake. */ export async function stopStream( api: APIClient, workspaceId: string, options?: { abandonPartial?: boolean; disableAutoRetry?: boolean } ): Promise { - const { disableAutoRetry, ...interruptOptions } = options ?? {}; - if (disableAutoRetry) { - try { - const optOut = await api.workspace.setAutoRetryEnabled?.({ workspaceId, enabled: false }); - if (optOut != null && !optOut.success) publishChatError(workspaceId, optOut.error); - } catch (error) { - publishChatError(workspaceId, getErrorMessage(error)); - } - } const result = await api.workspace.interruptStream({ workspaceId, - options: { ...interruptOptions, retireBashMonitorAttention: true }, + options: { ...options, retireBashMonitorAttention: true }, }); if (!result.success) { publishChatError(workspaceId, result.error); diff --git a/src/common/orpc/schemas/api.ts b/src/common/orpc/schemas/api.ts index 28d645d09ef..0e6184477d8 100644 --- a/src/common/orpc/schemas/api.ts +++ b/src/common/orpc/schemas/api.ts @@ -1657,6 +1657,9 @@ export const workspace = { // User Stop only: owed bash-monitor attention is dismissed instead of waking the // agent on the output it just stopped around. retireBashMonitorAttention: z.boolean().optional(), + // Persist the auto-retry opt-out inside the Stop, after attention retirement is + // reserved and before the Stop is acknowledged. + disableAutoRetry: z.boolean().optional(), }) .optional(), }), diff --git a/src/node/services/workspaceService.test.ts b/src/node/services/workspaceService.test.ts index 66aa1f8baad..d632a9b8204 100644 --- a/src/node/services/workspaceService.test.ts +++ b/src/node/services/workspaceService.test.ts @@ -523,6 +523,19 @@ describe("WorkspaceService bash monitor wake reconciler wiring", () => { expect(h.internal.pendingBashMonitorWakeIdleWaitsByOwner.has(h.workspaceId)).toBe(false); expect((await h.reconciler.snapshot(h.workspaceId)).pendingWakeKinds.size).toBe(2); + // An unarchive whose restoration fails rolls back to archived; the wake must not have run + // against the half-restored checkout in between. + const snapshots = h.internal as unknown as { + worktreeArchiveSnapshotService?: { restoreSnapshotAfterUnarchive(): Promise }; + }; + snapshots.worktreeArchiveSnapshotService = { + restoreSnapshotAfterUnarchive: () => Promise.resolve(Err("restore failed")), + }; + expect((await h.service.unarchive(h.workspaceId)).success).toBe(false); + await h.reconciler.reconcile(h.workspaceId); + expect(send).not.toHaveBeenCalled(); + + snapshots.worktreeArchiveSnapshotService = undefined; expect((await h.service.unarchive(h.workspaceId)).success).toBe(true); await h.reconciler.reconcile(h.workspaceId); expect(h.requests).toHaveLength(1); @@ -910,6 +923,41 @@ describe("WorkspaceService bash monitor wake reconciler wiring", () => { } }); + test("a Stop that disables auto-retry withdraws the wake before releasing the retry gate", async () => { + const h = await createActiveWakeHarness(); + try { + let wakeWithdrawnAtOptOut: boolean | undefined; + const optOut = h.session.setAutoRetryEnabled.bind(h.session); + spyOn(h.session, "setAutoRetryEnabled").mockImplementation(async (enabled, options) => { + // The opt-out releases the idle gate a pending wake waits behind; retirement must already + // have withdrawn the wake's dispatch by then. + wakeWithdrawnAtOptOut = h.dispatch.mock.calls[0]?.[0].cancelSignal.aborted; + return optOut(enabled, options); + }); + let stop: Promise> | undefined; + const unsubscribe = h.session.onChatEvent(({ message: event }) => { + if (event.type === "message" && event.role === "user" && stop == null) { + stop = h.service.interruptStream(h.workspaceId, { + retireBashMonitorAttention: true, + disableAutoRetry: true, + }); + } + }); + await h.addAttention(10); + unsubscribe(); + expect((await stop!).success).toBe(true); + expect(wakeWithdrawnAtOptOut).toBe(true); + expect(h.requests).toHaveLength(0); + const sessionInternal = h.session as unknown as { getAutoRetryPreferencePath(): string }; + const persisted = JSON.parse( + await fsPromises.readFile(sessionInternal.getAutoRetryPreferencePath(), "utf-8") + ) as { enabled?: boolean }; + expect(persisted.enabled).toBe(false); + } finally { + await h.finish(); + } + }); + test("hard Stop during a wake's acceptance window is acknowledged only once the wake's abandon marker is durable", async () => { const h = await createActiveWakeHarness(); const release = createDeferred(); diff --git a/src/node/services/workspaceService.ts b/src/node/services/workspaceService.ts index f0059e7864c..71a3398e3c5 100644 --- a/src/node/services/workspaceService.ts +++ b/src/node/services/workspaceService.ts @@ -9019,8 +9019,6 @@ export class WorkspaceService extends EventEmitter implements WorkspaceHost { if (!didUnarchive) { return Ok(undefined); } - // Monitor attention held while archived (see dispatchBashMonitorWake) wakes now. - this.scheduleBashMonitorWakeReconcile(workspaceId); // Emit updated metadata const allMetadata = await this.config.getAllWorkspaceMetadata(); @@ -9101,6 +9099,10 @@ export class WorkspaceService extends EventEmitter implements WorkspaceHost { subProjectPath: hookMetadata?.subProjectPath, }); + // Monitor attention held while archived (see dispatchBashMonitorWake) wakes now, at the + // same point as the workflow reconciliation below and for the same reason. + this.scheduleBashMonitorWakeReconcile(workspaceId); + // Archived owners park workflow terminal wakes unsettled; reconcile so an idle // workspace does not stay silent until the interval sweep. Only AFTER snapshot // restoration and lifecycle startup above: the drain can admit a synthetic agent turn, @@ -11718,6 +11720,7 @@ export class WorkspaceService extends EventEmitter implements WorkspaceHost { abandonPartial?: boolean; sendQueuedImmediately?: boolean; retireBashMonitorAttention?: boolean; + disableAutoRetry?: boolean; } ): Promise> { let releaseHardStopLatch: (() => void) | undefined; @@ -11769,6 +11772,18 @@ export class WorkspaceService extends EventEmitter implements WorkspaceHost { }); }) : undefined; + // The opt-out lands only after retirement reserved the reconciler lock above: disabling + // retry releases the idle gate a pending wake may be waiting behind, and that wake must + // find its attention withdrawn, not a window to start a turn after the user's Stop. Its + // write is verified below with the abandon marker; a failure there fails the Stop. + const disabling = options?.disableAutoRetry === true; + if (disabling) { + try { + await session.setAutoRetryEnabled(false); + } catch (error) { + log.warn("Failed to disable auto-retry during Stop", { workspaceId, error }); + } + } let stopResult: Result | undefined; try { stopResult = await session.interruptStream(options); @@ -11786,7 +11801,8 @@ export class WorkspaceService extends EventEmitter implements WorkspaceHost { // Stop, so the obligation is not lost with the joined send. await withdrawnWakeSend?.catch(() => undefined); const stopRecorded = - !retiring || ((await session.recordPendingAutoRetryState()) && retirementRecorded); + !(retiring || disabling) || + ((await session.recordPendingAutoRetryState()) && retirementRecorded); if (!stopResult.success) { // Interrupt failed, so clear hard-interrupt suppression we set above. if (!options?.soft) { From ebce8372bea496296ce73cdd635e633b15042e79 Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Mon, 7 Sep 2026 16:56:44 +0000 Subject: [PATCH 29/31] Interrupt before the opt-out write lands; scan the transcript only for recovered processes The Stop's session interrupt no longer waits on the auto-retry opt-out's disk write (a stuck write kept the stream running); the write starts after retirement is reserved, runs alongside the interrupt, and is joined before the durability check. stopStream also publishes a transport rejection as the workspace's chat error instead of letting a void-called Stop reject unseen. The reconciler consults the transcript only for processes created before this instance: a failed acceptance from this instance stays owed in memory, so a long-lived monitor no longer triggers a history scan for every new match. Unarchive schedules the held monitor reconcile in a finally around the post-restoration follow-ups, so a follow-up that throws after unarchivedAt is persisted cannot strand the attention behind the !didUnarchive early return. --- src/browser/utils/stopStream.test.ts | 13 ++++ src/browser/utils/stopStream.ts | 16 +++-- .../bashMonitorWakeReconciler.test.ts | 33 +++++++++ .../services/bashMonitorWakeReconciler.ts | 17 +++-- src/node/services/workspaceService.test.ts | 49 ++++++++++++- src/node/services/workspaceService.ts | 68 ++++++++++--------- 6 files changed, 149 insertions(+), 47 deletions(-) diff --git a/src/browser/utils/stopStream.test.ts b/src/browser/utils/stopStream.test.ts index 33f9ec7b151..66485cba5db 100644 --- a/src/browser/utils/stopStream.test.ts +++ b/src/browser/utils/stopStream.test.ts @@ -31,6 +31,19 @@ describe("stopStream", () => { expect(peekChatError("ws-unrecorded")).toBeUndefined(); }); + test("a Stop whose request fails in transport is retained as the workspace's chat error", async () => { + const api = { + workspace: { + interruptStream: () => Promise.reject(new Error("backend unreachable")), + }, + } as unknown as APIClient; + + await stopStream(api, "ws-transport"); + + expect(peekChatError("ws-transport")).toBe("backend unreachable"); + dismissChatError("ws-transport", "backend unreachable"); + }); + test("a recorded Stop retires owed monitor output without a chat error", async () => { const { api, calls } = apiReturning({ success: true, data: undefined }); diff --git a/src/browser/utils/stopStream.ts b/src/browser/utils/stopStream.ts index 777d4b66f8a..4f65418833f 100644 --- a/src/browser/utils/stopStream.ts +++ b/src/browser/utils/stopStream.ts @@ -1,5 +1,6 @@ import type { APIClient } from "@/browser/contexts/API"; import { publishChatError } from "@/browser/utils/chatErrorToasts"; +import { getErrorMessage } from "@/common/utils/errors"; /** * User Stop: interrupts the stream and dismisses owed background monitor output instead of letting @@ -15,11 +16,14 @@ export async function stopStream( workspaceId: string, options?: { abandonPartial?: boolean; disableAutoRetry?: boolean } ): Promise { - const result = await api.workspace.interruptStream({ - workspaceId, - options: { ...options, retireBashMonitorAttention: true }, - }); - if (!result.success) { - publishChatError(workspaceId, result.error); + try { + const result = await api.workspace.interruptStream({ + workspaceId, + options: { ...options, retireBashMonitorAttention: true }, + }); + if (!result.success) publishChatError(workspaceId, result.error); + } catch (error) { + // A transport failure (backend gone mid-click) is as invisible as an Err without this. + publishChatError(workspaceId, getErrorMessage(error)); } } diff --git a/src/node/services/bashMonitorWakeReconciler.test.ts b/src/node/services/bashMonitorWakeReconciler.test.ts index 047c1e015dc..f6382757f65 100644 --- a/src/node/services/bashMonitorWakeReconciler.test.ts +++ b/src/node/services/bashMonitorWakeReconciler.test.ts @@ -494,6 +494,39 @@ describe("BashMonitorWakeReconciler", () => { expect(dispatches).toHaveLength(2); }); + test("a process created in this instance never triggers a transcript scan", async () => { + let transcriptReads = 0; + const fresh = new BashMonitorWakeReconciler({ + sessionsDir: root, + processManager: { + pullMonitorWakeSignals: () => live, + getMonitorWakeDeliveryState: () => Promise.resolve(deliveryState), + acknowledgeMonitorWake: () => undefined, + dropRetiredMonitor: () => undefined, + }, + registry: { + listAll: () => Promise.resolve(rows), + remove: () => undefined, + recordTerminal: () => undefined, + }, + deliveredWakes: () => { + transcriptReads++; + return Promise.resolve(transcript); + }, + onWake: (dispatch) => { + dispatches.push(dispatch); + return "in-flight"; + }, + }); + // A failed acceptance from this instance stays owed in memory, so only processes older than + // the instance can have a delivered row the reconciler does not remember. + live = [liveSnapshot({ createdAt: new Date(Date.now() + 1_000).toISOString() })]; + await fresh.reconcile(OWNER); + expect(dispatches).toHaveLength(1); + expect(transcriptReads).toBe(0); + await fresh.dispose(OWNER); + }); + test("a wake the transcript already carries is consumed after restart, not redelivered", async () => { live = [liveSnapshot()]; await reconciler.reconcile(OWNER); diff --git a/src/node/services/bashMonitorWakeReconciler.ts b/src/node/services/bashMonitorWakeReconciler.ts index f67278b8a60..4605f61b2e0 100644 --- a/src/node/services/bashMonitorWakeReconciler.ts +++ b/src/node/services/bashMonitorWakeReconciler.ts @@ -380,6 +380,8 @@ export class BashMonitorWakeReconciler { private readonly retryTimers = new Map(); private readonly retryAttempts = new Map(); private readonly defunctWorkspaces = new Set(); + /** Processes created before this instance can carry acceptances that lived only in a previous one. */ + private readonly constructedAt = new Date().toISOString(); constructor( private readonly args: { @@ -664,7 +666,9 @@ export class BashMonitorWakeReconciler { * Outstanding signals whose wake row the transcript already carries. An acceptance whose * consumption I/O kept failing until the app exited leaves the durable row as the only record * of delivery; on the next run the signal derives as outstanding again and is consumed here - * instead of redelivered. Only this reconciler's own accepts add wake rows, so each outstanding + * instead of redelivered. Only processes older than this instance can be in that position (a + * failed acceptance from this instance stays owed in memory), so live monitors never trigger a + * history scan per match. Only this reconciler's own accepts add wake rows, so each outstanding * key is looked up once and the result holds until the key leaves the outstanding set. */ private async deliveredSignals( @@ -675,10 +679,11 @@ export class BashMonitorWakeReconciler { const keyOf = (signal: DerivedSignal) => wakeKey(signal.processId, wakeUpdatedAt(signal)); const checked = state.transcriptChecked ?? new Set(); let delivered: DerivedSignal[] = []; - if (signals.some((signal) => !checked.has(keyOf(signal)))) { - const since = signals.reduce( + const recovered = signals.filter((signal) => signal.createdAt < this.constructedAt); + if (recovered.some((signal) => !checked.has(keyOf(signal)))) { + const since = recovered.reduce( (oldest, signal) => (signal.createdAt < oldest ? signal.createdAt : oldest), - signals[0].createdAt + recovered[0].createdAt ); const rows = await this.args.deliveredWakes(ownerWorkspaceId, since); const inTranscript = new Set( @@ -688,10 +693,10 @@ export class BashMonitorWakeReconciler { : [] ) ); - delivered = signals.filter((signal) => inTranscript.has(keyOf(signal))); + delivered = recovered.filter((signal) => inTranscript.has(keyOf(signal))); } state.transcriptChecked = new Set( - signals.filter((signal) => !delivered.includes(signal)).map(keyOf) + recovered.filter((signal) => !delivered.includes(signal)).map(keyOf) ); return delivered; } diff --git a/src/node/services/workspaceService.test.ts b/src/node/services/workspaceService.test.ts index d632a9b8204..e1b4d86f8e1 100644 --- a/src/node/services/workspaceService.test.ts +++ b/src/node/services/workspaceService.test.ts @@ -536,9 +536,17 @@ describe("WorkspaceService bash monitor wake reconciler wiring", () => { expect(send).not.toHaveBeenCalled(); snapshots.worktreeArchiveSnapshotService = undefined; - expect((await h.service.unarchive(h.workspaceId)).success).toBe(true); - await h.reconciler.reconcile(h.workspaceId); - expect(h.requests).toHaveLength(1); + // Restoration succeeds but a follow-up step throws: unarchivedAt is already persisted and a + // retried unarchive would not run the hooks again, so the held attention must still wake. + spyOn( + h.internal as unknown as { syncCodeWorkspaceFiles(): Promise }, + "syncCodeWorkspaceFiles" + ).mockImplementationOnce(() => { + throw new Error("sync failed"); + }); + expect((await h.service.unarchive(h.workspaceId)).success).toBe(false); + // Unarchive itself schedules the reconcile; no manual reconcile here. + await waitForCondition(() => h.requests.length === 1); } finally { await h.finish(); } @@ -923,6 +931,41 @@ describe("WorkspaceService bash monitor wake reconciler wiring", () => { } }); + test("a Stop that disables auto-retry interrupts the stream without waiting on the opt-out write", async () => { + const h = await createActiveWakeHarness(); + const release = createDeferred(); + try { + await h.session.sendMessage("original", { model: h.model, agentId: "exec" }); + const stopStream = spyOn(h.aiService, "stopStream").mockImplementation(async () => { + h.abort("user"); + await h.session.waitForIdle(); + return Ok(undefined); + }); + const optOut = h.session.setAutoRetryEnabled.bind(h.session); + spyOn(h.session, "setAutoRetryEnabled").mockImplementation(async (enabled, options) => { + await release.promise; + return optOut(enabled, options); + }); + const stop = h.service.interruptStream(h.workspaceId, { + retireBashMonitorAttention: true, + disableAutoRetry: true, + }); + // The abort reaches the stream while the preference write is still pending. + await waitForCondition(() => stopStream.mock.calls.length === 1); + let stopSettled = false; + void stop.then(() => { + stopSettled = true; + }); + await new Promise((resolve) => setTimeout(resolve, 20)); + expect(stopSettled).toBe(false); + release.resolve(); + expect((await stop).success).toBe(true); + } finally { + release.resolve(); + await h.finish(); + } + }); + test("a Stop that disables auto-retry withdraws the wake before releasing the retry gate", async () => { const h = await createActiveWakeHarness(); try { diff --git a/src/node/services/workspaceService.ts b/src/node/services/workspaceService.ts index 71a3398e3c5..c410d822dfb 100644 --- a/src/node/services/workspaceService.ts +++ b/src/node/services/workspaceService.ts @@ -9078,30 +9078,34 @@ export class WorkspaceService extends EventEmitter implements WorkspaceHost { } } - // Lifecycle hooks run *after* we persist unarchivedAt. - // - // Why best-effort: Unarchive is a quick UI action and should not fail permanently due to a - // start error (e.g., Coder workspace start). - if (this.workspaceLifecycleHooks && hookMetadata) { - await this.workspaceLifecycleHooks.runAfterUnarchive({ - workspaceId, - workspaceMetadata: hookMetadata, - }); - } - - if (this.workspaceLifecycleHooks || this.worktreeArchiveSnapshotService) { - await this.emitCurrentWorkspaceMetadata(workspaceId); - } + // Restoration succeeded, so the unarchive is final from here: monitor attention held while + // archived (see dispatchBashMonitorWake) wakes after lifecycle startup below, and still + // wakes when a follow-up step throws, since a retried unarchive would take the + // !didUnarchive exit and never reach this point again. + try { + // Lifecycle hooks run *after* we persist unarchivedAt. + // + // Why best-effort: Unarchive is a quick UI action and should not fail permanently due to a + // start error (e.g., Coder workspace start). + if (this.workspaceLifecycleHooks && hookMetadata) { + await this.workspaceLifecycleHooks.runAfterUnarchive({ + workspaceId, + workspaceMetadata: hookMetadata, + }); + } - await this.syncCodeWorkspaceFiles({ - projectPath, - projects: hookMetadata?.projects, - subProjectPath: hookMetadata?.subProjectPath, - }); + if (this.workspaceLifecycleHooks || this.worktreeArchiveSnapshotService) { + await this.emitCurrentWorkspaceMetadata(workspaceId); + } - // Monitor attention held while archived (see dispatchBashMonitorWake) wakes now, at the - // same point as the workflow reconciliation below and for the same reason. - this.scheduleBashMonitorWakeReconcile(workspaceId); + await this.syncCodeWorkspaceFiles({ + projectPath, + projects: hookMetadata?.projects, + subProjectPath: hookMetadata?.subProjectPath, + }); + } finally { + this.scheduleBashMonitorWakeReconcile(workspaceId); + } // Archived owners park workflow terminal wakes unsettled; reconcile so an idle // workspace does not stay silent until the interval sweep. Only AFTER snapshot @@ -11772,18 +11776,17 @@ export class WorkspaceService extends EventEmitter implements WorkspaceHost { }); }) : undefined; - // The opt-out lands only after retirement reserved the reconciler lock above: disabling + // The opt-out starts only after retirement reserved the reconciler lock above: disabling // retry releases the idle gate a pending wake may be waiting behind, and that wake must - // find its attention withdrawn, not a window to start a turn after the user's Stop. Its - // write is verified below with the abandon marker; a failure there fails the Stop. + // find its attention withdrawn, not a window to start a turn after the user's Stop. The + // interrupt does not wait for the opt-out's disk write (a stuck write must not keep the + // stream running); the write is joined and verified below, and a failure fails the Stop. const disabling = options?.disableAutoRetry === true; - if (disabling) { - try { - await session.setAutoRetryEnabled(false); - } catch (error) { - log.warn("Failed to disable auto-retry during Stop", { workspaceId, error }); - } - } + const optOut = disabling + ? session.setAutoRetryEnabled(false).catch((error: unknown) => { + log.warn("Failed to disable auto-retry during Stop", { workspaceId, error }); + }) + : undefined; let stopResult: Result | undefined; try { stopResult = await session.interruptStream(options); @@ -11791,6 +11794,7 @@ export class WorkspaceService extends EventEmitter implements WorkspaceHost { settleStop(stopResult?.success === true); } await retirement; + await optOut; // A wake withdrawn past its point of no return (durable row, not yet PREPARING, so the // session interrupt above saw idle) records the startup abandon marker for that row on every // exit before it resolves, including a failed goal sync or acceptance (see From ff3a832866e13f4b3150122a0ab883922d13b9b2 Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Mon, 7 Sep 2026 17:28:31 +0000 Subject: [PATCH 30/31] Parse process ages before bounding the transcript scan; an unparseable age counts as recovered --- .../bashMonitorWakeReconciler.test.ts | 20 +++++++++++++++ .../services/bashMonitorWakeReconciler.ts | 25 +++++++++++-------- src/node/services/workspaceService.ts | 11 ++++---- 3 files changed, 40 insertions(+), 16 deletions(-) diff --git a/src/node/services/bashMonitorWakeReconciler.test.ts b/src/node/services/bashMonitorWakeReconciler.test.ts index f6382757f65..d9cd3f07d8c 100644 --- a/src/node/services/bashMonitorWakeReconciler.test.ts +++ b/src/node/services/bashMonitorWakeReconciler.test.ts @@ -527,6 +527,26 @@ describe("BashMonitorWakeReconciler", () => { await fresh.dispose(OWNER); }); + test.each(["9", "not-a-date"])( + "a recovered process with the noncanonical age %j is still checked against the transcript", + async (createdAt) => { + // The registry accepts any createdAt string, and both sort after an ISO instance stamp: a string + // comparison would take the process for a live one and redeliver the wake its row already holds. + live = [liveSnapshot({ createdAt })]; + transcript.push({ + processId: "proc", + wakeUpdatedAt: createdAt + ":12", + kind: "match", + displayName: "CI watcher", + filter: "READY", + filterExclude: false, + }); + await reconciler.reconcile(OWNER); + expect(dispatches).toEqual([]); + expect(acknowledged).toEqual([{ processId: "proc", matchedThroughOffset: 12 }]); + } + ); + test("a wake the transcript already carries is consumed after restart, not redelivered", async () => { live = [liveSnapshot()]; await reconciler.reconcile(OWNER); diff --git a/src/node/services/bashMonitorWakeReconciler.ts b/src/node/services/bashMonitorWakeReconciler.ts index 4605f61b2e0..beca2c6e340 100644 --- a/src/node/services/bashMonitorWakeReconciler.ts +++ b/src/node/services/bashMonitorWakeReconciler.ts @@ -381,7 +381,7 @@ export class BashMonitorWakeReconciler { private readonly retryAttempts = new Map(); private readonly defunctWorkspaces = new Set(); /** Processes created before this instance can carry acceptances that lived only in a previous one. */ - private readonly constructedAt = new Date().toISOString(); + private readonly constructedAtMs = Date.now(); constructor( private readonly args: { @@ -389,12 +389,13 @@ export class BashMonitorWakeReconciler { processManager: BashMonitorWakeReconcilerProcessManager; registry: BashMonitorWakeReconcilerRegistry; /** - * Wake identities the owner's transcript carries in rows stamped at or after `since`, the - * creation time of the oldest process being checked. A rejection holds dispatch. + * Wake identities the owner's transcript carries in rows stamped at or after `sinceMs`, the + * creation time of the oldest process being checked (-Infinity when an age is unparseable). + * A rejection holds dispatch. */ deliveredWakes( ownerWorkspaceId: string, - since: string + sinceMs: number ): Promise; onWake( dispatch: BashMonitorWakeDispatch @@ -679,13 +680,17 @@ export class BashMonitorWakeReconciler { const keyOf = (signal: DerivedSignal) => wakeKey(signal.processId, wakeUpdatedAt(signal)); const checked = state.transcriptChecked ?? new Set(); let delivered: DerivedSignal[] = []; - const recovered = signals.filter((signal) => signal.createdAt < this.constructedAt); + // Persisted ages are unvalidated strings: compare parsed times and, like startup recovery, + // count an unparseable age as recovered rather than let it sort past the instance stamp. + const createdAtMs = (signal: DerivedSignal) => Date.parse(signal.createdAt); + const recovered = signals.filter((signal) => { + const ms = createdAtMs(signal); + return !Number.isFinite(ms) || ms < this.constructedAtMs; + }); if (recovered.some((signal) => !checked.has(keyOf(signal)))) { - const since = recovered.reduce( - (oldest, signal) => (signal.createdAt < oldest ? signal.createdAt : oldest), - recovered[0].createdAt - ); - const rows = await this.args.deliveredWakes(ownerWorkspaceId, since); + const ages = recovered.map(createdAtMs); + const sinceMs = ages.every(Number.isFinite) ? Math.min(...ages) : -Infinity; + const rows = await this.args.deliveredWakes(ownerWorkspaceId, sinceMs); const inTranscript = new Set( rows.flatMap((row) => row.processId != null && row.wakeUpdatedAt != null diff --git a/src/node/services/workspaceService.ts b/src/node/services/workspaceService.ts index c410d822dfb..9d197921511 100644 --- a/src/node/services/workspaceService.ts +++ b/src/node/services/workspaceService.ts @@ -2402,8 +2402,8 @@ export class WorkspaceService extends EventEmitter implements WorkspaceHost { : undefined, }, registry: this.bashMonitorRegistryStore, - deliveredWakes: (ownerWorkspaceId, since) => - this.listDeliveredBashMonitorWakes(ownerWorkspaceId, since), + deliveredWakes: (ownerWorkspaceId, sinceMs) => + this.listDeliveredBashMonitorWakes(ownerWorkspaceId, sinceMs), onWake: (dispatch) => this.dispatchBashMonitorWake(dispatch), }); if (typeof this.backgroundProcessManager.on === "function") { @@ -2565,16 +2565,15 @@ export class WorkspaceService extends EventEmitter implements WorkspaceHost { } /** - * Wake records in transcript rows written since `since`, scanning newest-first across the + * Wake records in transcript rows written since `sinceMs`, scanning newest-first across the * compaction archive too because a delivered row stays proof of delivery after it leaves the * window the model sees. A failed read rejects so the reconciler holds dispatch in its retry * backoff instead of risking a duplicate row. */ private async listDeliveredBashMonitorWakes( ownerWorkspaceId: string, - since: string + sinceMs: number ): Promise { - const oldest = Date.parse(since); const records: DeliveredWakeRecord[] = []; const result = await this.historyService.iterateFullHistory( ownerWorkspaceId, @@ -2608,7 +2607,7 @@ export class WorkspaceService extends EventEmitter implements WorkspaceHost { } } const timestamp = message.metadata?.timestamp; - if (!(typeof timestamp === "number" && timestamp < oldest)) predatesAll = false; + if (!(typeof timestamp === "number" && timestamp < sinceMs)) predatesAll = false; } return !predatesAll; } From ed66d65cfcf6f8593f22694c046ffd3298aa89de Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Mon, 7 Sep 2026 19:41:53 +0000 Subject: [PATCH 31/31] Gate post-acceptance withdrawal behind withdrawAcceptedOnCancel, set only by bash-monitor wakes main (#4097) codifies that a late cancelSignal abort cannot revoke an accepted send; the wake dispatch opts into withdrawal so a Stop landing during acceptance or goal sync is still not followed by the wake's stream. --- .../agentSession.queueDispatch.test.ts | 25 ++++++++++++++--- src/node/services/agentSession.ts | 27 ++++++++++++------- src/node/services/taskWorkspaceSeam.ts | 2 ++ src/node/services/workspaceService.ts | 3 +++ 4 files changed, 45 insertions(+), 12 deletions(-) diff --git a/src/node/services/agentSession.queueDispatch.test.ts b/src/node/services/agentSession.queueDispatch.test.ts index 1ca32a7cf39..0fdd316ca9c 100644 --- a/src/node/services/agentSession.queueDispatch.test.ts +++ b/src/node/services/agentSession.queueDispatch.test.ts @@ -572,7 +572,11 @@ describe("AgentSession queued message tool-call dispatch", () => { session.queueMessage( "Background monitor wake", { model: TEST_MODEL, agentId: "exec", queueDispatchMode: "tool-end" }, - { synthetic: true, agentInitiated: true, cancelSignal: controller.signal } + { + synthetic: true, + agentInitiated: true, + cancelSignal: controller.signal, + } ); expect(session.hasQueuedMessages("tool-end")).toBe(true); @@ -616,7 +620,11 @@ describe("AgentSession queued message tool-call dispatch", () => { session.queueMessage( "Background monitor wake", { model: TEST_MODEL, agentId: "exec", queueDispatchMode: withdrawnMode }, - { synthetic: true, agentInitiated: true, cancelSignal: controller.signal } + { + synthetic: true, + agentInitiated: true, + cancelSignal: controller.signal, + } ); controller.abort("monitor withdrawn"); @@ -1008,6 +1016,7 @@ describe("AgentSession queued message tool-call dispatch", () => { agentInitiated: true, cancelState, cancelSignal: controller.signal, + withdrawAcceptedOnCancel: true, onCanceled: (reason) => { canceledReasons.push(reason); }, @@ -1092,6 +1101,7 @@ describe("AgentSession queued message tool-call dispatch", () => { agentInitiated: true, cancelState, cancelSignal: controller.signal, + withdrawAcceptedOnCancel: true, onCanceled: (reason) => { canceledReasons.push(reason); }, @@ -1177,6 +1187,7 @@ describe("AgentSession queued message tool-call dispatch", () => { agentInitiated: true, cancelState, cancelSignal: controller.signal, + withdrawAcceptedOnCancel: true, onCanceled: (reason) => { canceledReasons.push(reason); }, @@ -1271,6 +1282,7 @@ describe("AgentSession queued message tool-call dispatch", () => { synthetic: true, agentInitiated: true, cancelSignal: controller.signal, + withdrawAcceptedOnCancel: true, admissionStale: () => manualSendInPreflight, onAccepted: () => { accepted = true; @@ -1358,7 +1370,12 @@ describe("AgentSession queued message tool-call dispatch", () => { const sendPromise = session.sendMessage( "Background monitor wake", { model: TEST_MODEL, agentId: "exec" }, - { synthetic: true, agentInitiated: true, cancelSignal: controller.signal } + { + synthetic: true, + agentInitiated: true, + cancelSignal: controller.signal, + withdrawAcceptedOnCancel: true, + } ); await syncStarted; // A Stop withdraws the wake past the point of no return. @@ -1418,6 +1435,7 @@ describe("AgentSession queued message tool-call dispatch", () => { agentInitiated: true, cancelState, cancelSignal: controller.signal, + withdrawAcceptedOnCancel: true, onAccepted: () => { accepted = true; }, @@ -1476,6 +1494,7 @@ describe("AgentSession queued message tool-call dispatch", () => { agentInitiated: true, cancelState, cancelSignal: controller.signal, + withdrawAcceptedOnCancel: true, onCanceled: (reason) => { canceledReasons.push(reason); }, diff --git a/src/node/services/agentSession.ts b/src/node/services/agentSession.ts index d1dad98553f..c5daa218ecc 100644 --- a/src/node/services/agentSession.ts +++ b/src/node/services/agentSession.ts @@ -760,6 +760,13 @@ interface SendMessageInternalOptions { onCanceled?: (reason: string) => Promise | void; cancelState?: { canceledBeforeAcceptance: boolean }; cancelSignal?: AbortSignal; + /** + * Withdraw the send when `cancelSignal` aborts after its rows are durable but before PREPARING: + * resolve Ok without a stream and record the startup abandon marker for the row. By default a + * late abort cannot revoke an accepted send (r54). Bash-monitor wakes set this so a Stop that + * lands during acceptance or goal sync is not followed by the wake's stream. + */ + withdrawAcceptedOnCancel?: boolean; /** * For queue-dispatched sends: when the user last added to the queued * entry. Goal safety compares it against the goal's explicit @@ -4435,15 +4442,17 @@ export class AgentSession { if (cancelSignal != null) { cancellationDisabled = true; } - // A cancelable send withdrawn past the point of no return (a hard Stop retiring owed attention - // during goal sync or acceptance) keeps its durable, accepted rows but never streams: the Stop - // saw no turn to abort. The trailing UI-visible row would read as an interrupted turn to - // startup recovery, so every exit below that skips PREPARING records the same abandon marker a - // user-aborted stream leaves, before the send resolves (Stop joins the send for this). The - // withdrawal can land during any await on the way out, including acceptance I/O, so each exit - // runs this check after its last other await. + // A send that opted into withdrawal and is withdrawn past the point of no return (a hard Stop + // retiring owed attention during goal sync or acceptance) keeps its durable, accepted rows but + // never streams: the Stop saw no turn to abort. The trailing UI-visible row would read as an + // interrupted turn to startup recovery, so every exit below that skips PREPARING records the + // same abandon marker a user-aborted stream leaves, before the send resolves (Stop joins the + // send for this). The withdrawal can land during any await on the way out, including + // acceptance I/O, so each exit runs this check after its last other await. + const withdrawn = () => + internal?.withdrawAcceptedOnCancel === true && cancelSignal?.aborted === true; const abandonWithdrawnSend = async (): Promise => { - if (cancelSignal?.aborted === true) { + if (withdrawn()) { // Startup recovery matches the marker against the trailing durable row, which under on-send // compaction is the compaction request, not the never-persisted user message. await this.updateStartupAutoRetryAbandonFromAbort( @@ -4606,7 +4615,7 @@ export class AgentSession { } // A withdrawn send must not claim PREPARING (see abandonWithdrawnSend); it resolves Ok without // a stream, like cancelBeforeAcceptance and the disposed path above. - if (cancelSignal?.aborted === true) { + if (withdrawn()) { if (this.coordinator.thinkingOverride === turnThinkingOverride) { this.coordinator.releaseThinkingOverride(turnThinkingOverride); } diff --git a/src/node/services/taskWorkspaceSeam.ts b/src/node/services/taskWorkspaceSeam.ts index 72575991977..5d98fd0ae83 100644 --- a/src/node/services/taskWorkspaceSeam.ts +++ b/src/node/services/taskWorkspaceSeam.ts @@ -327,6 +327,8 @@ export interface SendMessageInternalOptions { cancelState?: { canceledBeforeAcceptance: boolean }; /** Cancels a synthetic send even after it has left MessageQueue for PREPARING. */ cancelSignal?: AbortSignal; + /** Let a late `cancelSignal` abort withdraw the send after its rows are durable (see AgentSession). */ + withdrawAcceptedOnCancel?: boolean; /** * Synchronous staleness probe from the caller, re-evaluated at the real admission points * (the enqueue block and the session's turn-admission gates) in addition to the diff --git a/src/node/services/workspaceService.ts b/src/node/services/workspaceService.ts index 44a8a1cda6e..d1afc4e2773 100644 --- a/src/node/services/workspaceService.ts +++ b/src/node/services/workspaceService.ts @@ -2675,6 +2675,7 @@ export class WorkspaceService extends EventEmitter implements WorkspaceHost { agentInitiated: true, requireIdle: true, cancelSignal: dispatch.cancelSignal, + withdrawAcceptedOnCancel: true, onAccepted: async () => { accepted = true; await dispatch.onAccepted(); @@ -11037,6 +11038,7 @@ export class WorkspaceService extends EventEmitter implements WorkspaceHost { goalId: internal?.goalId, cancelState: internal?.cancelState, cancelSignal: internal?.cancelSignal, + withdrawAcceptedOnCancel: internal?.withdrawAcceptedOnCancel, onCanceled: internal?.onCanceled, onAccepted: internal?.onAccepted, onAcceptedPreStreamFailure: internal?.onAcceptedPreStreamFailure, @@ -11338,6 +11340,7 @@ export class WorkspaceService extends EventEmitter implements WorkspaceHost { startStreamInBackground: internal?.startStreamInBackground, cancelState: internal?.cancelState, cancelSignal: internal?.cancelSignal, + withdrawAcceptedOnCancel: internal?.withdrawAcceptedOnCancel, // Same authoring-time race as the queued path: the goal-creating // stream can end during the preflight awaits above, making a fresh // goal visible after the user hit enter but before this dispatch.