Preserve paired completion tail ownership

This commit is contained in:
Brennan Benson
2026-08-14 16:25:31 -07:00
parent caec58ab4f
commit 37abcd8f6a
2 changed files with 154 additions and 4 deletions
@@ -1449,6 +1449,131 @@ describe('agent completion coordinator', () => {
expect(dispatchCompletion).toHaveBeenCalledTimes(2)
})
it('keeps sibling tail suppression after the host stamped all-clear arrives first', () => {
const dispatchCompletion = vi.fn()
const localCoordinator = createAgentCompletionCoordinator({
paneKey: 'tab-1:leaf-1',
getPtyId: () => 'pty-1',
getSettings: () => null,
inspectProcess: vi.fn(),
dispatchCompletion,
isLive: () => true
})
const hostCoordinator = createAgentCompletionCoordinator({
paneKey: 'tab-1:leaf-1',
getPtyId: () => 'pty-1',
getSettings: () => null,
inspectProcess: vi.fn(),
dispatchCompletion,
isLive: () => true
})
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
})
hostCoordinator.observeHookStatus({
state: 'done',
prompt: 'first turn',
agentType: 'claude',
stateStartedAt: 3_000,
turnCompletedAt: 2_500
})
localCoordinator.observeHookStatus({
state: 'done',
prompt: 'first turn',
agentType: 'claude',
stateStartedAt: 5_500
})
localCoordinator.observeHookStatus({
state: 'done',
prompt: 'first turn',
agentType: 'claude',
stateStartedAt: 5_500
})
vi.advanceTimersByTime(HOOK_DONE_QUIET_MS)
expect(dispatchCompletion).toHaveBeenCalledTimes(1)
})
it('releases a stamped tail when its host reports a new same-state turn', () => {
const dispatchCompletion = vi.fn()
const localCoordinator = createAgentCompletionCoordinator({
paneKey: 'tab-1:leaf-1',
getPtyId: () => 'pty-1',
getSettings: () => null,
inspectProcess: vi.fn(),
dispatchCompletion,
isLive: () => true
})
const hostCoordinator = createAgentCompletionCoordinator({
paneKey: 'tab-1:leaf-1',
getPtyId: () => 'pty-1',
getSettings: () => null,
inspectProcess: vi.fn(),
dispatchCompletion,
isLive: () => true
})
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)
hostCoordinator.observeHookStatus({
state: 'working',
prompt: 'second turn',
agentType: 'claude',
stateStartedAt: 2_000
})
localCoordinator.observeHookStatus({
state: 'working',
prompt: 'second turn',
agentType: 'claude',
stateStartedAt: 5_000
})
localCoordinator.observeHookStatus({
state: 'done',
prompt: 'second turn',
agentType: 'claude',
stateStartedAt: 5_500
})
vi.advanceTimersByTime(HOOK_DONE_QUIET_MS)
expect(dispatchCompletion).toHaveBeenCalledTimes(2)
})
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,6 +44,7 @@ const pendingStampedTailByPaneKey = new Map<
string,
{
turnCompletedAt: number
originCoordinatorId: number
eligibleWorkingBoundaryByCoordinator: Map<number, number>
}
>()
@@ -260,10 +261,24 @@ export function createAgentCompletionCoordinator(
) {
return false
}
pendingStampedTailByPaneKey.delete(options.paneKey)
pendingStampedTail.eligibleWorkingBoundaryByCoordinator.delete(coordinatorId)
if (pendingStampedTail.eligibleWorkingBoundaryByCoordinator.size === 0) {
pendingStampedTailByPaneKey.delete(options.paneKey)
}
return true
}
function consumeStampedTailForCurrentCoordinator(turnCompletedAt: number): void {
const pendingStampedTail = pendingStampedTailByPaneKey.get(options.paneKey)
if (pendingStampedTail?.turnCompletedAt !== turnCompletedAt) {
return
}
pendingStampedTail.eligibleWorkingBoundaryByCoordinator.delete(coordinatorId)
if (pendingStampedTail.eligibleWorkingBoundaryByCoordinator.size === 0) {
pendingStampedTailByPaneKey.delete(options.paneKey)
}
}
function hookCompletionAgentIdentity(payload: AgentCompletionStatusSnapshot): string | null {
return payload.agentType?.trim().toLowerCase() || null
}
@@ -933,6 +948,7 @@ export function createAgentCompletionCoordinator(
if (!alreadyHandled) {
pendingStampedTailByPaneKey.set(options.paneKey, {
turnCompletedAt,
originCoordinatorId: coordinatorId,
eligibleWorkingBoundaryByCoordinator: new Map(
workingBoundaryByPaneKey.get(options.paneKey)
)
@@ -970,6 +986,10 @@ export function createAgentCompletionCoordinator(
options.dispatchHookLifecycle?.(payload)
return
}
const pendingStampedTail = pendingStampedTailByPaneKey.get(options.paneKey)
if (pendingStampedTail?.originCoordinatorId === coordinatorId) {
pendingStampedTailByPaneKey.delete(options.paneKey)
}
recordWorkingBoundary(payload.stateStartedAt)
clearPendingHookDone()
// Why: resumed work cancels the debounced attention so a self-resolving pause never notifies.
@@ -1011,6 +1031,13 @@ export function createAgentCompletionCoordinator(
consumePendingStampedTailForAgent(hookCompletionAgentIdentity(payload))
) {
// Why: paired host hooks carry the stamp while the sibling OSC coordinator sees only the matching all-clear.
lastCompletionIdentity = hookIdentity
? {
source: 'hook',
identity: hookIdentity,
agentIdentity: hookCompletionAgentIdentity(payload)
}
: null
options.dispatchHookLifecycle?.(payload)
return
}
@@ -1021,9 +1048,7 @@ export function createAgentCompletionCoordinator(
lastCompletedTurn === currentTurn)
) {
// Why: this `done` is the background all-clear of a turn already announced (or completed from another source). Keep pane lifecycle; do not raise a second banner.
if (pendingStampedTailByPaneKey.get(options.paneKey)?.turnCompletedAt === turnCompletedAt) {
pendingStampedTailByPaneKey.delete(options.paneKey)
}
consumeStampedTailForCurrentCoordinator(turnCompletedAt)
options.dispatchHookLifecycle?.(payload)
return
}