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