Keep paired tail replay state across remounts

This commit is contained in:
Brennan Benson
2026-08-14 16:38:11 -07:00
parent 37abcd8f6a
commit 0fa5dcf380
5 changed files with 124 additions and 28 deletions
@@ -23,6 +23,7 @@ export type AgentAttentionDispatchMeta = {
export type AgentCompletionCoordinatorOptions = {
paneKey: string
statusLane?: 'hook' | 'pty'
getPtyId: () => string | null
getSettings: () => Pick<GlobalSettings, 'activeRuntimeEnvironmentId'> | null | undefined
inspectProcess: (
@@ -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()
@@ -44,12 +44,13 @@ const pendingStampedTailByPaneKey = new Map<
string,
{
turnCompletedAt: number
originCoordinatorId: number
eligibleWorkingBoundaryByCoordinator: Map<number, number>
originLane: string
eligibleWorkingBoundaryByLane: Map<string, number>
consumedIdentityByLane: Map<string, string>
}
>()
// Why: a sibling all-clear belongs to the stamped tail only while its exact working interval is still current.
const workingBoundaryByPaneKey = new Map<string, Map<number, number>>()
const workingBoundaryByPaneKey = new Map<string, Map<string, number>>()
const coordinatorCountByPaneKey = new Map<string, number>()
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
@@ -2424,6 +2424,7 @@ export function connectPanePty(
const agentCompletionCoordinator = createAgentCompletionCoordinator({
paneKey: cacheKey,
statusLane: 'pty',
getPtyId: () => transport.getPtyId(),
getSettings: () => useAppStore.getState().settings,
inspectProcess: inspectRuntimeTerminalProcess,
@@ -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<RuntimeTerminalProcessInspection> => ({