From e1599c94b8b6dd2a0cfa8be3dec87f067b54cbbf Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Sat, 5 Sep 2026 00:05:29 -0700 Subject: [PATCH] perf(terminals): let idle panes share one process-table capture instead of forking their own (#18742) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * perf(terminals): let idle panes share one process-table capture instead of forking their own Every visible local pane runs an agent-completion cadence that resolves through `getStrictProcessTableSnapshot`, and the inspection queue already collapses every shared-observation task enqueued in the same tick onto a single whole-host `ps`. Independent ±10% jitter per pane defeated that: the jitter was re-rolled on each reschedule, so panes drifted permanently apart, each landing in its own tick and each missing the snapshot's 500 ms TTL. Four idle panes cost four captures where one would have served all of them. Idle panes now aim at a deadline grid anchored at the epoch. The pull-forward is clamped to the snapshot TTL, so no interval is ever longer than its tier and none is more than 500 ms shorter: a pane off the grid walks onto it over at most `tier / TTL` steps, costs at most one extra inspection in total, and no inspection is ever delayed. Scoped deliberately. A pane with a foreground agent, or one still inside the 10 s post-activity hot window, keeps its exact interval and its own phase, so the bounded hot cadence is unchanged. The error-backoff path keeps its jitter, where spreading retries across panes is the point. Measured by `pnpm bench:agent-inspection-cadence` — whole-host `ps` captures over 60 s at the 2 s idle tier, median of 21 rounds: | visible panes | before | after | reduction | | --- | --- | --- | --- | | 1 | 29 | 29 | 0% | | 2 | 42 | 30 | 29% | | 4 | 62 | 31 | 50% | | 8 | 82 | 32 | 61% | `process-table-snapshot-reader.ts` measures the `command=` column at 1.15 s of work for 1,948 processes, so these are captures a quiet app was paying for continuously. All 4,202 existing terminal-pane tests pass unchanged, including the no-evidence cadence suite that pins the relaxed and hot intervals. * test(terminals): report n/a instead of dividing by a zero baseline in the cadence benchmark A window shorter than one cadence tier leaves the baseline capture count at zero, and the reduction line then divided by it and printed a meaningless percentage. Reported by CodeRabbit on #18742. --- ...-inspection-cadence-batching-benchmark.mjs | 139 ++++++++++++++++++ package.json | 1 + .../agent-completion-poll-interval.test.ts | 72 +++++++++ .../agent-completion-poll-interval.ts | 41 ++++++ .../agent-completion-poll-scheduler.ts | 15 +- 5 files changed, 267 insertions(+), 1 deletion(-) create mode 100644 config/scripts/agent-inspection-cadence-batching-benchmark.mjs create mode 100644 src/renderer/src/components/terminal-pane/agent-completion-poll-interval.test.ts create mode 100644 src/renderer/src/components/terminal-pane/agent-completion-poll-interval.ts diff --git a/config/scripts/agent-inspection-cadence-batching-benchmark.mjs b/config/scripts/agent-inspection-cadence-batching-benchmark.mjs new file mode 100644 index 00000000000..128627676c4 --- /dev/null +++ b/config/scripts/agent-inspection-cadence-batching-benchmark.mjs @@ -0,0 +1,139 @@ +#!/usr/bin/env node +// Counts how many whole-host process-table captures the agent-completion cadence costs. +// +// Local panes all resolve out of one TTL-deduped snapshot, and the inspection queue collapses +// every shared-observation task enqueued in the same tick onto a single capture. So the capture +// count is the number of DISTINCT wake instants across panes, not the number of pane wakes. +// +// This drives the production interval picker (`nextCadenceInspectionDelayMs`) against a baseline +// that reproduces the pre-change ±10% jitter, over a simulated wall-clock window. +import { spawnSync } from 'node:child_process' +import fs from 'node:fs' +import nodeModule from 'node:module' +import path from 'node:path' +import process from 'node:process' +import { fileURLToPath } from 'node:url' + +if (!process.execArgv.includes('--experimental-transform-types')) { + const result = spawnSync( + process.execPath, + ['--experimental-transform-types', '--no-warnings', import.meta.filename], + { stdio: 'inherit' } + ) + process.exit(result.status ?? 1) +} + +nodeModule.registerHooks({ + resolve(specifier, context, nextResolve) { + if (specifier.startsWith('.') && !/\.[cm]?[jt]s$/.test(specifier) && context.parentURL) { + const candidate = new URL(`${specifier}.ts`, context.parentURL) + if (fs.existsSync(fileURLToPath(candidate))) { + return { url: candidate.href, shortCircuit: true } + } + } + return nextResolve(specifier, context) + } +}) + +const ROOT = path.resolve(import.meta.dirname, '../..') +const WINDOW_MS = Number(process.env.ORCA_INSPECTION_BENCH_WINDOW_MS ?? '60000') +const PANE_COUNTS = (process.env.ORCA_INSPECTION_BENCH_PANES ?? '1,2,4,8') + .split(',') + .map((value) => Number(value.trim())) + +if (!Number.isSafeInteger(WINDOW_MS) || WINDOW_MS <= 0) { + throw new Error(`ORCA_INSPECTION_BENCH_WINDOW_MS must be a positive integer, got ${WINDOW_MS}`) +} +for (const paneCount of PANE_COUNTS) { + if (!Number.isSafeInteger(paneCount) || paneCount <= 0) { + throw new Error(`ORCA_INSPECTION_BENCH_PANES entries must be positive, got ${paneCount}`) + } +} + +const { nextCadenceInspectionDelayMs } = await import( + path.join(ROOT, 'src/renderer/src/components/terminal-pane/agent-completion-poll-interval.ts') +) +const { POLL_TIER_INTERVAL_MS } = await import( + path.join(ROOT, 'src/renderer/src/components/terminal-pane/agent-completion-poll-cadence.ts') +) +const { PROCESS_TABLE_SNAPSHOT_MAX_STALENESS_MS } = await import( + path.join(ROOT, 'src/shared/process-table-snapshot-reader.ts') +) + +// Pre-change: independent ±10% jitter per pane, re-rolled on every reschedule. +function baselineDelayMs(baseMs) { + return Math.round(baseMs * (1 + (Math.random() * 0.2 - 0.1))) +} + +function simulate(paneCount, baseMs, pickDelay) { + const startedAt = 1_700_000_000_000 + const wakes = [] + for (let pane = 0; pane < paneCount; pane += 1) { + // Panes mount at arbitrary moments, which is what spreads them apart in the first place. + let clock = startedAt + Math.floor(Math.random() * baseMs) + while ((clock += pickDelay(baseMs, clock)) < startedAt + WINDOW_MS) { + wakes.push(clock) + } + } + // A wake is served from the snapshot the previous capture produced until that snapshot's TTL + // lapses, so the TTL window starts at the capture, not on an epoch grid. + let captures = 0 + let snapshotExpiresAt = -Infinity + for (const wakeAt of wakes.sort((left, right) => left - right)) { + if (wakeAt >= snapshotExpiresAt) { + captures += 1 + snapshotExpiresAt = wakeAt + PROCESS_TABLE_SNAPSHOT_MAX_STALENESS_MS + } + } + return captures +} + +function medianOf(rounds, run) { + const samples = Array.from({ length: rounds }, run).sort((left, right) => left - right) + return samples[Math.floor(samples.length / 2)] +} + +const baseMs = POLL_TIER_INTERVAL_MS.idle +console.log( + `Agent-completion cadence — whole-host \`ps\` captures over ${WINDOW_MS / 1000}s at the idle tier (${baseMs}ms)\n` +) +console.log('| visible panes | before | after | reduction |') +console.log('| --- | --- | --- | --- |') +for (const paneCount of PANE_COUNTS) { + const before = medianOf(21, () => simulate(paneCount, baseMs, baselineDelayMs)) + const after = medianOf(21, () => + simulate(paneCount, baseMs, (base, now) => + nextCadenceInspectionDelayMs({ + baseMs: base, + hasConsecutiveErrors: false, + alignToSharedGrid: true, + now + }) + ) + ) + // A window shorter than one cadence tier can leave the baseline at zero; reporting a + // percentage off that divides by zero and prints a meaningless reduction. + const reduction = before > 0 ? `${(((before - after) / before) * 100).toFixed(0)}%` : 'n/a' + console.log(`| ${paneCount} | ${before} | ${after} | ${reduction} |`) +} + +// Detection latency must not regress: the grid deadline is always within one interval. +let worstDelay = 0 +for (let sample = 0; sample < 100_000; sample += 1) { + const now = 1_700_000_000_000 + sample * 7 + worstDelay = Math.max( + worstDelay, + nextCadenceInspectionDelayMs({ + baseMs, + hasConsecutiveErrors: false, + alignToSharedGrid: true, + now + }) + ) +} +if (worstDelay > baseMs) { + throw new Error(`grid alignment delayed a poll to ${worstDelay}ms, above the ${baseMs}ms tier`) +} +console.log( + `\nWorst observed wait: ${worstDelay}ms (tier interval ${baseMs}ms) — no inspection is ever delayed.` +) diff --git a/package.json b/package.json index 24a5ade2c7c..e8efe797974 100644 --- a/package.json +++ b/package.json @@ -141,6 +141,7 @@ "bench:main-thread-jank": "pnpm run ensure:electron-runtime && node tests/tools/benchmarks/main-thread-jank-bench.mjs", "bench:worktree-deletion": "node tests/tools/benchmarks/worktree-deletion-dev-bench.mjs", "bench:zustand-selector-fanout": "node config/scripts/zustand-selector-fanout-benchmark.mjs", + "bench:agent-inspection-cadence": "node config/scripts/agent-inspection-cadence-batching-benchmark.mjs", "bench:session-write-hot-path": "node config/scripts/session-write-hot-path-benchmark.mjs", "bench:worktree-refresh-churn": "node --disable-warning=MODULE_TYPELESS_PACKAGE_JSON config/scripts/worktree-refresh-churn-benchmark.mjs", "bench:multi-workspace-typing": "pnpm run ensure:electron-runtime && node config/scripts/run-multi-workspace-typing-bench.mjs", diff --git a/src/renderer/src/components/terminal-pane/agent-completion-poll-interval.test.ts b/src/renderer/src/components/terminal-pane/agent-completion-poll-interval.test.ts new file mode 100644 index 00000000000..fffbb94de90 --- /dev/null +++ b/src/renderer/src/components/terminal-pane/agent-completion-poll-interval.test.ts @@ -0,0 +1,72 @@ +import { describe, expect, it } from 'vitest' +import { PROCESS_TABLE_SNAPSHOT_MAX_STALENESS_MS } from '../../../../shared/process-table-snapshot-reader' +import { POLL_TIER_INTERVAL_MS } from './agent-completion-poll-cadence' +import { nextCadenceInspectionDelayMs } from './agent-completion-poll-interval' + +const IDLE_MS = POLL_TIER_INTERVAL_MS.idle + +describe('nextCadenceInspectionDelayMs', () => { + const alignedDelay = (now: number): number => + nextCadenceInspectionDelayMs({ + baseMs: IDLE_MS, + hasConsecutiveErrors: false, + alignToSharedGrid: true, + now + }) + + it('walks panes that scheduled at different moments onto one shared deadline', () => { + // Why this matters: the inspection queue collapses shared-observation tasks enqueued in the + // same tick onto one process-table capture, so a shared deadline is one `ps` for all panes. + const clocks = [0, 137, 999, 1_501].map((offset) => 1_700_000_000_000 + offset) + // Each pane may only be pulled forward by the snapshot TTL per step, so convergence takes + // at most IDLE_MS / TTL steps. + for (let step = 0; step < IDLE_MS / PROCESS_TABLE_SNAPSHOT_MAX_STALENESS_MS; step += 1) { + for (let pane = 0; pane < clocks.length; pane += 1) { + clocks[pane] += alignedDelay(clocks[pane]!) + } + } + + expect(new Set(clocks).size).toBe(1) + }) + + it('never waits longer than the tier interval, nor more than the snapshot TTL less', () => { + for (let offset = 0; offset < IDLE_MS * 3; offset += 1) { + const delay = alignedDelay(1_700_000_000_000 + offset) + expect(delay).toBeGreaterThanOrEqual(IDLE_MS - PROCESS_TABLE_SNAPSHOT_MAX_STALENESS_MS) + expect(delay).toBeLessThanOrEqual(IDLE_MS) + } + }) + + it('keeps jitter while backing off, so a failing host is not retried by every pane at once', () => { + const lowJitter = nextCadenceInspectionDelayMs({ + baseMs: IDLE_MS, + hasConsecutiveErrors: true, + alignToSharedGrid: true, + now: 1_700_000_000_000, + random: () => 0 + }) + const highJitter = nextCadenceInspectionDelayMs({ + baseMs: IDLE_MS, + hasConsecutiveErrors: true, + alignToSharedGrid: true, + now: 1_700_000_000_000, + random: () => 1 + }) + + expect(lowJitter).toBe(Math.round(IDLE_MS * 0.9)) + expect(highJitter).toBe(Math.round(IDLE_MS * 1.1)) + }) + + it('degrades safely on a non-positive interval', () => { + for (const baseMs of [0, -1, Number.NaN, Number.POSITIVE_INFINITY]) { + expect( + nextCadenceInspectionDelayMs({ + baseMs, + hasConsecutiveErrors: false, + alignToSharedGrid: true, + now: 1_700_000_000_000 + }) + ).toBe(0) + } + }) +}) diff --git a/src/renderer/src/components/terminal-pane/agent-completion-poll-interval.ts b/src/renderer/src/components/terminal-pane/agent-completion-poll-interval.ts new file mode 100644 index 00000000000..a8058bc2ac5 --- /dev/null +++ b/src/renderer/src/components/terminal-pane/agent-completion-poll-interval.ts @@ -0,0 +1,41 @@ +import { PROCESS_TABLE_SNAPSHOT_MAX_STALENESS_MS } from '../../../../shared/process-table-snapshot-reader' + +/** + * Picks the delay until a pane's next cadence inspection. + * + * Local panes all resolve out of one TTL-deduped process-table snapshot, and the inspection + * queue collapses every shared-observation task enqueued in the same tick onto a single host + * capture. Independent per-pane jitter defeated that: panes drifted apart, each landing in its + * own tick and forking its own `ps`. Snapping to a grid anchored at the epoch puts same-tier + * panes back in one tick, so N panes cost one capture instead of N. + * + * The pull-forward is clamped to the process-table snapshot TTL, so a pane never polls more than + * that early and never later than its tier interval. A pane off the grid therefore walks onto it + * in at most `baseMs / TTL` steps, costing at most one extra inspection in total, and no + * inspection is ever delayed. + * + * Alignment is scoped to genuinely idle panes: no foreground agent and no pane activity inside + * the hot window. A pane that just produced output keeps its exact interval, so the bounded + * post-activity cadence is unchanged, and the error-backoff path keeps its jitter — spreading + * retries across panes is the point when a host has just failed. + */ + +export function nextCadenceInspectionDelayMs(args: { + baseMs: number + hasConsecutiveErrors: boolean + alignToSharedGrid: boolean + now: number + random?: () => number +}): number { + const { alignToSharedGrid, baseMs, hasConsecutiveErrors, now } = args + if (!Number.isFinite(baseMs) || baseMs <= 0) { + return 0 + } + if (hasConsecutiveErrors || !alignToSharedGrid) { + const random = args.random ?? Math.random + return Math.round(baseMs * (1 + (random() * 0.2 - 0.1))) + } + const deadline = Math.floor((now + baseMs) / baseMs) * baseMs + const earliest = Math.max(1, baseMs - PROCESS_TABLE_SNAPSHOT_MAX_STALENESS_MS) + return Math.min(baseMs, Math.max(earliest, deadline - now)) +} diff --git a/src/renderer/src/components/terminal-pane/agent-completion-poll-scheduler.ts b/src/renderer/src/components/terminal-pane/agent-completion-poll-scheduler.ts index 8894edbd9c6..30a7b2e817e 100644 --- a/src/renderer/src/components/terminal-pane/agent-completion-poll-scheduler.ts +++ b/src/renderer/src/components/terminal-pane/agent-completion-poll-scheduler.ts @@ -7,6 +7,7 @@ import { POLL_TIER_INTERVAL_MS, type PollCadenceTier } from './agent-completion-poll-cadence' +import { nextCadenceInspectionDelayMs } from './agent-completion-poll-interval' export function createAgentCompletionPollScheduler(args: { options: AgentCompletionCoordinatorOptions @@ -88,7 +89,19 @@ export function createAgentCompletionPollScheduler(args: { state.consecutiveInspectionErrors > 0 ? Math.min(Math.max(10_000, base), base * 2 ** state.consecutiveInspectionErrors) : base - const interval = Math.round(backoff * (1 + (Math.random() * 0.2 - 0.1))) + const now = Date.now() + // Only genuinely idle panes share a deadline: a pane with a foreground agent, or one still + // inside the post-activity hot window, keeps its exact interval and its own phase. + const isIdlePane = + state.lastForegroundAgent === null && + (state.lastPaneActivityAt === null || + now - state.lastPaneActivityAt >= NO_EVIDENCE_ACTIVITY_HOT_WINDOW_MS) + const interval = nextCadenceInspectionDelayMs({ + baseMs: backoff, + hasConsecutiveErrors: state.consecutiveInspectionErrors > 0, + alignToSharedGrid: isIdlePane, + now + }) state.pollTimerTier = tier state.pollTimer = setTimeout(() => { state.pollTimer = null