perf: reduce hidden terminal process polling
Avoid per-pane notification-setting store subscribers and keep hidden idle terminals off the agent-completion foreground-process polling cadence until they have agent evidence.
This commit is contained in:
parent
5dc6947fc6
commit
c43092a63d
|
|
@ -12,6 +12,7 @@ export type AgentCompletionCoordinatorOptions = {
|
|||
) => Promise<RuntimeTerminalProcessInspection>
|
||||
dispatchCompletion: (title: string) => void
|
||||
isLive: () => boolean
|
||||
shouldPollProcessCadence?: () => boolean
|
||||
}
|
||||
|
||||
export type AgentCompletionCoordinator = {
|
||||
|
|
|
|||
|
|
@ -45,6 +45,47 @@ describe('agent completion coordinator', () => {
|
|||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
it('does not schedule cadence process inspections for hidden idle panes', () => {
|
||||
const inspectProcess = vi.fn(async () => processResult(null))
|
||||
const coordinator = createAgentCompletionCoordinator({
|
||||
paneKey: 'tab-1:leaf-1',
|
||||
getPtyId: () => 'pty-1',
|
||||
getSettings: () => null,
|
||||
inspectProcess,
|
||||
dispatchCompletion: vi.fn(),
|
||||
isLive: () => true,
|
||||
shouldPollProcessCadence: () => false
|
||||
})
|
||||
|
||||
coordinator.startProcessTracking()
|
||||
vi.advanceTimersByTime(10_000)
|
||||
|
||||
expect(inspectProcess).not.toHaveBeenCalled()
|
||||
expect(vi.getTimerCount()).toBe(0)
|
||||
})
|
||||
|
||||
it('keeps the process-exit backstop after hidden panes gain agent evidence', async () => {
|
||||
const inspectProcess = vi.fn(async () => processResult('codex'))
|
||||
const coordinator = createAgentCompletionCoordinator({
|
||||
paneKey: 'tab-1:leaf-1',
|
||||
getPtyId: () => 'pty-1',
|
||||
getSettings: () => null,
|
||||
inspectProcess,
|
||||
dispatchCompletion: vi.fn(),
|
||||
isLive: () => true,
|
||||
shouldPollProcessCadence: () => false
|
||||
})
|
||||
|
||||
coordinator.startProcessTracking()
|
||||
expect(vi.getTimerCount()).toBe(0)
|
||||
|
||||
coordinator.observeTitle('Codex working')
|
||||
vi.advanceTimersByTime(2_000)
|
||||
await flushAsyncTicks()
|
||||
|
||||
expect(inspectProcess).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('clears process evidence after agent exit so later non-agent spinner titles do not notify', async () => {
|
||||
let foregroundProcess: string | null = 'codex'
|
||||
const dispatchCompletion = vi.fn()
|
||||
|
|
|
|||
|
|
@ -83,6 +83,7 @@ export function createAgentCompletionCoordinator(
|
|||
function establishAgentEvidence(): void {
|
||||
agentIdentityEstablished = true
|
||||
hasAgentRunEvidence = true
|
||||
scheduleNextPoll()
|
||||
}
|
||||
|
||||
function clearAgentRunEvidence(): void {
|
||||
|
|
@ -218,6 +219,9 @@ export function createAgentCompletionCoordinator(
|
|||
if (disposed || inspectionInFlight || !options.isLive()) {
|
||||
return
|
||||
}
|
||||
if (priority === 'cadence' && !shouldRunCadenceInspection()) {
|
||||
return
|
||||
}
|
||||
const ptyId = options.getPtyId()
|
||||
if (!ptyId) {
|
||||
return
|
||||
|
|
@ -276,6 +280,17 @@ export function createAgentCompletionCoordinator(
|
|||
})
|
||||
}
|
||||
|
||||
function shouldRunCadenceInspection(): boolean {
|
||||
// Why: hidden idle terminals should not join the global process-inspection
|
||||
// cadence. Once a pane has agent evidence, keep the backstop alive so an
|
||||
// unannounced process exit can still produce/clear completion state.
|
||||
return (
|
||||
hasAgentRunEvidence ||
|
||||
lastForegroundAgent !== null ||
|
||||
options.shouldPollProcessCadence?.() !== false
|
||||
)
|
||||
}
|
||||
|
||||
function nextPollInterval(): number {
|
||||
const base = lastForegroundAgent ? ACTIVE_POLL_INTERVAL_MS : IDLE_POLL_INTERVAL_MS
|
||||
const backoff =
|
||||
|
|
@ -290,6 +305,9 @@ export function createAgentCompletionCoordinator(
|
|||
if (disposed || !options.isLive() || pollTimer !== null || pendingTitle) {
|
||||
return
|
||||
}
|
||||
if (!shouldRunCadenceInspection()) {
|
||||
return
|
||||
}
|
||||
const ptyId = options.getPtyId()
|
||||
if (!ptyId) {
|
||||
return
|
||||
|
|
|
|||
|
|
@ -3753,6 +3753,31 @@ describe('connectPanePty', () => {
|
|||
)
|
||||
})
|
||||
|
||||
it('shares one raw store subscriber for agent-complete notification settings across panes', async () => {
|
||||
const { connectPanePty } = await import('./pty-connection')
|
||||
transportFactoryQueue.push(createMockTransport('pty-1'), createMockTransport('pty-2'))
|
||||
|
||||
vi.useFakeTimers()
|
||||
const firstBinding = connectPanePty(
|
||||
createPane(1) as never,
|
||||
createManager(1) as never,
|
||||
createDeps() as never
|
||||
)
|
||||
const secondBinding = connectPanePty(
|
||||
createPane(2) as never,
|
||||
createManager(1) as never,
|
||||
createDeps() as never
|
||||
)
|
||||
await flushAsyncTicks()
|
||||
|
||||
expect(storeSubscribers).toHaveLength(1)
|
||||
|
||||
firstBinding.dispose()
|
||||
expect(storeSubscribers).toHaveLength(1)
|
||||
secondBinding.dispose()
|
||||
expect(storeSubscribers).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('does not dispatch generic title completions when agent-complete notifications are disabled', async () => {
|
||||
const { connectPanePty } = await import('./pty-connection')
|
||||
const transport = createMockTransport('pty-codex')
|
||||
|
|
|
|||
|
|
@ -120,13 +120,53 @@ let codexRestartNoticePresence = false
|
|||
|
||||
export type PanePtyBinding = IDisposable & {
|
||||
syncRendererOutputVisibility: () => void
|
||||
syncProcessTracking: () => void
|
||||
}
|
||||
|
||||
function isAgentTaskCompleteNotificationEnabled(): boolean {
|
||||
const notifications = useAppStore.getState().settings?.notifications
|
||||
return isAgentTaskCompleteNotificationEnabledFromState(useAppStore.getState())
|
||||
}
|
||||
|
||||
function isAgentTaskCompleteNotificationEnabledFromState(
|
||||
state: ReturnType<typeof useAppStore.getState>
|
||||
): boolean {
|
||||
const notifications = state.settings?.notifications
|
||||
return notifications?.enabled !== false && notifications?.agentTaskComplete !== false
|
||||
}
|
||||
|
||||
const agentTaskCompleteNotificationEnabledListeners = new Set<() => void>()
|
||||
let agentTaskCompleteNotificationSettingsUnsubscribe: (() => void) | null = null
|
||||
let agentTaskCompleteNotificationEnabledSnapshot: boolean | null = null
|
||||
|
||||
function subscribeAgentTaskCompleteNotificationEnabled(listener: () => void): () => void {
|
||||
if (agentTaskCompleteNotificationSettingsUnsubscribe === null) {
|
||||
agentTaskCompleteNotificationEnabledSnapshot = isAgentTaskCompleteNotificationEnabled()
|
||||
agentTaskCompleteNotificationSettingsUnsubscribe = useAppStore.subscribe((state) => {
|
||||
const enabled = isAgentTaskCompleteNotificationEnabledFromState(state)
|
||||
if (enabled === agentTaskCompleteNotificationEnabledSnapshot) {
|
||||
return
|
||||
}
|
||||
agentTaskCompleteNotificationEnabledSnapshot = enabled
|
||||
for (const subscriber of Array.from(agentTaskCompleteNotificationEnabledListeners)) {
|
||||
subscriber()
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
agentTaskCompleteNotificationEnabledListeners.add(listener)
|
||||
return () => {
|
||||
agentTaskCompleteNotificationEnabledListeners.delete(listener)
|
||||
if (
|
||||
agentTaskCompleteNotificationEnabledListeners.size === 0 &&
|
||||
agentTaskCompleteNotificationSettingsUnsubscribe !== null
|
||||
) {
|
||||
agentTaskCompleteNotificationSettingsUnsubscribe()
|
||||
agentTaskCompleteNotificationSettingsUnsubscribe = null
|
||||
agentTaskCompleteNotificationEnabledSnapshot = null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function hasAgentNotificationDetail(entry: AgentStatusEntry | undefined): boolean {
|
||||
return Boolean(
|
||||
entry &&
|
||||
|
|
@ -560,6 +600,8 @@ export function connectPanePty(
|
|||
getSettings: () => useAppStore.getState().settings,
|
||||
inspectProcess: inspectRuntimeTerminalProcess,
|
||||
dispatchCompletion: (title) => scheduleAgentTaskCompleteNotification(title),
|
||||
shouldPollProcessCadence: () =>
|
||||
isAgentTaskCompleteNotificationEnabled() && deps.isVisibleRef.current,
|
||||
isLive: () => {
|
||||
if (disposed) {
|
||||
return false
|
||||
|
|
@ -896,9 +938,9 @@ export function connectPanePty(
|
|||
AGENT_TASK_COMPLETE_NOTIFICATION_MAX_WAIT_MS
|
||||
)
|
||||
}
|
||||
agentTaskCompleteSettingsUnsubscribe = useAppStore.subscribe((state, previousState) => {
|
||||
if (state.settings?.notifications !== previousState?.settings?.notifications) {
|
||||
syncAgentTaskCompleteNotificationEnabled()
|
||||
agentTaskCompleteSettingsUnsubscribe = subscribeAgentTaskCompleteNotificationEnabled(() => {
|
||||
if (syncAgentTaskCompleteNotificationEnabled()) {
|
||||
agentCompletionCoordinator.startProcessTracking()
|
||||
}
|
||||
})
|
||||
|
||||
|
|
@ -2482,6 +2524,9 @@ export function connectPanePty(
|
|||
syncRendererOutputVisibility() {
|
||||
syncRendererOutputVisibility()
|
||||
},
|
||||
syncProcessTracking() {
|
||||
agentCompletionCoordinator.startProcessTracking()
|
||||
},
|
||||
dispose() {
|
||||
disposed = true
|
||||
if (terminalKeyTargetSupportsEvents) {
|
||||
|
|
|
|||
|
|
@ -1190,8 +1190,10 @@ export function useTerminalPaneLifecycle({
|
|||
for (const panePtyBinding of panePtyBindingsRef.current.values()) {
|
||||
const bindingWithVisibility = panePtyBinding as IDisposable & {
|
||||
syncRendererOutputVisibility?: () => void
|
||||
syncProcessTracking?: () => void
|
||||
}
|
||||
bindingWithVisibility.syncRendererOutputVisibility?.()
|
||||
bindingWithVisibility.syncProcessTracking?.()
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps -- Why: visibility flips must notify existing PTY bindings even though the ref object identity is stable.
|
||||
}, [isVisible, isVisibleRef, panePtyBindingsRef])
|
||||
|
|
|
|||
Loading…
Reference in New Issue