From 9876c1478d54fb3c4edfb2b74b8a9df1e35f07d6 Mon Sep 17 00:00:00 2001 From: Ammar Date: Sat, 11 Jul 2026 19:55:10 -0500 Subject: [PATCH 1/6] fix concurrent agent banner wake flicker --- src/browser/components/ChatPane/ChatPane.tsx | 12 ++-- .../ConcurrentLocalWarning.test.ts | 57 +++++++++++++++++++ .../ConcurrentLocalWarning.tsx | 26 +++++++-- 3 files changed, 84 insertions(+), 11 deletions(-) create mode 100644 src/browser/components/ConcurrentLocalWarning/ConcurrentLocalWarning.test.ts diff --git a/src/browser/components/ChatPane/ChatPane.tsx b/src/browser/components/ChatPane/ChatPane.tsx index 4b7b31cb0b..247e2b17d6 100644 --- a/src/browser/components/ChatPane/ChatPane.tsx +++ b/src/browser/components/ChatPane/ChatPane.tsx @@ -71,7 +71,7 @@ import { CompactionWarning } from "../CompactionWarning/CompactionWarning"; import { ContextSwitchWarning as ContextSwitchWarningBanner } from "../ContextSwitchWarning/ContextSwitchWarning"; import { ConcurrentLocalWarningDecoration, - useConcurrentLocalStreamingWorkspaceName, + useConcurrentLocalActiveWorkspaceName, } from "../ConcurrentLocalWarning/ConcurrentLocalWarning"; import { BackgroundProcessesBanner } from "../BackgroundProcessesBanner/BackgroundProcessesBanner"; import { checkAutoCompaction } from "@/common/utils/compaction/autoCompactionCheck"; @@ -327,7 +327,7 @@ const ChatPaneContent: React.FC = (props) => { : null; const shouldShowQueuedAgentTaskPrompt = Boolean(queuedAgentTaskPrompt) && (workspaceState?.messages.length ?? 0) === 0; - const concurrentLocalStreamingWorkspaceName = useConcurrentLocalStreamingWorkspaceName({ + const concurrentLocalActiveWorkspaceName = useConcurrentLocalActiveWorkspaceName({ workspaceId, projectPath, runtimeConfig, @@ -1629,7 +1629,7 @@ const ChatPaneContent: React.FC = (props) => { isCompacting={isCompacting} shouldShowPinnedTodoList={shouldShowPinnedTodoList} shouldShowReviewsBanner={shouldShowReviewsBanner} - concurrentLocalStreamingWorkspaceName={concurrentLocalStreamingWorkspaceName} + concurrentLocalActiveWorkspaceName={concurrentLocalActiveWorkspaceName} canInterrupt={canInterrupt} autoCompactionResult={autoCompactionResult} shouldShowCompactionWarning={shouldShowCompactionWarning} @@ -1694,7 +1694,7 @@ interface ChatInputPaneProps { isTranscriptCaughtUp: boolean; shouldShowPinnedTodoList: boolean; shouldShowReviewsBanner: boolean; - concurrentLocalStreamingWorkspaceName: string | null; + concurrentLocalActiveWorkspaceName: string | null; canInterrupt: boolean; autoCompactionResult: ReturnType; shouldShowCompactionWarning: boolean; @@ -1756,12 +1756,12 @@ const ChatInputPane: React.FC = (props) => { // message insert above a live tail row, so bottom-lock had to correct after layout and // visibly flashed while another local agent was active. Pin it with composer decorations // instead; new transcript rows no longer move the warning. - if (props.concurrentLocalStreamingWorkspaceName) { + if (props.concurrentLocalActiveWorkspaceName) { addDecorationEntry({ key: "concurrent-local-warning", node: ( ), }); diff --git a/src/browser/components/ConcurrentLocalWarning/ConcurrentLocalWarning.test.ts b/src/browser/components/ConcurrentLocalWarning/ConcurrentLocalWarning.test.ts new file mode 100644 index 0000000000..8996956ce6 --- /dev/null +++ b/src/browser/components/ConcurrentLocalWarning/ConcurrentLocalWarning.test.ts @@ -0,0 +1,57 @@ +import { describe, expect, test } from "bun:test"; +import { isConcurrentLocalWorkspaceActive } from "./ConcurrentLocalWarning"; + +describe("isConcurrentLocalWorkspaceActive", () => { + test("stays active across a background monitor wake cycle", () => { + const wakeCycle = [ + { + canInterrupt: true, + isStarting: false, + activeWorkflowRunCount: 0, + activeBashMonitorCount: 1, + }, + { + canInterrupt: false, + isStarting: false, + activeWorkflowRunCount: 0, + activeBashMonitorCount: 1, + }, + { + canInterrupt: false, + isStarting: true, + activeWorkflowRunCount: 0, + activeBashMonitorCount: 0, + }, + { + canInterrupt: true, + isStarting: false, + activeWorkflowRunCount: 0, + activeBashMonitorCount: 0, + }, + ]; + + expect(wakeCycle.map(isConcurrentLocalWorkspaceActive)).toEqual([true, true, true, true]); + }); + + test("stays active while a background workflow can wake the agent", () => { + expect( + isConcurrentLocalWorkspaceActive({ + canInterrupt: false, + isStarting: false, + activeWorkflowRunCount: 1, + activeBashMonitorCount: 0, + }) + ).toBe(true); + }); + + test("is inactive once no stream, startup, or wake monitor remains", () => { + expect( + isConcurrentLocalWorkspaceActive({ + canInterrupt: false, + isStarting: false, + activeWorkflowRunCount: 0, + activeBashMonitorCount: 0, + }) + ).toBe(false); + }); +}); diff --git a/src/browser/components/ConcurrentLocalWarning/ConcurrentLocalWarning.tsx b/src/browser/components/ConcurrentLocalWarning/ConcurrentLocalWarning.tsx index e9821e54b7..21500ef7e0 100644 --- a/src/browser/components/ConcurrentLocalWarning/ConcurrentLocalWarning.tsx +++ b/src/browser/components/ConcurrentLocalWarning/ConcurrentLocalWarning.tsx @@ -1,7 +1,7 @@ import React, { useMemo, useSyncExternalStore } from "react"; import { AlertTriangle } from "lucide-react"; import { useWorkspaceContext } from "@/browser/contexts/WorkspaceContext"; -import { useWorkspaceStoreRaw } from "@/browser/stores/WorkspaceStore"; +import { useWorkspaceStoreRaw, type WorkspaceSidebarState } from "@/browser/stores/WorkspaceStore"; import { cn } from "@/common/lib/utils"; import { isLocalProjectRuntime } from "@/common/types/runtime"; import type { RuntimeConfig } from "@/common/types/runtime"; @@ -12,11 +12,27 @@ interface ConcurrentLocalWarningProps { runtimeConfig?: RuntimeConfig; } +type ConcurrentLocalWorkspaceActivity = Pick< + WorkspaceSidebarState, + "canInterrupt" | "isStarting" | "activeWorkflowRunCount" | "activeBashMonitorCount" +>; + +export function isConcurrentLocalWorkspaceActive(state: ConcurrentLocalWorkspaceActivity): boolean { + // User rationale: background work briefly transitions through idle and startup states as it wakes + // the owning agent. Treat the whole wake cycle as active so the warning does not flash between turns. + return ( + state.canInterrupt || + state.isStarting || + state.activeWorkflowRunCount > 0 || + state.activeBashMonitorCount > 0 + ); +} + /** - * Returns the name of another local-project workspace that is actively streaming in the same - * project directory, or null when there is no conflicting local stream to warn about. + * Returns the name of another active local-project workspace in the same project directory, or null + * when there is no conflicting local agent to warn about. */ -export function useConcurrentLocalStreamingWorkspaceName( +export function useConcurrentLocalActiveWorkspaceName( props: ConcurrentLocalWarningProps ): string | null { const isLocalProject = isLocalProjectRuntime(props.runtimeConfig); @@ -53,7 +69,7 @@ export function useConcurrentLocalStreamingWorkspaceName( for (const id of otherLocalWorkspaceIds) { try { const state = store.getWorkspaceSidebarState(id); - if (state.canInterrupt) { + if (isConcurrentLocalWorkspaceActive(state)) { const meta = workspaceMetadata.get(id); return meta?.name ?? id; } From d8c11fb0a499a47f572925a92baf46708cc068d9 Mon Sep 17 00:00:00 2001 From: Ammar Date: Sat, 11 Jul 2026 20:12:13 -0500 Subject: [PATCH 2/6] track pending background agent wakes --- .../ConcurrentLocalWarning.test.ts | 8 +- .../ConcurrentLocalWarning.tsx | 7 +- src/browser/stores/WorkspaceStore.test.ts | 2 + src/browser/stores/WorkspaceStore.ts | 7 + src/common/orpc/schemas/workspace.ts | 4 + src/node/services/taskService.test.ts | 12 ++ src/node/services/taskService.ts | 14 ++ src/node/services/workspaceService.ts | 137 +++++++++++++++++- 8 files changed, 185 insertions(+), 6 deletions(-) diff --git a/src/browser/components/ConcurrentLocalWarning/ConcurrentLocalWarning.test.ts b/src/browser/components/ConcurrentLocalWarning/ConcurrentLocalWarning.test.ts index 8996956ce6..88154dfb10 100644 --- a/src/browser/components/ConcurrentLocalWarning/ConcurrentLocalWarning.test.ts +++ b/src/browser/components/ConcurrentLocalWarning/ConcurrentLocalWarning.test.ts @@ -7,24 +7,28 @@ describe("isConcurrentLocalWorkspaceActive", () => { { canInterrupt: true, isStarting: false, + pendingBackgroundWake: false, activeWorkflowRunCount: 0, activeBashMonitorCount: 1, }, { canInterrupt: false, isStarting: false, + pendingBackgroundWake: true, activeWorkflowRunCount: 0, - activeBashMonitorCount: 1, + activeBashMonitorCount: 0, }, { canInterrupt: false, isStarting: true, + pendingBackgroundWake: true, activeWorkflowRunCount: 0, activeBashMonitorCount: 0, }, { canInterrupt: true, isStarting: false, + pendingBackgroundWake: false, activeWorkflowRunCount: 0, activeBashMonitorCount: 0, }, @@ -38,6 +42,7 @@ describe("isConcurrentLocalWorkspaceActive", () => { isConcurrentLocalWorkspaceActive({ canInterrupt: false, isStarting: false, + pendingBackgroundWake: false, activeWorkflowRunCount: 1, activeBashMonitorCount: 0, }) @@ -49,6 +54,7 @@ describe("isConcurrentLocalWorkspaceActive", () => { isConcurrentLocalWorkspaceActive({ canInterrupt: false, isStarting: false, + pendingBackgroundWake: false, activeWorkflowRunCount: 0, activeBashMonitorCount: 0, }) diff --git a/src/browser/components/ConcurrentLocalWarning/ConcurrentLocalWarning.tsx b/src/browser/components/ConcurrentLocalWarning/ConcurrentLocalWarning.tsx index 21500ef7e0..7990d18458 100644 --- a/src/browser/components/ConcurrentLocalWarning/ConcurrentLocalWarning.tsx +++ b/src/browser/components/ConcurrentLocalWarning/ConcurrentLocalWarning.tsx @@ -14,7 +14,11 @@ interface ConcurrentLocalWarningProps { type ConcurrentLocalWorkspaceActivity = Pick< WorkspaceSidebarState, - "canInterrupt" | "isStarting" | "activeWorkflowRunCount" | "activeBashMonitorCount" + | "canInterrupt" + | "isStarting" + | "pendingBackgroundWake" + | "activeWorkflowRunCount" + | "activeBashMonitorCount" >; export function isConcurrentLocalWorkspaceActive(state: ConcurrentLocalWorkspaceActivity): boolean { @@ -23,6 +27,7 @@ export function isConcurrentLocalWorkspaceActive(state: ConcurrentLocalWorkspace return ( state.canInterrupt || state.isStarting || + state.pendingBackgroundWake === true || state.activeWorkflowRunCount > 0 || state.activeBashMonitorCount > 0 ); diff --git a/src/browser/stores/WorkspaceStore.test.ts b/src/browser/stores/WorkspaceStore.test.ts index f8d9fc6857..2cf0f5da5d 100644 --- a/src/browser/stores/WorkspaceStore.test.ts +++ b/src/browser/stores/WorkspaceStore.test.ts @@ -2960,6 +2960,7 @@ describe("WorkspaceStore", () => { streaming: true, lastModel: "claude-sonnet-4", lastThinkingLevel: "high", + pendingBackgroundWake: true, activeWorkflowRunCount: 1, todoStatus: { emoji: "🔄", message: "Run checks" }, hasTodos: true, @@ -2985,6 +2986,7 @@ describe("WorkspaceStore", () => { expect(state.canInterrupt).toBe(true); expect(state.currentModel).toBe(activitySnapshot.lastModel); expect(state.currentThinkingLevel).toBe(activitySnapshot.lastThinkingLevel); + expect(state.pendingBackgroundWake).toBe(true); expect(state.activeWorkflowRunCount).toBe(1); expect(store.getWorkspaceSidebarState(workspaceId).activeWorkflowRunCount).toBe(1); expect(state.agentStatus).toEqual(activitySnapshot.todoStatus ?? undefined); diff --git a/src/browser/stores/WorkspaceStore.ts b/src/browser/stores/WorkspaceStore.ts index b303720103..aca673193b 100644 --- a/src/browser/stores/WorkspaceStore.ts +++ b/src/browser/stores/WorkspaceStore.ts @@ -123,6 +123,7 @@ export interface WorkspaceState { loadedSkills: LoadedSkill[]; skillLoadErrors: SkillLoadError[]; agentStatus: { emoji: string; message: string; url?: string } | undefined; + pendingBackgroundWake?: boolean; activeWorkflowRunCount: number; activeBashMonitorCount: number; lastAbortReason: StreamAbortReasonSnapshot | null; @@ -183,6 +184,7 @@ export interface WorkspaceSidebarState { loadedSkills: LoadedSkill[]; skillLoadErrors: SkillLoadError[]; agentStatus: { emoji: string; message: string; url?: string } | undefined; + pendingBackgroundWake?: boolean; activeWorkflowRunCount: number; activeBashMonitorCount: number; terminalActiveCount: number; @@ -1989,6 +1991,7 @@ export class WorkspaceStore { (activity?.hasTodos === false ? undefined : deriveTodoStatus(aggregatorTodos))); const agentStatus = displayStatus ?? liveTodoStatus ?? fallbackAgentStatus ?? persistedTodoStatus; + const pendingBackgroundWake = activity?.pendingBackgroundWake ?? false; const activeWorkflowRunCount = activity?.activeWorkflowRunCount ?? 0; const activeBashMonitorCount = activity?.activeBashMonitorCount ?? 0; const goal = activity?.goal ?? null; @@ -2015,6 +2018,7 @@ export class WorkspaceStore { skillLoadErrors: aggregator.getSkillLoadErrors(), lastAbortReason: aggregator.getLastAbortReason(), agentStatus, + pendingBackgroundWake, activeWorkflowRunCount, activeBashMonitorCount, pendingStreamStartTime, @@ -2127,6 +2131,7 @@ export class WorkspaceStore { cached.loadedSkills === fullState.loadedSkills && cached.skillLoadErrors === fullState.skillLoadErrors && cached.agentStatus === fullState.agentStatus && + cached.pendingBackgroundWake === fullState.pendingBackgroundWake && cached.activeWorkflowRunCount === fullState.activeWorkflowRunCount && cached.activeBashMonitorCount === fullState.activeBashMonitorCount && cached.terminalActiveCount === terminalActiveCount && @@ -2151,6 +2156,7 @@ export class WorkspaceStore { loadedSkills: fullState.loadedSkills, skillLoadErrors: fullState.skillLoadErrors, agentStatus: fullState.agentStatus, + pendingBackgroundWake: fullState.pendingBackgroundWake, activeWorkflowRunCount: fullState.activeWorkflowRunCount, activeBashMonitorCount: fullState.activeBashMonitorCount, terminalActiveCount, @@ -2814,6 +2820,7 @@ export class WorkspaceStore { previous?.lastThinkingLevel !== snapshot?.lastThinkingLevel || previous?.recency !== snapshot?.recency || previous?.hasTodos !== snapshot?.hasTodos || + (previous?.pendingBackgroundWake ?? false) !== (snapshot?.pendingBackgroundWake ?? false) || (previous?.activeWorkflowRunCount ?? 0) !== (snapshot?.activeWorkflowRunCount ?? 0) || (previous?.activeBashMonitorCount ?? 0) !== (snapshot?.activeBashMonitorCount ?? 0) || !areAgentStatusesEqual(previous?.displayStatus, snapshot?.displayStatus) || diff --git a/src/common/orpc/schemas/workspace.ts b/src/common/orpc/schemas/workspace.ts index 248f5c0d96..c5a7c39b9e 100644 --- a/src/common/orpc/schemas/workspace.ts +++ b/src/common/orpc/schemas/workspace.ts @@ -313,6 +313,10 @@ export const WorkspaceActivitySnapshotSchema = z.object({ isIdleCompaction: z.boolean().optional().meta({ description: "Whether the current streaming activity is an idle (background) compaction", }), + pendingBackgroundWake: z.boolean().optional().meta({ + description: + "Whether a durable background notification is waiting to wake the workspace or is starting its synthetic turn.", + }), activeWorkflowRunCount: z.number().int().nonnegative().optional().meta({ description: "Number of top-level workflow runs in this workspace that are pending, running, or backgrounded.", diff --git a/src/node/services/taskService.test.ts b/src/node/services/taskService.test.ts index 3675651530..dc787cc46f 100644 --- a/src/node/services/taskService.test.ts +++ b/src/node/services/taskService.test.ts @@ -389,6 +389,8 @@ function createWorkspaceServiceMocks( updateAgentStatus: ReturnType; isExperimentEnabled: ReturnType; emitChatEvent: ReturnType; + setPendingBackgroundWake: ReturnType; + refreshPendingBackgroundWake: ReturnType; isWorkflowInvocationCurrent: ReturnType; create: ReturnType; }> @@ -414,6 +416,8 @@ function createWorkspaceServiceMocks( updateAgentStatus: ReturnType; isExperimentEnabled: ReturnType; emitChatEvent: ReturnType; + setPendingBackgroundWake: ReturnType; + refreshPendingBackgroundWake: ReturnType; isWorkflowInvocationCurrent: ReturnType; create: ReturnType; } { @@ -454,6 +458,10 @@ function createWorkspaceServiceMocks( const isWorkflowInvocationCurrent = overrides?.isWorkflowInvocationCurrent ?? mock(() => Promise.resolve(true)); + const setPendingBackgroundWake = overrides?.setPendingBackgroundWake ?? mock(() => undefined); + const refreshPendingBackgroundWake = + overrides?.refreshPendingBackgroundWake ?? mock((): Promise => Promise.resolve()); + const create = overrides?.create ?? mock( @@ -484,6 +492,8 @@ function createWorkspaceServiceMocks( updateAgentStatus, isExperimentEnabled, emitChatEvent, + setPendingBackgroundWake, + refreshPendingBackgroundWake, isWorkflowInvocationCurrent, } as unknown as WorkspaceService, create, @@ -507,6 +517,8 @@ function createWorkspaceServiceMocks( updateAgentStatus, isExperimentEnabled, emitChatEvent, + setPendingBackgroundWake, + refreshPendingBackgroundWake, isWorkflowInvocationCurrent, }; } diff --git a/src/node/services/taskService.ts b/src/node/services/taskService.ts index cbd904ceb4..3d0ac35994 100644 --- a/src/node/services/taskService.ts +++ b/src/node/services/taskService.ts @@ -4544,6 +4544,7 @@ export class TaskService { terminalOutcome: workflowRunTerminalOutcome(run.status), }); if (created != null) { + this.workspaceService.setPendingBackgroundWake(workspace.id); this.scheduleTerminalAttentionDrain(workspace.id); recoveredCount += 1; } @@ -4641,6 +4642,7 @@ export class TaskService { params.ownerWorkspaceId, TerminalAttentionStore.notificationId("workflow_run", params.runId) ); + await this.workspaceService.refreshPendingBackgroundWake(params.ownerWorkspaceId); } async markWorkflowRunTerminalAttentionConsumed(params: { @@ -4667,6 +4669,7 @@ export class TaskService { params.ownerWorkspaceId, TerminalAttentionStore.notificationId("workflow_run", params.runId) ); + await this.workspaceService.refreshPendingBackgroundWake(params.ownerWorkspaceId); } async markWorkspaceTurnTerminalAttentionConsumed(params: { @@ -4696,6 +4699,7 @@ export class TaskService { params.ownerWorkspaceId, TerminalAttentionStore.notificationId("workspace_turn", params.handleId) ); + await this.workspaceService.refreshPendingBackgroundWake(params.ownerWorkspaceId); } private async enqueueTerminalAttention(params: { @@ -4710,9 +4714,16 @@ export class TaskService { if (created == null) { return; } + // Publish the durable handoff before the terminal source disappears from activity counts. + // The workspace clears this only after streaming starts or the wake is canceled/superseded. + this.workspaceService.setPendingBackgroundWake(params.ownerWorkspaceId); this.scheduleTerminalAttentionDrain(params.ownerWorkspaceId); } + async hasPendingTerminalAttention(ownerWorkspaceId: string): Promise { + return (await this.terminalAttentionStore.listPending(ownerWorkspaceId)).length > 0; + } + private scheduleTerminalAttentionDrain(ownerWorkspaceId: string): void { const previous = this.pendingTerminalAttentionDrainsByOwner.get(ownerWorkspaceId); const promise = (previous ?? Promise.resolve()) @@ -4806,6 +4817,7 @@ export class TaskService { for (const notification of pending) { await this.terminalAttentionStore.markSuperseded(ownerWorkspaceId, notification.id); } + await this.workspaceService.refreshPendingBackgroundWake(ownerWorkspaceId); return; } @@ -4862,11 +4874,13 @@ export class TaskService { ); if (workflowPrompt == null) { await this.terminalAttentionStore.markSuperseded(ownerWorkspaceId, notification.id); + await this.workspaceService.refreshPendingBackgroundWake(ownerWorkspaceId); continue; } promptSections.push(workflowPrompt); } if (promptSections.length === 0) { + await this.workspaceService.refreshPendingBackgroundWake(ownerWorkspaceId); return; } const prompt = promptSections.join("\n\n"); diff --git a/src/node/services/workspaceService.ts b/src/node/services/workspaceService.ts index a7647ea41b..d2b8f9026d 100644 --- a/src/node/services/workspaceService.ts +++ b/src/node/services/workspaceService.ts @@ -1619,6 +1619,23 @@ function mergeActiveCount( return merged; } +function mergePendingBackgroundWake( + snapshot: WorkspaceActivitySnapshot | null, + pending: boolean +): WorkspaceActivitySnapshot | null { + if (snapshot == null && !pending) { + return null; + } + + const merged: WorkspaceActivitySnapshot = { ...(snapshot ?? createDefaultActivitySnapshot()) }; + if (pending) { + merged.pendingBackgroundWake = true; + } else { + delete merged.pendingBackgroundWake; + } + return merged; +} + // eslint-disable-next-line @typescript-eslint/no-unsafe-declaration-merging export class WorkspaceService extends EventEmitter { private readonly sessions = new Map(); @@ -1737,6 +1754,13 @@ export class WorkspaceService extends EventEmitter { }); }; + // Durable background notifications can outlive the monitor/task that produced them and remain + // pending through pre-stream startup. Cache that handoff state so inactive workspace activity + // snapshots keep the concurrent-agent warning mounted until streaming begins or the wake clears. + private readonly pendingBackgroundWakes = new Set(); + private readonly pendingBackgroundWakeSeenWorkspaces = new Set(); + private readonly pendingBackgroundWakeRefreshVersions = new Map(); + // Lazily bootstrapped workflow activity cache so sidebar refreshes don't rescan run history. private readonly activeWorkflowRunIdBootstrapsByWorkspace = new Map< string, @@ -1881,6 +1905,7 @@ export class WorkspaceService extends EventEmitter { try { const ownerWorkspaceIds = await this.bashMonitorWakeStore.listPendingOwnerWorkspaceIds(); for (const ownerWorkspaceId of ownerWorkspaceIds) { + this.setPendingBackgroundWake(ownerWorkspaceId); this.scheduleBashMonitorWakeDrain(ownerWorkspaceId); } } catch (error) { @@ -1889,10 +1914,14 @@ export class WorkspaceService extends EventEmitter { } private async handleBashMonitorMatch(payload: MonitorMatchPayload): Promise { + // Mark the handoff before persistence: one-shot monitors can retire synchronously after the + // match, and their count must overlap the pending wake instead of briefly exposing idle. + this.setPendingBackgroundWake(payload.workspaceId); try { await this.bashMonitorWakeStore.enqueueOrMergePending(payload); this.scheduleBashMonitorWakeDrain(payload.workspaceId); } catch (error) { + await this.refreshPendingBackgroundWake(payload.workspaceId); log.error("Failed to enqueue bash monitor wake", { workspaceId: payload.workspaceId, error }); } } @@ -1982,6 +2011,7 @@ export class WorkspaceService extends EventEmitter { for (const record of pending) { await this.bashMonitorWakeStore.markSuperseded(ownerWorkspaceId, record.id); } + await this.refreshPendingBackgroundWake(ownerWorkspaceId); return; } @@ -2067,6 +2097,9 @@ export class WorkspaceService extends EventEmitter { if (supersededByShown.length > 0) { await markSupersededSnapshots(supersededByShown); } + if (supersededByShown.length > 0) { + await this.refreshPendingBackgroundWake(ownerWorkspaceId); + } if (deliverable.length === 0) return; const prompt = buildBashMonitorWakePrompt(deliverable); @@ -2155,6 +2188,7 @@ export class WorkspaceService extends EventEmitter { for (const key of cancelingKeys) { this.cancelingBashMonitorWakeKeys.delete(key); } + await this.refreshPendingBackgroundWake(ownerWorkspaceId); } }, } @@ -2444,7 +2478,7 @@ export class WorkspaceService extends EventEmitter { model: data.model, thinkingLevel: data.thinkingLevel, generation, - }); + }).then(() => this.refreshPendingBackgroundWake(data.workspaceId)); } }); @@ -2650,6 +2684,68 @@ export class WorkspaceService extends EventEmitter { ); } + private emitCurrentWorkspaceActivity(workspaceId: string): void { + if (typeof this.extensionMetadata.getSnapshot !== "function") { + // Some focused tests provide a partial metadata service. The pending overlay is the + // behavior under test, so emit a sparse activity snapshot instead of crashing the wake path. + this.emitWorkspaceActivity(workspaceId, null); + return; + } + void this.extensionMetadata + .getSnapshot(workspaceId) + .then((snapshot) => this.emitWorkspaceActivity(workspaceId, snapshot)) + .catch((error: unknown) => { + log.debug("Failed to refresh pending background wake activity", { workspaceId, error }); + }); + } + + public setPendingBackgroundWake(workspaceId: string): void { + const nextVersion = (this.pendingBackgroundWakeRefreshVersions.get(workspaceId) ?? 0) + 1; + this.pendingBackgroundWakeRefreshVersions.set(workspaceId, nextVersion); + this.pendingBackgroundWakeSeenWorkspaces.add(workspaceId); + if (this.pendingBackgroundWakes.has(workspaceId)) { + return; + } + this.pendingBackgroundWakes.add(workspaceId); + this.emitCurrentWorkspaceActivity(workspaceId); + } + + private async computePendingBackgroundWake(workspaceId: string): Promise { + const session = + this.sessions.get(workspaceId) ?? this.transientStartupRecoverySessions.get(workspaceId); + const [monitorWakes, hasTerminalAttention] = await Promise.all([ + this.bashMonitorWakeStore.listPending(workspaceId), + this.taskService?.hasPendingTerminalAttention(workspaceId) ?? Promise.resolve(false), + ]); + return ( + monitorWakes.length > 0 || + hasTerminalAttention || + session?.isPreparingTurn() === true || + session?.hasQueuedMessages() === true || + session?.hasPendingAutoRetry() === true + ); + } + + public async refreshPendingBackgroundWake(workspaceId: string): Promise { + const version = (this.pendingBackgroundWakeRefreshVersions.get(workspaceId) ?? 0) + 1; + this.pendingBackgroundWakeRefreshVersions.set(workspaceId, version); + const pending = await this.computePendingBackgroundWake(workspaceId); + if (this.pendingBackgroundWakeRefreshVersions.get(workspaceId) !== version) { + return; + } + + const previous = this.pendingBackgroundWakes.has(workspaceId); + if (pending) { + this.pendingBackgroundWakes.add(workspaceId); + this.pendingBackgroundWakeSeenWorkspaces.add(workspaceId); + } else { + this.pendingBackgroundWakes.delete(workspaceId); + } + if (previous !== pending) { + this.emitCurrentWorkspaceActivity(workspaceId); + } + } + /** * Public so AgentStatusService can broadcast a snapshot it produced after * a direct setX call. (Most callers use emitWorkspaceActivityUpdate, which @@ -2665,7 +2761,10 @@ export class WorkspaceService extends EventEmitter { workspaceId, this.mergeCachedActiveWorkflowRunCount( workspaceId, - this.overlayPendingGoal(workspaceId, snapshot) + mergePendingBackgroundWake( + this.overlayPendingGoal(workspaceId, snapshot), + this.pendingBackgroundWakes.has(workspaceId) + ) ) ), }); @@ -3035,6 +3134,15 @@ export class WorkspaceService extends EventEmitter { if (this.shouldClearAgentStatusFromChatMessage(event.message)) { void this.updateAgentStatus(event.workspaceId, null); } + if ( + event.message.type === "stream-lifecycle" || + event.message.type === "queued-message-changed" || + event.message.type === "auto-retry-scheduled" || + event.message.type === "auto-retry-starting" || + event.message.type === "auto-retry-abandoned" + ) { + void this.refreshPendingBackgroundWake(event.workspaceId); + } }); const metadataUnsubscribe = session.onMetadataEvent((event) => { @@ -9010,6 +9118,9 @@ export class WorkspaceService extends EventEmitter { for (const workspaceId of this.activeWorkflowRunIdsByWorkspace.keys()) { workspaceIds.add(workspaceId); } + for (const workspaceId of this.pendingBackgroundWakeSeenWorkspaces) { + workspaceIds.add(workspaceId); + } for (const workspaceId of this.bashMonitorSeenWorkspaces) { workspaceIds.add(workspaceId); } @@ -9033,6 +9144,19 @@ export class WorkspaceService extends EventEmitter { // "watching" state survives reconnect. The seen-set is used instead of the // dedupe map because dedupe entries are dropped around in-flight/failed emits. const hadBashMonitorActivityCache = this.bashMonitorSeenWorkspaces.has(workspaceId); + const hadPendingBackgroundWake = + this.pendingBackgroundWakeSeenWorkspaces.has(workspaceId); + const pendingBackgroundWake = await this.computePendingBackgroundWake(workspaceId); + this.pendingBackgroundWakeRefreshVersions.set( + workspaceId, + (this.pendingBackgroundWakeRefreshVersions.get(workspaceId) ?? 0) + 1 + ); + if (pendingBackgroundWake) { + this.pendingBackgroundWakes.add(workspaceId); + this.pendingBackgroundWakeSeenWorkspaces.add(workspaceId); + } else { + this.pendingBackgroundWakes.delete(workspaceId); + } const activeWorkflowRunCount = await this.getActiveWorkflowRunCount(workspaceId); const activeBashMonitorCount = this.getActiveBashMonitorCount(workspaceId); if (activeBashMonitorCount > 0) { @@ -9047,7 +9171,9 @@ export class WorkspaceService extends EventEmitter { activeWorkflowRunCount === 0 && !hadWorkflowActivityCache && activeBashMonitorCount === 0 && - !hadBashMonitorActivityCache + !hadBashMonitorActivityCache && + !pendingBackgroundWake && + !hadPendingBackgroundWake ) { return null; } @@ -9061,7 +9187,10 @@ export class WorkspaceService extends EventEmitter { // goal until the next live emit or goal read. mergeActiveCount( mergeActiveCount( - this.overlayPendingGoal(workspaceId, snapshot), + mergePendingBackgroundWake( + this.overlayPendingGoal(workspaceId, snapshot), + pendingBackgroundWake + ), "activeWorkflowRunCount", activeWorkflowRunCount ), From c7f785131a2465ddc265719c270f3028d86a90ef Mon Sep 17 00:00:00 2001 From: Ammar Date: Sat, 11 Jul 2026 20:16:36 -0500 Subject: [PATCH 3/6] avoid stale activity list wake cache writes --- src/node/services/workspaceService.ts | 10 +++------- 1 file changed, 3 insertions(+), 7 deletions(-) diff --git a/src/node/services/workspaceService.ts b/src/node/services/workspaceService.ts index d2b8f9026d..5d31fc6718 100644 --- a/src/node/services/workspaceService.ts +++ b/src/node/services/workspaceService.ts @@ -9146,16 +9146,12 @@ export class WorkspaceService extends EventEmitter { const hadBashMonitorActivityCache = this.bashMonitorSeenWorkspaces.has(workspaceId); const hadPendingBackgroundWake = this.pendingBackgroundWakeSeenWorkspaces.has(workspaceId); + // The list response should report current durable/session state, but must not mutate the + // live cache: a concurrent refresh may have a newer clear/set in flight, and list must + // never invalidate it or restore a stale pending value after the handoff finishes. const pendingBackgroundWake = await this.computePendingBackgroundWake(workspaceId); - this.pendingBackgroundWakeRefreshVersions.set( - workspaceId, - (this.pendingBackgroundWakeRefreshVersions.get(workspaceId) ?? 0) + 1 - ); if (pendingBackgroundWake) { - this.pendingBackgroundWakes.add(workspaceId); this.pendingBackgroundWakeSeenWorkspaces.add(workspaceId); - } else { - this.pendingBackgroundWakes.delete(workspaceId); } const activeWorkflowRunCount = await this.getActiveWorkflowRunCount(workspaceId); const activeBashMonitorCount = this.getActiveBashMonitorCount(workspaceId); From b0c7655336e88c5c55eef19182054363e01d8737 Mon Sep 17 00:00:00 2001 From: Ammar Date: Sat, 11 Jul 2026 20:24:54 -0500 Subject: [PATCH 4/6] seed persisted terminal wake activity --- src/node/services/taskService.ts | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/node/services/taskService.ts b/src/node/services/taskService.ts index 3d0ac35994..afd0a45c0c 100644 --- a/src/node/services/taskService.ts +++ b/src/node/services/taskService.ts @@ -2181,6 +2181,9 @@ export class TaskService { const pendingTerminalAttentionOwnerWorkspaceIds = await this.terminalAttentionStore.listPendingOwnerWorkspaceIds(); for (const ownerWorkspaceId of pendingTerminalAttentionOwnerWorkspaceIds) { + // Persisted terminal attention survives restarts, so seed the live activity handoff before + // any drain-triggered metadata emit can otherwise report the workspace as idle. + this.workspaceService.setPendingBackgroundWake(ownerWorkspaceId); this.scheduleTerminalAttentionDrain(ownerWorkspaceId); } const terminalAttentionDrainMs = Date.now() - terminalAttentionDrainStartedAt; From 9dd4f14ca3b54dbe28d0d715ab5be186761cf16f Mon Sep 17 00:00:00 2001 From: Ammar Date: Sat, 11 Jul 2026 20:26:22 -0500 Subject: [PATCH 5/6] seed recovered terminal wake activity --- src/node/services/taskService.test.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/node/services/taskService.test.ts b/src/node/services/taskService.test.ts index dc787cc46f..54a269f13d 100644 --- a/src/node/services/taskService.test.ts +++ b/src/node/services/taskService.test.ts @@ -2890,10 +2890,13 @@ describe("TaskService", () => { const sendMessage = mock( (..._args: unknown[]): Promise> => Promise.resolve(Ok(undefined)) ); - const { workspaceService } = createWorkspaceServiceMocks({ sendMessage }); + const { workspaceService, setPendingBackgroundWake } = createWorkspaceServiceMocks({ + sendMessage, + }); const { taskService } = createTaskServiceHarness(config, { workspaceService }); await taskService.initialize(); + expect(setPendingBackgroundWake).toHaveBeenCalledWith(parentId); await flushTerminalAttentionDrains(taskService); expect(sendMessage).toHaveBeenCalledTimes(1); From b0f371466f6aafa5a17de59e4a25494d0512574f Mon Sep 17 00:00:00 2001 From: Ammar Date: Sat, 11 Jul 2026 20:33:53 -0500 Subject: [PATCH 6/6] clear delivered terminal wake activity --- src/node/services/taskService.test.ts | 5 ++++- src/node/services/taskService.ts | 2 ++ 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/src/node/services/taskService.test.ts b/src/node/services/taskService.test.ts index 54a269f13d..4d18ac5fc8 100644 --- a/src/node/services/taskService.test.ts +++ b/src/node/services/taskService.test.ts @@ -2245,7 +2245,9 @@ describe("TaskService", () => { const sendMessage = mock( (..._args: unknown[]): Promise> => Promise.resolve(Ok(undefined)) ); - const { workspaceService } = createWorkspaceServiceMocks({ sendMessage }); + const { workspaceService, refreshPendingBackgroundWake } = createWorkspaceServiceMocks({ + sendMessage, + }); const { taskService } = createTaskServiceHarness(config, { workspaceService }); const internal = taskService as unknown as { drainTerminalAttention: (ownerWorkspaceId: string) => Promise; @@ -2253,6 +2255,7 @@ describe("TaskService", () => { await internal.drainTerminalAttention(parentId); + expect(refreshPendingBackgroundWake).toHaveBeenCalledWith(parentId); expect(sendMessage).toHaveBeenCalledTimes(1); const prompt = String(sendMessage.mock.calls[0]?.[1]); expect(prompt).toContain("Background sub-agent task(s) have completed"); diff --git a/src/node/services/taskService.ts b/src/node/services/taskService.ts index afd0a45c0c..6d9bfde081 100644 --- a/src/node/services/taskService.ts +++ b/src/node/services/taskService.ts @@ -4892,12 +4892,14 @@ export class TaskService { for (const notification of pending) { await this.terminalAttentionStore.markDelivered(ownerWorkspaceId, notification.id); } + await this.workspaceService.refreshPendingBackgroundWake(ownerWorkspaceId); }; const markPendingForRetry = async () => { for (const notification of pending) { await this.terminalAttentionStore.markPending(ownerWorkspaceId, notification.id); } + await this.workspaceService.refreshPendingBackgroundWake(ownerWorkspaceId); }; const resumeOptions = await this.resolveParentAutoResumeOptions(