From 6cc4d70ef8601348d671505b3b39ee50f1c995f1 Mon Sep 17 00:00:00 2001 From: Max <112043822+Maxaubert@users.noreply.github.com> Date: Fri, 16 Oct 2026 07:29:25 +0200 Subject: [PATCH] fix(agent-poll): forced looks and a failing query no longer launch PowerShell nonstop (core 0.41.1) Perf review 2026-10-16 (research\prism-terminal\2026-10-16-perf-review.md), the worst finding and two small ones: - Every title frame and hook signal of an agent the poll had not reported forced a PowerShell + CIM query. A Claude the poll can never see (under WSL or SSH) therefore kept queries running back to back while it worked, half to a whole core. The page now asks at most once in 15 s per tab, and main holds forced looks 5 s apart (AGENT_FORCE_GAP). - A query that always fails (WMI broken, AppLocker, Constrained Language) launched PowerShell every 2.5 s for the app's life. Failures now back off, doubling from 5 s to a minute (AGENT_FAIL_MAX), forced looks included, and the third in a row writes `agent-poll-failing` to the diagnostics log. - A pointer moving over the unfocused window re-rendered App twice a second; it now does only when the tab in front has a Finished or Failed mark to take down. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_017XYVRPzvhLpPCr75uB6uaA --- core/main/agentPoll.test.ts | 54 +++++++++++++++++++++++++- core/main/agentPoll.ts | 36 ++++++++++++++++- core/package.json | 2 +- core/renderer/lib/useAgentIndicator.ts | 32 +++++++++++++-- core/shared/termVersion.ts | 2 +- package-lock.json | 4 +- package.json | 2 +- 7 files changed, 119 insertions(+), 13 deletions(-) diff --git a/core/main/agentPoll.test.ts b/core/main/agentPoll.test.ts index b5a3c344..d29f6667 100644 --- a/core/main/agentPoll.test.ts +++ b/core/main/agentPoll.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest' -import { AGENT_POLL_MAX, AGENT_POLL_MIN, createAgentPoll, type AgentPollDeps } from './agentPoll' +import { AGENT_FAIL_MAX, AGENT_FORCE_GAP, AGENT_POLL_MAX, AGENT_POLL_MIN, createAgentPoll, type AgentPollDeps } from './agentPoll' /** * The poll's three rules, checked without a process list: ask only when a @@ -47,9 +47,10 @@ describe('the agent poll', () => { w.set({ out: '100 1\n200 100 claude\n', now: 1 }) // claude starts, silently, inside the backoff poll() expect(w.sent).toEqual([['t1', false, null]]) + w.set({ now: AGENT_FORCE_GAP }) poll(true) expect(w.sent).toEqual([['t1', false, null], ['t1', true, 'claude']]) - w.set({ out: '100 1\n', now: 2 }) // and quits + w.set({ out: '100 1\n', now: 2 * AGENT_FORCE_GAP }) // and quits poll(true) expect(w.sent.at(-1)).toEqual(['t1', false, null]) const before = w.sent.length @@ -140,10 +141,59 @@ describe('the agent poll', () => { // Same tick count, but there is still no answer: it asks again. w.set({ out: `100 1\n200 100 node ${CLAUDE}\n` }) poll() + // (Inside the failure's backoff it waits; past it, it asks again.) + expect(w.asked()).toBe(1) + w.set({ now: 2 * AGENT_POLL_MIN }) + poll() expect(w.asked()).toBe(2) expect(w.sent).toEqual([['t1', true, 'claude']]) }) + // Perf review 2026-10-16: a query that always failed launched PowerShell + // every 2.5 s for the app's life, and a Claude the poll cannot see (WSL, + // SSH) had every title frame force a new one. + it('a failing query backs off, doubling to a minute, forced looks included, and says so once', () => { + const w = world() + let failing = 0 + const poll = createAgentPoll(w.send, { ...w.deps, failing: () => (failing += 1) }) + w.set({ out: null }) + let t = 0 + const times: number[] = [] + for (let i = 0; i < 400; i += 1) { + t += 500 + w.set({ now: t, ticks: i }) + const before = w.asked() + poll(i % 2 === 0) + if (w.asked() > before) times.push(t) + } + const gaps = times.slice(1).map((x, i) => x - times[i]) + expect(gaps.slice(0, 4)).toEqual([5000, 10000, 20000, 40000]) + expect(Math.max(...gaps)).toBe(AGENT_FAIL_MAX) + expect(failing).toBe(1) + // An answer resets it: the next look comes at the usual pace. + w.set({ out: '100 1\n', now: t + AGENT_FAIL_MAX }) + poll() + w.set({ now: t + AGENT_FAIL_MAX + AGENT_POLL_MIN, ticks: 999 }) + const before = w.asked() + poll() + expect(w.asked()).toBe(before + 1) + }) + + it('a forced look waits for the gap since the last query, then looks', () => { + const w = world() + const poll = createAgentPoll(w.send, w.deps) + poll() + expect(w.asked()).toBe(1) + for (let i = 1; i < 10; i += 1) { + w.set({ now: i * 400 }) + poll(true) + } + expect(w.asked()).toBe(1) + w.set({ now: AGENT_FORCE_GAP }) + poll(true) + expect(w.asked()).toBe(2) + }) + it('forgets a session that ended, so its id starts clean', () => { const w = world() const poll = createAgentPoll(w.send, w.deps) diff --git a/core/main/agentPoll.ts b/core/main/agentPoll.ts index cb2091f0..5244abb7 100644 --- a/core/main/agentPoll.ts +++ b/core/main/agentPoll.ts @@ -3,12 +3,26 @@ import { execFile } from 'child_process' import type { DetectedAgent } from '../shared/types' import { parseProcLines, treeAgentKind } from './agentDetect' import { livePids, ptyOutputTicks } from './terminal' +import { diagMain } from './diagLog' /* ------------------------------------------------------------------ * * The agent poll's shape. See the poll itself for why it is like this. * ------------------------------------------------------------------ */ export const AGENT_POLL_MIN = 2500 export const AGENT_POLL_MAX = 20000 +/** + * A FORCED LOOK WAITS THIS LONG after the last query started, and a query + * that FAILS backs off doubling up to the ceiling below, forced looks + * included (perf review 2026-10-16). Before, a title or hook signal forced a + * PowerShell + CIM query on every frame of a Claude the poll cannot see + * (Claude under WSL or SSH: its process is not in the Windows tree, so the + * poll never reports it and the renderer asks again on the next frame), half + * to a whole core while it worked; and a query that always fails (WMI broken, + * AppLocker, Constrained Language) launched PowerShell every 2.5 s for the + * app's whole life. + */ +export const AGENT_FORCE_GAP = 5000 +export const AGENT_FAIL_MAX = 60000 /** * "pid ppid" for everything, plus the command line only where a cheap word * match hits. The full dump with every command line was megabytes; this is a @@ -42,13 +56,17 @@ export interface AgentPollDeps { ticks: () => number query: (done: (stdout: string | null) => void) => void now: () => number + /** Said once when the query has failed three times in a row: why agent + * marks are missing, for the diagnostics log. */ + failing?: () => void } const REAL: AgentPollDeps = { pids: livePids, ticks: ptyOutputTicks, query: queryProcesses, - now: () => Date.now() + now: () => Date.now(), + failing: () => diagMain().write('main', 'agent-poll-failing', { note: 'the process query keeps failing: agent marks rely on titles and hooks alone' }) } /** @@ -90,11 +108,20 @@ export function createAgentPoll(send: AgentSend, deps: AgentPollDeps = REAL): Ag // started again between two looks. Held until a look STARTS, so a look // already in flight, taken before the ask, does not spend it. const asked = new Set() + /** When the last query started, and the failures in a row since one answered. */ + let lastStart = Number.NEGATIVE_INFINITY + let fails = 0 + let failNext = 0 // `force` skips the two waits below, never the answer (#73): a look the // renderer asked for sends only what CHANGED, like any other. - const poll = (force = false): void => { + const poll = (asForced = false): void => { const pids = deps.pids() if (!pids.length || agentBusy) return + const startAt = deps.now() + // A failing query backs off for everyone, forced looks too. + if (fails && startAt < failNext) return + // A forced look inside the gap is an ordinary one: its waits apply. + const force = asForced && startAt - lastStart >= AGENT_FORCE_GAP const ticks = deps.ticks() const quiet = ticks === agentSeenTicks const known = pids.every((s) => agentState.has(s.id) && !asked.has(s.id)) @@ -107,6 +134,7 @@ export function createAgentPoll(send: AgentSend, deps: AgentPollDeps = REAL): Ag const now = deps.now() if (known && now < agentNext && !force) return agentBusy = true + lastStart = startAt const asking = new Set(asked) asked.clear() deps.query((stdout) => { @@ -118,8 +146,12 @@ export function createAgentPoll(send: AgentSend, deps: AgentPollDeps = REAL): Ag const rows = stdout ? parseProcLines(stdout) : [] if (!rows.length) { asking.forEach((id) => asked.add(id)) + fails += 1 + failNext = deps.now() + Math.min(AGENT_FAIL_MAX, AGENT_POLL_MIN * 2 ** fails) + if (fails === 3) deps.failing?.() return } + fails = 0 agentSeenTicks = ticks let changed = false for (const { id, pid } of deps.pids()) { diff --git a/core/package.json b/core/package.json index 075d114b..d035afb6 100644 --- a/core/package.json +++ b/core/package.json @@ -1,6 +1,6 @@ { "name": "prism-term-core", - "version": "0.41.0", + "version": "0.41.1", "description": "What Prism Terminal and Prism share: the terminal (pty, shells, agent detection and indicator, themes, links, the panel, dictation) and the update chip with its window. TypeScript source, compiled by the host.", "license": "MIT", "private": true, diff --git a/core/renderer/lib/useAgentIndicator.ts b/core/renderer/lib/useAgentIndicator.ts index 455da71f..885111ed 100644 --- a/core/renderer/lib/useAgentIndicator.ts +++ b/core/renderer/lib/useAgentIndicator.ts @@ -101,6 +101,13 @@ export function useAgentIndicator(activeId: string | null): AgentIndicator { */ const [moved, setMoved] = useState(0) const movedAt = useRef(0) + // Whether a look by the pointer could take anything down: only a Finished + // or Failed mark on the tab in front (perf review 2026-10-16: every move + // over the unfocused window re-rendered App twice a second for nothing). + const frontMarked = useRef(false) + useEffect(() => { + frontMarked.current = !!activeId && (doneIds.has(activeId) || failedIds.has(activeId)) + }, [activeId, doneIds, failedIds]) useEffect(() => { const MOVE_LOOK_MS = 1000 let last = 0 @@ -111,6 +118,7 @@ export function useAgentIndicator(activeId: string | null): AgentIndicator { if (looking.current.focused) return const now = Date.now() movedAt.current = now + if (!frontMarked.current) return // A look every half second at most: a moving pointer fires constantly. if (now - last < MOVE_LOOK_MS / 2) return last = now @@ -156,6 +164,21 @@ export function useAgentIndicator(activeId: string | null): AgentIndicator { const titled = useRef(new Set()) /** Sessions the process poll has said host an agent. */ const polled = useRef(new Set()) + /** + * ASK THE POLL TO LOOK, AT MOST ONCE IN 15 S PER TAB (perf review + * 2026-10-16). Every title frame and hook signal of an agent the poll has + * not reported asked for a look, and a Claude the poll can never see (under + * WSL or SSH) kept PowerShell queries running back to back while it worked. + * Main also holds forced looks 5 s apart (agentPoll). + */ + const lookAsked = useRef(new Map()) + const askLook = useCallback((id: string): void => { + if (polled.current.has(id)) return + const now = Date.now() + if (now - (lookAsked.current.get(id) ?? Number.NEGATIVE_INFINITY) < 15_000) return + lookAsked.current.set(id, now) + termApi().termAgentLook?.() + }, []) /** The one clearing timer per fallback-scored session. */ const fallbackTimers = useRef(new Map()) const stopFallback = useCallback((id: string): void => { @@ -389,7 +412,7 @@ export function useAgentIndicator(activeId: string | null): AgentIndicator { // Present by its title alone: have the poll look now, so it sees the // agent while it runs and can say when it leaves (#73). Only the poll // ever takes a title's claim back. - if (!polled.current.has(id)) termApi().termAgentLook?.() + askLook(id) if (!titled.current.has(id)) { titled.current.add(id) outputRuns.current.delete(id) @@ -424,7 +447,7 @@ export function useAgentIndicator(activeId: string | null): AgentIndicator { return next }) }), - [stopFallback, checkQuestion, applyHook, stopAnswerCheck] + [stopFallback, checkQuestion, applyHook, stopAnswerCheck, askLook] ) /** @@ -437,7 +460,7 @@ export function useAgentIndicator(activeId: string | null): AgentIndicator { () => onAgentSignal((id, signal) => { logAgentHook(id, signal.state, signal.kind) - if (!polled.current.has(id)) termApi().termAgentLook?.() + askLook(id) if (!hooked.current.has(id)) { outputRuns.current.delete(id) stopFallback(id) @@ -449,7 +472,7 @@ export function useAgentIndicator(activeId: string | null): AgentIndicator { setAgentIds((prev) => (prev.has(id) ? prev : new Set(prev).add(id))) applyHook(id, signal) }), - [stopFallback, applyHook] + [stopFallback, applyHook, askLook] ) /** @@ -557,6 +580,7 @@ export function useAgentIndicator(activeId: string | null): AgentIndicator { outputRuns.current.delete(id) titled.current.delete(id) polled.current.delete(id) + lookAsked.current.delete(id) agentKinds.current.delete(id) forgetAgentTitle(id) stopFallback(id) diff --git a/core/shared/termVersion.ts b/core/shared/termVersion.ts index 84e2103e..818c17a6 100644 --- a/core/shared/termVersion.ts +++ b/core/shared/termVersion.ts @@ -7,5 +7,5 @@ * so a core release that bumps `core/package.json` (or xterm) without this * fails `npm test` (core/README.md, "Releasing the core"). */ -export const TERM_CORE_VERSION = '0.41.0' +export const TERM_CORE_VERSION = '0.41.1' export const XTERM_VERSION = '6.0.0' diff --git a/package-lock.json b/package-lock.json index ffa20137..6153aaee 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "prism-terminal", - "version": "0.55.0", + "version": "0.55.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "prism-terminal", - "version": "0.55.0", + "version": "0.55.1", "license": "MIT", "dependencies": { "@xterm/addon-fit": "^0.11.0", diff --git a/package.json b/package.json index 22857717..dc3aa160 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "prism-terminal", "productName": "Prism Terminal", - "version": "0.55.0", + "version": "0.55.1", "description": "A tabbed Windows terminal for AI CLIs.", "main": "./out/main/index.js", "author": "Max",