mirror of
https://github.com/stablyai/orca.git
synced 2026-10-22 16:03:47 +00:00
Deduplicate paired completion tails
This commit is contained in:
@@ -5,6 +5,8 @@ import type { RuntimeTerminalProcessInspection } from '@/runtime/runtime-termina
|
||||
|
||||
export type AgentCompletionStatusSnapshot = ParsedAgentStatusPayload & {
|
||||
stateStartedAt?: number
|
||||
/** Renderer-local boundary used only to reject a delayed cross-host completion. */
|
||||
localStateStartedAt?: number
|
||||
}
|
||||
|
||||
export type AgentCompletionDispatchMeta = {
|
||||
|
||||
@@ -1328,6 +1328,70 @@ describe('agent completion coordinator', () => {
|
||||
)
|
||||
})
|
||||
|
||||
it.each(['before', 'after'] as const)(
|
||||
'does not let a sibling OSC all-clear arriving %s the host stamp duplicate it',
|
||||
(allClearOrder) => {
|
||||
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: 'review the PR',
|
||||
agentType: 'claude',
|
||||
stateStartedAt: 5_000
|
||||
})
|
||||
hostCoordinator.observeHookStatus({
|
||||
state: 'working',
|
||||
prompt: 'review the PR',
|
||||
agentType: 'claude',
|
||||
stateStartedAt: 2_000
|
||||
})
|
||||
if (allClearOrder === 'before') {
|
||||
localCoordinator.observeHookStatus({
|
||||
state: 'done',
|
||||
prompt: 'review the PR',
|
||||
agentType: 'claude',
|
||||
stateStartedAt: 5_500
|
||||
})
|
||||
}
|
||||
hostCoordinator.observeHookStatus({
|
||||
state: 'working',
|
||||
prompt: 'review the PR',
|
||||
agentType: 'claude',
|
||||
stateStartedAt: 2_000,
|
||||
turnCompletedAt: 2_500
|
||||
})
|
||||
expect(dispatchCompletion).toHaveBeenCalledTimes(1)
|
||||
|
||||
if (allClearOrder === 'after') {
|
||||
localCoordinator.observeHookStatus({
|
||||
state: 'done',
|
||||
prompt: 'review the PR',
|
||||
agentType: 'claude',
|
||||
stateStartedAt: 5_500
|
||||
})
|
||||
}
|
||||
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()
|
||||
|
||||
@@ -211,6 +211,21 @@ export function createAgentCompletionCoordinator(
|
||||
)
|
||||
}
|
||||
|
||||
function consumePendingStampedTailForAgent(agentIdentity: string | null): boolean {
|
||||
const pendingStampedTail = pendingStampedTailByPaneKey.get(options.paneKey)
|
||||
const stampedCompletion = lastCompletionIdentityByPaneKey.get(options.paneKey)
|
||||
if (
|
||||
pendingStampedTail === undefined ||
|
||||
stampedCompletion?.lastTurnCompletedAtNotified !== pendingStampedTail ||
|
||||
agentIdentity === null ||
|
||||
stampedCompletion.agentIdentity !== agentIdentity
|
||||
) {
|
||||
return false
|
||||
}
|
||||
pendingStampedTailByPaneKey.delete(options.paneKey)
|
||||
return true
|
||||
}
|
||||
|
||||
function hookCompletionAgentIdentity(payload: AgentCompletionStatusSnapshot): string | null {
|
||||
return payload.agentType?.trim().toLowerCase() || null
|
||||
}
|
||||
@@ -283,7 +298,11 @@ export function createAgentCompletionCoordinator(
|
||||
return false
|
||||
}
|
||||
if (previous.source === completionIdentity.source) {
|
||||
return previous.identity === completionIdentity.identity
|
||||
return (
|
||||
previous.identity === completionIdentity.identity ||
|
||||
(completionIdentity.source === 'hook' &&
|
||||
consumePendingStampedTailForAgent(completionIdentity.agentIdentity))
|
||||
)
|
||||
}
|
||||
return (
|
||||
previous.agentIdentity !== null &&
|
||||
@@ -943,6 +962,14 @@ export function createAgentCompletionCoordinator(
|
||||
const turnCompletedAt = isFiniteTurnCompletedAt(payload.turnCompletedAt)
|
||||
? payload.turnCompletedAt
|
||||
: undefined
|
||||
if (
|
||||
turnCompletedAt === undefined &&
|
||||
consumePendingStampedTailForAgent(hookCompletionAgentIdentity(payload))
|
||||
) {
|
||||
// Why: paired host hooks carry the stamp while the sibling OSC coordinator sees only the matching all-clear.
|
||||
options.dispatchHookLifecycle?.(payload)
|
||||
return
|
||||
}
|
||||
if (
|
||||
turnCompletedAt !== undefined &&
|
||||
(turnCompletedAtAlreadyHandled(turnCompletedAt) ||
|
||||
|
||||
@@ -8,16 +8,20 @@ export function isSupersededAgentCompletionSnapshot(
|
||||
if (!storedAgentStatus || !snapshot) {
|
||||
return false
|
||||
}
|
||||
if (typeof snapshot.stateStartedAt !== 'number') {
|
||||
const comparableStateStartedAt = snapshot.localStateStartedAt ?? snapshot.stateStartedAt
|
||||
if (typeof comparableStateStartedAt !== 'number') {
|
||||
return storedAgentStatus.state !== snapshot.state
|
||||
}
|
||||
// Why: hook completion notifications are delayed by a quiet window; by the
|
||||
// time they fire, the same pane may already belong to a newer agent turn.
|
||||
if (storedAgentStatus.stateStartedAt > snapshot.stateStartedAt) {
|
||||
if (storedAgentStatus.stateStartedAt > comparableStateStartedAt) {
|
||||
return true
|
||||
}
|
||||
const hasStampedTurn =
|
||||
typeof snapshot.turnCompletedAt === 'number' && Number.isFinite(snapshot.turnCompletedAt)
|
||||
return (
|
||||
storedAgentStatus.stateStartedAt === snapshot.stateStartedAt &&
|
||||
storedAgentStatus.state !== snapshot.state
|
||||
storedAgentStatus.stateStartedAt === comparableStateStartedAt &&
|
||||
storedAgentStatus.state !== snapshot.state &&
|
||||
!hasStampedTurn
|
||||
)
|
||||
}
|
||||
|
||||
@@ -225,6 +225,64 @@ describe('dispatchTerminalNotification', () => {
|
||||
)
|
||||
})
|
||||
|
||||
it.each([
|
||||
{ clientStateStartedAt: 5_000, hostTurnCompletedAt: 2_000 },
|
||||
{ clientStateStartedAt: 2_000, hostTurnCompletedAt: 5_000 }
|
||||
])(
|
||||
'accepts a host-stamped completion across client/host clock skew %#',
|
||||
({ clientStateStartedAt, hostTurnCompletedAt }) => {
|
||||
mockState.agentStatusByPaneKey[paneKey] = makeAgentStatus(paneKey, {
|
||||
state: 'working',
|
||||
stateStartedAt: clientStateStartedAt,
|
||||
agentType: 'claude',
|
||||
terminalTitle: 'claude'
|
||||
})
|
||||
|
||||
dispatchTerminalNotification('wt-primary', {
|
||||
source: 'agent-task-complete',
|
||||
terminalTitle: 'claude',
|
||||
paneKey,
|
||||
agentStatusSnapshot: {
|
||||
state: 'done',
|
||||
prompt: 'review the PR',
|
||||
agentType: 'claude',
|
||||
stateStartedAt: hostTurnCompletedAt,
|
||||
localStateStartedAt: clientStateStartedAt,
|
||||
turnCompletedAt: hostTurnCompletedAt
|
||||
}
|
||||
})
|
||||
|
||||
expect(window.api.notifications.dispatch).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ source: 'agent-task-complete' })
|
||||
)
|
||||
}
|
||||
)
|
||||
|
||||
it('drops a host-stamped completion after a newer client turn starts', () => {
|
||||
mockState.agentStatusByPaneKey[paneKey] = makeAgentStatus(paneKey, {
|
||||
state: 'working',
|
||||
stateStartedAt: 6_000,
|
||||
agentType: 'claude',
|
||||
terminalTitle: 'claude'
|
||||
})
|
||||
|
||||
dispatchTerminalNotification('wt-primary', {
|
||||
source: 'agent-task-complete',
|
||||
terminalTitle: 'claude',
|
||||
paneKey,
|
||||
agentStatusSnapshot: {
|
||||
state: 'done',
|
||||
prompt: 'previous turn',
|
||||
agentType: 'claude',
|
||||
stateStartedAt: 2_000,
|
||||
localStateStartedAt: 5_000,
|
||||
turnCompletedAt: 2_000
|
||||
}
|
||||
})
|
||||
|
||||
expect(window.api.notifications.dispatch).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('uses a live pane key when inactive worktree tab membership is not hydrated', () => {
|
||||
mockState.tabsByWorktree = {}
|
||||
|
||||
|
||||
@@ -177,6 +177,15 @@ describe('paired session-tab agent completion notifications', () => {
|
||||
|
||||
expect(useAppStore.getState().agentStatusByPaneKey[paneKey]?.state).toBe('working')
|
||||
expect(mocks.observeAgentHookCompletionForNotification).toHaveBeenCalledTimes(3)
|
||||
expect(mocks.observeAgentHookCompletionForNotification).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
expect.objectContaining({
|
||||
payload: expect.objectContaining({
|
||||
localStateStartedAt: NOW,
|
||||
turnCompletedAt
|
||||
})
|
||||
})
|
||||
)
|
||||
expect(dispatchCompletion).toHaveBeenCalledTimes(1)
|
||||
expect(dispatchCompletion).toHaveBeenCalledWith(
|
||||
'claude',
|
||||
|
||||
@@ -3426,7 +3426,10 @@ export function applyWebSessionTabsStorePatch(
|
||||
const acceptedNotificationStatuses: {
|
||||
paneKey: string
|
||||
worktreeId: string
|
||||
payload: ReturnType<typeof pickParsedAgentStatusPayload> & { stateStartedAt: number }
|
||||
payload: ReturnType<typeof pickParsedAgentStatusPayload> & {
|
||||
stateStartedAt: number
|
||||
localStateStartedAt?: number
|
||||
}
|
||||
}[] = []
|
||||
useAppStore.setState((state) => {
|
||||
const patch = buildPatch(state)
|
||||
@@ -3476,7 +3479,13 @@ export function applyWebSessionTabsStorePatch(
|
||||
...notificationStatus,
|
||||
...(turnCompletedAt !== undefined ? { turnCompletedAt } : {})
|
||||
}),
|
||||
stateStartedAt: notificationStatus.stateStartedAt
|
||||
stateStartedAt: notificationStatus.stateStartedAt,
|
||||
...(clientOwnedNotification
|
||||
? {
|
||||
localStateStartedAt:
|
||||
state.agentStatusByPaneKey[notificationStatus.paneKey]?.stateStartedAt
|
||||
}
|
||||
: {})
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user