From 0fa5dcf38026e3baccfa6ca5b4ea028fdf0c91a6 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Fri, 14 Aug 2026 16:38:11 -0700 Subject: [PATCH] Keep paired tail replay state across remounts --- .../agent-completion-coordinator-types.ts | 1 + .../agent-completion-coordinator.test.ts | 64 ++++++++++++++ .../agent-completion-coordinator.ts | 85 +++++++++++++------ .../terminal-pane/pty-connection.ts | 1 + .../agent-hook-completion-notifications.ts | 1 + 5 files changed, 124 insertions(+), 28 deletions(-) diff --git a/src/renderer/src/components/terminal-pane/agent-completion-coordinator-types.ts b/src/renderer/src/components/terminal-pane/agent-completion-coordinator-types.ts index 4084287cd4b..bcdcb19386f 100644 --- a/src/renderer/src/components/terminal-pane/agent-completion-coordinator-types.ts +++ b/src/renderer/src/components/terminal-pane/agent-completion-coordinator-types.ts @@ -23,6 +23,7 @@ export type AgentAttentionDispatchMeta = { export type AgentCompletionCoordinatorOptions = { paneKey: string + statusLane?: 'hook' | 'pty' getPtyId: () => string | null getSettings: () => Pick | null | undefined inspectProcess: ( diff --git a/src/renderer/src/components/terminal-pane/agent-completion-coordinator.test.ts b/src/renderer/src/components/terminal-pane/agent-completion-coordinator.test.ts index 5131171db2d..0d928991a29 100644 --- a/src/renderer/src/components/terminal-pane/agent-completion-coordinator.test.ts +++ b/src/renderer/src/components/terminal-pane/agent-completion-coordinator.test.ts @@ -1574,6 +1574,70 @@ describe('agent completion coordinator', () => { expect(dispatchCompletion).toHaveBeenCalledTimes(2) }) + it.each(['before', 'after'] as const)( + 'keeps sibling tail replay suppression when the pane remounts %s its all-clear', + (remountOrder) => { + const dispatchCompletion = vi.fn() + const createLocalCoordinator = () => + createAgentCompletionCoordinator({ + paneKey: 'tab-1:leaf-1', + statusLane: 'pty', + getPtyId: () => 'pty-1', + getSettings: () => null, + inspectProcess: vi.fn(), + dispatchCompletion, + isLive: () => true + }) + const localCoordinator = createLocalCoordinator() + const hostCoordinator = createAgentCompletionCoordinator({ + paneKey: 'tab-1:leaf-1', + statusLane: 'hook', + getPtyId: () => 'pty-1', + getSettings: () => null, + inspectProcess: vi.fn(), + dispatchCompletion, + isLive: () => true + }) + const localAllClear = { + state: 'done' as const, + prompt: 'first turn', + agentType: 'claude' as const, + stateStartedAt: 5_500 + } + + localCoordinator.observeHookStatus({ + state: 'working', + prompt: 'first turn', + agentType: 'claude', + stateStartedAt: 5_000 + }) + hostCoordinator.observeHookStatus({ + state: 'working', + prompt: 'first turn', + agentType: 'claude', + stateStartedAt: 2_000 + }) + hostCoordinator.observeHookStatus({ + state: 'working', + prompt: 'first turn', + agentType: 'claude', + stateStartedAt: 2_000, + turnCompletedAt: 2_500 + }) + expect(dispatchCompletion).toHaveBeenCalledTimes(1) + + if (remountOrder === 'after') { + localCoordinator.observeHookStatus(localAllClear) + } + localCoordinator.dispose() + const remounted = createLocalCoordinator() + remounted.observeHookStatus(localAllClear) + vi.advanceTimersByTime(HOOK_DONE_QUIET_MS) + + expect(dispatchCompletion).toHaveBeenCalledTimes(1) + } + ) + it('does not replay a stamped turn or its all-clear after a working title blip', () => { const dispatchCompletion = vi.fn() const dispatchHookLifecycle = vi.fn() diff --git a/src/renderer/src/components/terminal-pane/agent-completion-coordinator.ts b/src/renderer/src/components/terminal-pane/agent-completion-coordinator.ts index 2502ce358de..3860ea63572 100644 --- a/src/renderer/src/components/terminal-pane/agent-completion-coordinator.ts +++ b/src/renderer/src/components/terminal-pane/agent-completion-coordinator.ts @@ -44,12 +44,13 @@ const pendingStampedTailByPaneKey = new Map< string, { turnCompletedAt: number - originCoordinatorId: number - eligibleWorkingBoundaryByCoordinator: Map + originLane: string + eligibleWorkingBoundaryByLane: Map + consumedIdentityByLane: Map } >() // Why: a sibling all-clear belongs to the stamped tail only while its exact working interval is still current. -const workingBoundaryByPaneKey = new Map>() +const workingBoundaryByPaneKey = new Map>() const coordinatorCountByPaneKey = new Map() let nextCoordinatorId = 1 @@ -94,6 +95,10 @@ export function createAgentCompletionCoordinator( options: AgentCompletionCoordinatorOptions ): AgentCompletionCoordinator { const coordinatorId = nextCoordinatorId++ + const coordinatorLane = options.statusLane ?? `coordinator:${coordinatorId}` + let inheritedWorkingBoundary = pendingStampedTailByPaneKey + .get(options.paneKey) + ?.eligibleWorkingBoundaryByLane.get(coordinatorLane) coordinatorCountByPaneKey.set( options.paneKey, (coordinatorCountByPaneKey.get(options.paneKey) ?? 0) + 1 @@ -225,7 +230,7 @@ export function createAgentCompletionCoordinator( function recordWorkingBoundary(stateStartedAt: number | undefined): void { let boundaries = workingBoundaryByPaneKey.get(options.paneKey) if (!isFiniteTurnCompletedAt(stateStartedAt)) { - boundaries?.delete(coordinatorId) + boundaries?.delete(coordinatorLane) if (boundaries?.size === 0) { workingBoundaryByPaneKey.delete(options.paneKey) } @@ -235,35 +240,45 @@ export function createAgentCompletionCoordinator( boundaries = new Map() workingBoundaryByPaneKey.set(options.paneKey, boundaries) } - boundaries.set(coordinatorId, stateStartedAt) + inheritedWorkingBoundary = undefined + boundaries.set(coordinatorLane, stateStartedAt) } function clearWorkingBoundary(): void { + inheritedWorkingBoundary = undefined const boundaries = workingBoundaryByPaneKey.get(options.paneKey) - boundaries?.delete(coordinatorId) + boundaries?.delete(coordinatorLane) if (boundaries?.size === 0) { workingBoundaryByPaneKey.delete(options.paneKey) } } - function consumePendingStampedTailForAgent(agentIdentity: string | null): boolean { + function consumePendingStampedTailForAgent( + agentIdentity: string | null, + completionIdentity: string | null + ): boolean { const pendingStampedTail = pendingStampedTailByPaneKey.get(options.paneKey) const stampedCompletion = lastCompletionIdentityByPaneKey.get(options.paneKey) - const currentWorkingBoundary = workingBoundaryByPaneKey.get(options.paneKey)?.get(coordinatorId) + const currentWorkingBoundary = workingBoundaryByPaneKey + .get(options.paneKey) + ?.get(coordinatorLane) + const eligibleWorkingBoundary = + pendingStampedTail?.eligibleWorkingBoundaryByLane.get(coordinatorLane) if ( pendingStampedTail === undefined || stampedCompletion?.lastTurnCompletedAtNotified !== pendingStampedTail.turnCompletedAt || agentIdentity === null || stampedCompletion.agentIdentity !== agentIdentity || - currentWorkingBoundary === undefined || - pendingStampedTail.eligibleWorkingBoundaryByCoordinator.get(coordinatorId) !== - currentWorkingBoundary + eligibleWorkingBoundary === undefined || + (currentWorkingBoundary !== eligibleWorkingBoundary && + inheritedWorkingBoundary !== eligibleWorkingBoundary) ) { return false } - pendingStampedTail.eligibleWorkingBoundaryByCoordinator.delete(coordinatorId) - if (pendingStampedTail.eligibleWorkingBoundaryByCoordinator.size === 0) { - pendingStampedTailByPaneKey.delete(options.paneKey) + pendingStampedTail.eligibleWorkingBoundaryByLane.delete(coordinatorLane) + inheritedWorkingBoundary = undefined + if (completionIdentity) { + pendingStampedTail.consumedIdentityByLane.set(coordinatorLane, completionIdentity) } return true } @@ -273,10 +288,14 @@ export function createAgentCompletionCoordinator( if (pendingStampedTail?.turnCompletedAt !== turnCompletedAt) { return } - pendingStampedTail.eligibleWorkingBoundaryByCoordinator.delete(coordinatorId) - if (pendingStampedTail.eligibleWorkingBoundaryByCoordinator.size === 0) { - pendingStampedTailByPaneKey.delete(options.paneKey) - } + pendingStampedTail.eligibleWorkingBoundaryByLane.delete(coordinatorLane) + } + + function hasUnconsumedStampedTail(): boolean { + return ( + (pendingStampedTailByPaneKey.get(options.paneKey)?.eligibleWorkingBoundaryByLane.size ?? 0) > + 0 + ) } function hookCompletionAgentIdentity(payload: AgentCompletionStatusSnapshot): string | null { @@ -346,6 +365,14 @@ export function createAgentCompletionCoordinator( if (!completionIdentity) { return false } + if ( + completionIdentity.source === 'hook' && + pendingStampedTailByPaneKey + .get(options.paneKey) + ?.consumedIdentityByLane.get(coordinatorLane) === completionIdentity.identity + ) { + return true + } const previous = lastCompletionIdentityByPaneKey.get(options.paneKey) if (!previous) { return false @@ -354,7 +381,10 @@ export function createAgentCompletionCoordinator( return ( previous.identity === completionIdentity.identity || (completionIdentity.source === 'hook' && - consumePendingStampedTailForAgent(completionIdentity.agentIdentity)) + consumePendingStampedTailForAgent( + completionIdentity.agentIdentity, + completionIdentity.identity + )) ) } return ( @@ -578,7 +608,7 @@ export function createAgentCompletionCoordinator( if ( !lastForegroundAgent && processSession > 0 && - !pendingStampedTailByPaneKey.has(options.paneKey) && + !hasUnconsumedStampedTail() && replayIdentity?.source === 'hook' && replayIdentity.agentIdentity === process.agent ) { @@ -656,7 +686,7 @@ export function createAgentCompletionCoordinator( }) if ( !committed && - !pendingStampedTailByPaneKey.has(options.paneKey) && + !hasUnconsumedStampedTail() && replayIdentityBeforeExit?.source === 'hook' && replayIdentityBeforeExit.agentIdentity === exited.agent ) { @@ -843,7 +873,7 @@ export function createAgentCompletionCoordinator( clearPendingCodexAttention() workingStatusObserved = true requiresFreshWorking = false - if (!pendingStampedTailByPaneKey.has(options.paneKey)) { + if (!hasUnconsumedStampedTail()) { lastCompletionIdentityByPaneKey.delete(options.paneKey) } currentTurn += 1 @@ -948,10 +978,9 @@ export function createAgentCompletionCoordinator( if (!alreadyHandled) { pendingStampedTailByPaneKey.set(options.paneKey, { turnCompletedAt, - originCoordinatorId: coordinatorId, - eligibleWorkingBoundaryByCoordinator: new Map( - workingBoundaryByPaneKey.get(options.paneKey) - ) + originLane: coordinatorLane, + eligibleWorkingBoundaryByLane: new Map(workingBoundaryByPaneKey.get(options.paneKey)), + consumedIdentityByLane: new Map() }) } // Why: Claude's lead Stop already ended the turn; the pane stays `working` only for background inventory. Announce now, keep lifecycle on the reported working row. @@ -987,7 +1016,7 @@ export function createAgentCompletionCoordinator( return } const pendingStampedTail = pendingStampedTailByPaneKey.get(options.paneKey) - if (pendingStampedTail?.originCoordinatorId === coordinatorId) { + if (pendingStampedTail?.originLane === coordinatorLane) { pendingStampedTailByPaneKey.delete(options.paneKey) } recordWorkingBoundary(payload.stateStartedAt) @@ -1028,7 +1057,7 @@ export function createAgentCompletionCoordinator( : undefined if ( turnCompletedAt === undefined && - consumePendingStampedTailForAgent(hookCompletionAgentIdentity(payload)) + consumePendingStampedTailForAgent(hookCompletionAgentIdentity(payload), hookIdentity) ) { // Why: paired host hooks carry the stamp while the sibling OSC coordinator sees only the matching all-clear. lastCompletionIdentity = hookIdentity diff --git a/src/renderer/src/components/terminal-pane/pty-connection.ts b/src/renderer/src/components/terminal-pane/pty-connection.ts index 14a514cfee5..37cc77d7580 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection.ts @@ -2424,6 +2424,7 @@ export function connectPanePty( const agentCompletionCoordinator = createAgentCompletionCoordinator({ paneKey: cacheKey, + statusLane: 'pty', getPtyId: () => transport.getPtyId(), getSettings: () => useAppStore.getState().settings, inspectProcess: inspectRuntimeTerminalProcess, diff --git a/src/renderer/src/hooks/agent-hook-completion-notifications.ts b/src/renderer/src/hooks/agent-hook-completion-notifications.ts index 125c0994514..7502f19ce21 100644 --- a/src/renderer/src/hooks/agent-hook-completion-notifications.ts +++ b/src/renderer/src/hooks/agent-hook-completion-notifications.ts @@ -231,6 +231,7 @@ function paneCanReceiveHookCompletion(paneKey: string, tabIndex?: TabIndex): boo function createCoordinator(paneKey: string, worktreeId: string): AgentCompletionCoordinator { return createAgentCompletionCoordinator({ paneKey, + statusLane: 'hook', getPtyId: () => getPtyIdForPaneKey(paneKey), getSettings: () => useAppStore.getState().settings, inspectProcess: async (): Promise => ({