diff --git a/src/node/services/workspaceService.test.ts b/src/node/services/workspaceService.test.ts index bf67f2099e..f8c4a1c4db 100644 --- a/src/node/services/workspaceService.test.ts +++ b/src/node/services/workspaceService.test.ts @@ -49,6 +49,7 @@ import type { } from "@/common/types/workspace"; import { makeAgentTaskIntegrationFake } from "./taskWorkspaceSeam.testUtils"; import type { BackgroundProcessManager } from "./backgroundProcessManager"; +import type { BashMonitorProcessSnapshot } from "./bashMonitorWakeReconciler"; import type { TerminalService } from "@/node/services/terminalService"; import type { DesktopSessionManager } from "@/node/services/desktop/DesktopSessionManager"; import type { WorktreeArchiveSnapshot } from "@/common/schemas/project"; @@ -248,7 +249,7 @@ describe("WorkspaceService bash monitor wake reconciler wiring", () => { ), backgroundProcessManager, }); - return { config, service, events, cleanup }; + return { config, historyService, service, events, backgroundProcessManager, cleanup }; } test("monitor lifecycle and shown-output events poke the reconciler", async () => { @@ -842,6 +843,172 @@ describe("WorkspaceService bash monitor wake reconciler wiring", () => { await cleanup(); } }); + + test("defers a second wake while the accepted first wake waits to start", async () => { + const { config, service, backgroundProcessManager, cleanup } = await createWakeWiringService(); + const acknowledgeMonitorWake = mock(() => undefined); + Object.assign(backgroundProcessManager, { acknowledgeMonitorWake }); + const workspaceId = "concurrent-wake-owner"; + await config.addWorkspace("/tmp/concurrent-wake-project", { + id: workspaceId, + name: workspaceId, + projectName: "concurrent-wake-project", + projectPath: "/tmp/concurrent-wake-project", + runtimeConfig: { type: "local" }, + }); + + const firstWake: BashMonitorProcessSnapshot = { + processId: "first-proc", + taskId: "bash:first-proc", + ownerWorkspaceId: workspaceId, + displayName: "first monitor", + filter: "FIRST", + filterExclude: false, + script: "run-first", + createdAt: "2026-09-01T00:00:00.000Z", + match: { throughOffset: 5, lines: ["FIRST"], totalMatches: 1 }, + retired: false, + }; + const secondWake: BashMonitorProcessSnapshot = { + ...firstWake, + processId: "second-proc", + taskId: "bash:second-proc", + displayName: "second monitor", + filter: "SECOND", + script: "run-second", + match: { throughOffset: 6, lines: ["SECOND"], totalMatches: 1 }, + }; + let liveWakes = [firstWake]; + backgroundProcessManager.pullMonitorWakeSignals = mock(() => liveWakes); + backgroundProcessManager.getMonitorWakeDeliveryState = mock(() => + Promise.resolve({ + status: "settled" as const, + shownThroughOffset: 0, + terminalStatusShown: false, + }) + ); + + const idleGate = createDeferred(); + const idleWaitStarted = createDeferred(); + const secondAccepted = createDeferred(); + let idleWaitObserved = false; + let queuedEntryCount = 0; + let preparing = false; + let busy = true; + let streaming = true; + let idleReleased = false; + const fakeSession = { + hasQueuedMessages: () => queuedEntryCount > 0, + isPreparingTurn: () => preparing, + hasPendingAutoRetry: () => false, + isBusy: () => busy, + waitForIdle: () => { + if (!idleWaitObserved) { + idleWaitObserved = true; + idleWaitStarted.resolve(); + } + return idleGate.promise; + }, + onChatEvent: () => () => undefined, + } as unknown as AgentSession; + + interface AcceptedCallbacks { + onAccepted?: () => Promise; + } + let firstCallbacks: AcceptedCallbacks | undefined; + const queuedModes: string[] = []; + let secondStartedAfterIdle = false; + let sendCount = 0; + const sendMessage = mock( + ( + _workspaceId: string, + _prompt: string, + options: { queueDispatchMode?: string }, + callbacks?: AcceptedCallbacks + ) => { + sendCount++; + queuedModes.push(options.queueDispatchMode ?? ""); + if (sendCount === 1) { + queuedEntryCount++; + firstCallbacks = callbacks; + return Promise.resolve(Ok(undefined)); + } + secondStartedAfterIdle = idleReleased; + const onAccepted = callbacks?.onAccepted; + if (onAccepted == null) { + throw new Error("Expected the second wake to provide an acceptance callback"); + } + return onAccepted().then(() => { + secondAccepted.resolve(); + return Ok(undefined); + }); + } + ); + + const internal = service as unknown as { + sessions: Map; + aiService: { isStreaming(workspaceId: string): boolean }; + getDelegatedTurnContinuationSendOptions(workspaceId: string): Promise; + sendMessage: typeof sendMessage; + bashMonitorWakeReconciler: { reconcile(workspaceId: string): Promise }; + }; + try { + internal.sessions.set(workspaceId, fakeSession); + internal.aiService = { isStreaming: () => streaming }; + internal.getDelegatedTurnContinuationSendOptions = () => + Promise.resolve({ model: "anthropic:claude-sonnet-4-5", agentId: "exec" }); + internal.sendMessage = sendMessage; + + await internal.bashMonitorWakeReconciler.reconcile(workspaceId); + expect(queuedEntryCount).toBe(1); + expect(queuedModes).toEqual(["tool-end"]); + + liveWakes = [firstWake, secondWake]; + queuedEntryCount--; + preparing = true; + streaming = false; + const firstOnAccepted = firstCallbacks?.onAccepted; + expect(firstOnAccepted).toBeDefined(); + if (firstOnAccepted == null) { + throw new Error("Expected the first wake to provide an acceptance callback"); + } + await firstOnAccepted(); + expect(acknowledgeMonitorWake).toHaveBeenCalledWith( + "first-proc", + Date.parse(firstWake.createdAt), + firstWake.match?.throughOffset, + undefined + ); + await internal.bashMonitorWakeReconciler.reconcile(workspaceId); + + expect(sendCount).toBe(1); + expect(queuedModes).toEqual(["tool-end"]); + expect(secondStartedAfterIdle).toBe(false); + + preparing = false; + streaming = true; + await idleWaitStarted.promise; + expect(sendCount).toBe(1); + + busy = false; + streaming = false; + idleReleased = true; + idleGate.resolve(); + await secondAccepted.promise; + await internal.bashMonitorWakeReconciler.reconcile(workspaceId); + expect(sendCount).toBe(2); + expect(secondStartedAfterIdle).toBe(true); + expect(acknowledgeMonitorWake).toHaveBeenCalledTimes(2); + expect(acknowledgeMonitorWake).toHaveBeenLastCalledWith( + "second-proc", + Date.parse(secondWake.createdAt), + secondWake.match?.throughOffset, + undefined + ); + } finally { + await cleanup(); + } + }); }); async function setWorkspaceGoalOk(