From ea6c69be9a193f1a5cf8e5a5fda28ea6c4253f46 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=B0=8F=E4=B8=83?= Date: Wed, 28 Oct 2026 11:46:00 +0800 Subject: [PATCH] fix(runtime): add opt-in agentWait to worktree ps agent rows (#24142) `orca worktree ps --agent-wait --json` now reports, on each terminal-backed agent row of the returned page, the same agentWait verdict `orca terminal show --json` gives for that terminal. The lookup is opt-in (RPC `includeAgentWait: true`) because it reads screens and may probe PTYs, which is too costly for default pollers. Hook rows use their terminal handle only when a connected PTY confirms it, and pane-only hook rows resolve the live terminal by pane key. Structured-worker rows and older hosts omit the field (absent = not evaluated; null = looked, no wait). Fixes #23921 Co-authored-by: Neil --- skill-guides/orca-cli.md | 1 + src/cli/bundled-skill-guides.ts | 1 + src/cli/flag-help-text.ts | 1 + src/cli/handlers/worktree.ts | 5 +- src/cli/root-help-text-secondary.ts | 2 +- src/cli/specs/core.ts | 9 +- .../worktree-ps-agent-wait.spec.ts | 82 +++++++++++++++++++ src/main/runtime/orca-runtime.test.ts | 1 + .../rpc/methods/worktree-catalog-methods.ts | 3 +- .../worktree-catalog-snapshot-method.test.ts | 8 +- .../runtime-worktree-agent-row-waits.ts | 40 +++++++++ .../runtime/runtime-worktree-agent-source.ts | 2 + .../runtime-worktree-pty-agent-sources.ts | 1 + .../workspaces/workspace-process-listing.ts | 20 +++-- src/shared/cli-argument-boundary.ts | 1 + src/shared/rpc-contract/worktree-params.ts | 4 +- src/shared/runtime-worktree-contracts.ts | 3 + 17 files changed, 165 insertions(+), 19 deletions(-) create mode 100644 src/main/runtime/orca-runtime-tests/worktree-ps-agent-wait.spec.ts create mode 100644 src/main/runtime/runtime-worktree-agent-row-waits.ts diff --git a/skill-guides/orca-cli.md b/skill-guides/orca-cli.md index 7ad248d4522..00ebf7da5be 100644 --- a/skill-guides/orca-cli.md +++ b/skill-guides/orca-cli.md @@ -81,6 +81,7 @@ ORCA repo set-base-ref --repo id: --ref origin/main --json ORCA repo search-refs --repo id: --query main --limit 10 --json ORCA worktree list --repo id: --json ORCA worktree ps --json +ORCA worktree ps --agent-wait --json ORCA worktree current --json ORCA worktree show --worktree --json ORCA worktree create --repo id: --name related-task --json diff --git a/src/cli/bundled-skill-guides.ts b/src/cli/bundled-skill-guides.ts index 694ce8f7a13..28637e271cc 100644 --- a/src/cli/bundled-skill-guides.ts +++ b/src/cli/bundled-skill-guides.ts @@ -464,6 +464,7 @@ const ORCA_CLI_MARKDOWN = [ "ORCA repo search-refs --repo id: --query main --limit 10 --json", "ORCA worktree list --repo id: --json", "ORCA worktree ps --json", + "ORCA worktree ps --agent-wait --json", "ORCA worktree current --json", "ORCA worktree show --worktree --json", "ORCA worktree create --repo id: --name related-task --json", diff --git a/src/cli/flag-help-text.ts b/src/cli/flag-help-text.ts index cefbfe52e36..7e38477c9af 100644 --- a/src/cli/flag-help-text.ts +++ b/src/cli/flag-help-text.ts @@ -2,6 +2,7 @@ export const FLAG_HELP_TEXT: Record = { 'external-worktree-visibility': '--external-worktree-visibility show|hide|inherit Override or inherit non-Orca worktree visibility', agent: '--agent Launch a known TUI agent in the first terminal', + 'agent-wait': "--agent-wait Add each agent row's terminal wait verdict to JSON output", 'base-branch': '--base-branch Base branch/ref to create the worktree from', command: '--command Command to run in the terminal on startup', comment: '--comment Comment stored in Orca metadata', diff --git a/src/cli/handlers/worktree.ts b/src/cli/handlers/worktree.ts index a0bc349ca5a..6a480f76a5a 100644 --- a/src/cli/handlers/worktree.ts +++ b/src/cli/handlers/worktree.ts @@ -92,7 +92,10 @@ export const WORKTREE_HANDLERS: Record = { 'worktree ps': async ({ flags, client, json }) => { const result = await client.call>( 'worktree.ps', - { limit: getOptionalPositiveIntegerFlag(flags, 'limit') } + { + limit: getOptionalPositiveIntegerFlag(flags, 'limit'), + ...(json && flags.has('agent-wait') ? { includeAgentWait: true } : {}) + } ) await annotateOmittedHostScope(result.result) const worktrees = result.result.worktrees.map(projectWorktreePsTerminalVerdict) diff --git a/src/cli/root-help-text-secondary.ts b/src/cli/root-help-text-secondary.ts index a950fce0a10..9e46cd66af3 100644 --- a/src/cli/root-help-text-secondary.ts +++ b/src/cli/root-help-text-secondary.ts @@ -55,7 +55,7 @@ export const ROOT_HELP_TEXT_SECONDARY = [ ' orca worktree current [--json]', ' orca worktree set --worktree [--display-name ] [--issue ] [--pr ] [--linear-issue ] [--gitlab-issue ] [--gitlab-mr ] [--comment ] [--workspace-status ] [--unread|--read] [--parent-worktree |--no-parent] [--json]', ' orca worktree rm --worktree [--force] [--run-hooks] [--allow-failed-archive-hook] [--json]', - ' orca worktree ps [--limit ] [--json]', + ' orca worktree ps [--limit ] [--agent-wait] [--json]', ' orca ports [--worktree ] [--all] [--json]', ' orca file open [--line [--column ]] [--worktree ] [--focus] [--json]', ' orca file diff [--staged] [--worktree ] [--focus] [--json]', diff --git a/src/cli/specs/core.ts b/src/cli/specs/core.ts index 42e7a38f9a1..decd5487eec 100644 --- a/src/cli/specs/core.ts +++ b/src/cli/specs/core.ts @@ -151,9 +151,12 @@ export const CORE_COMMAND_SPECS: CommandSpec[] = [ { path: ['worktree', 'ps'], summary: 'Show a compact orchestration summary across worktrees', - usage: 'orca worktree ps [--limit ] [--json]', - allowedFlags: [...GLOBAL_FLAGS, 'limit'], - notes: [...WORKTREE_LISTING_SCOPE_NOTES] + usage: 'orca worktree ps [--limit ] [--agent-wait] [--json]', + allowedFlags: [...GLOBAL_FLAGS, 'limit', 'agent-wait'], + notes: [ + ...WORKTREE_LISTING_SCOPE_NOTES, + 'JSON omits agentWait by default; pass --agent-wait to add the `terminal show` wait verdict to each terminal-backed agent row on the returned page.' + ] }, { path: ['terminal', 'list'], diff --git a/src/main/runtime/orca-runtime-tests/worktree-ps-agent-wait.spec.ts b/src/main/runtime/orca-runtime-tests/worktree-ps-agent-wait.spec.ts new file mode 100644 index 00000000000..3c9a4146f8a --- /dev/null +++ b/src/main/runtime/orca-runtime-tests/worktree-ps-agent-wait.spec.ts @@ -0,0 +1,82 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { makePaneKey } from '../../../shared/stable-pane-id' +import { OrcaRuntimeService } from '../orca-runtime-test-mocks.spec' +import { TEST_WORKTREE_ID, TEST_WORKTREE_PATH, store } from '../orca-runtime-test-fixtures.spec' +import { AgentHookServer, _internals } from '../../agent-hooks/server' + +vi.mock('../../telemetry/client', () => ({ track: vi.fn() })) + +async function makeRuntimeWithHookRow( + state: 'waiting' | 'working', + source: 'terminal' | 'http' = 'terminal' +) { + const statusStore = new AgentHookServer() + const runtime = new OrcaRuntimeService(store, undefined, { + getAgentStatusSnapshot: () => statusStore.getStatusSnapshot() + }) + runtime.setPtyController({ + spawn: vi.fn().mockResolvedValue({ id: 'pty-ps-agent-wait' }), + write: () => true, + kill: () => true, + getForegroundProcess: async () => null + }) + runtime.attachWindow(1) + runtime.syncWindowGraph(1, { tabs: [], leaves: [] }) + const { handle } = await runtime.createTerminal(`path:${TEST_WORKTREE_PATH}`, { + command: 'claude' + }) + const payload = { state, prompt: 'review the PR', agentType: 'claude' } as const + if (source === 'http') { + const paneKey = runtime.getTerminalPaneKey(handle) + if (!paneKey) { + throw new Error('expected the terminal to own a pane key') + } + // Hook posts carry only the pane key, so the row has no terminal handle to look up. + statusStore.ingestRemote({ paneKey, worktreeId: TEST_WORKTREE_ID, payload }, null) + expect(statusStore.getStatusSnapshot()[0]).not.toHaveProperty('terminalHandle') + return runtime + } + statusStore.ingestTerminalStatus({ + paneKey: makePaneKey('tab-ps-wait', '22222222-2222-4222-8222-222222222222'), + tabId: 'tab-ps-wait', + worktreeId: TEST_WORKTREE_ID, + connectionId: null, + terminalHandle: handle, + payload + }) + return runtime +} + +async function readAgentRow(runtime: OrcaRuntimeService, includeAgentWait: boolean) { + const { worktrees } = await runtime.getWorktreePs(undefined, true, includeAgentWait) + const agents = worktrees.find((entry) => entry.worktreeId === TEST_WORKTREE_ID)?.agents + expect(agents).toHaveLength(1) + return agents?.[0] +} + +describe('worktree ps agentWait', () => { + beforeEach(() => { + _internals.resetCachesForTests() + }) + + it('reports the terminal hook wait only when requested', async () => { + const runtime = await makeRuntimeWithHookRow('waiting') + const probe = vi.spyOn(runtime, 'getTerminalInteractiveWait') + + expect(await readAgentRow(runtime, false)).not.toHaveProperty('agentWait') + expect(probe).not.toHaveBeenCalled() + expect((await readAgentRow(runtime, true))?.agentWait).toMatchObject({ source: 'hook' }) + }) + + it('resolves the live terminal for a hook row that reported only its pane', async () => { + const runtime = await makeRuntimeWithHookRow('waiting', 'http') + + expect((await readAgentRow(runtime, true))?.agentWait).toMatchObject({ source: 'hook' }) + }) + + it('reports null when the terminal shows no wait', async () => { + const runtime = await makeRuntimeWithHookRow('working') + + expect((await readAgentRow(runtime, true))?.agentWait).toBeNull() + }) +}) diff --git a/src/main/runtime/orca-runtime.test.ts b/src/main/runtime/orca-runtime.test.ts index 652c6f30e80..6cec562476a 100644 --- a/src/main/runtime/orca-runtime.test.ts +++ b/src/main/runtime/orca-runtime.test.ts @@ -108,6 +108,7 @@ await import('./orca-runtime-tests/mobile-summaries-part-02.spec') await import('./orca-runtime-tests/mobile-summaries-part-03.spec') await import('./orca-runtime-tests/mobile-summaries-part-04.spec') await import('./orca-runtime-tests/worktree-ps-structured-host.spec') +await import('./orca-runtime-tests/worktree-ps-agent-wait.spec') await import('./orca-runtime-tests/worktree-ps-agent-row-dismissal.spec') await import('./orca-runtime-tests/terminal-sleep-and-teardown.spec') await import('./orca-runtime-tests/terminal-sleep-and-teardown-part-02.spec') diff --git a/src/main/runtime/rpc/methods/worktree-catalog-methods.ts b/src/main/runtime/rpc/methods/worktree-catalog-methods.ts index 36e5ef60e78..e2ec0805cda 100644 --- a/src/main/runtime/rpc/methods/worktree-catalog-methods.ts +++ b/src/main/runtime/rpc/methods/worktree-catalog-methods.ts @@ -28,7 +28,8 @@ export const WORKTREE_CATALOG_METHODS = [ supportsWorktreeVisibilitySourceDefaults( context, params.supportsWorktreeVisibilitySourceDefaults - ) + ), + params.includeAgentWait === true ), context, pendingAtScan diff --git a/src/main/runtime/rpc/methods/worktree-catalog-snapshot-method.test.ts b/src/main/runtime/rpc/methods/worktree-catalog-snapshot-method.test.ts index 33afbf25dd1..76ecd2e6011 100644 --- a/src/main/runtime/rpc/methods/worktree-catalog-snapshot-method.test.ts +++ b/src/main/runtime/rpc/methods/worktree-catalog-snapshot-method.test.ts @@ -90,7 +90,7 @@ describe('worktree.ps catalog snapshots', () => { id: 'current', authToken: 'token', method: 'worktree.ps', - params: { limit: 10_000 } + params: { limit: 10_000, includeAgentWait: true } }, () => {}, { clientCapabilities: [WORKTREE_VISIBILITY_SOURCE_DEFAULTS_RUNTIME_CAPABILITY] } @@ -106,9 +106,9 @@ describe('worktree.ps catalog snapshots', () => { { clientCapabilities: [] } ) - expect(runtime.getWorktreePs).toHaveBeenNthCalledWith(1, 10_000, false) - expect(runtime.getWorktreePs).toHaveBeenNthCalledWith(2, 10_000, true) - expect(runtime.getWorktreePs).toHaveBeenNthCalledWith(3, 10_000, true) + expect(runtime.getWorktreePs).toHaveBeenNthCalledWith(1, 10_000, false, false) + expect(runtime.getWorktreePs).toHaveBeenNthCalledWith(2, 10_000, true, true) + expect(runtime.getWorktreePs).toHaveBeenNthCalledWith(3, 10_000, true, false) }) }) diff --git a/src/main/runtime/runtime-worktree-agent-row-waits.ts b/src/main/runtime/runtime-worktree-agent-row-waits.ts new file mode 100644 index 00000000000..c2cd191922c --- /dev/null +++ b/src/main/runtime/runtime-worktree-agent-row-waits.ts @@ -0,0 +1,40 @@ +import type { + RuntimeTerminalInteractiveWait, + RuntimeWorktreePsSummary +} from '../../shared/runtime-types' +import type { RuntimeWorktreeAgentSource } from './runtime-worktree-agent-source' + +/** Gives each terminal-backed row on the page the same agentWait `terminal show` reports for its handle. */ +export async function attachRuntimeWorktreeAgentRowWaits< + TPage extends { worktrees: readonly RuntimeWorktreePsSummary[] } +>( + page: TPage, + rowSources: ReadonlyMap, + terminals: { + getTerminalInteractiveWait( + handle: string + ): Promise + getTerminalHandleForPaneKey(paneKey: string): string | null + } +): Promise { + await Promise.all( + page.worktrees.flatMap((summary) => + summary.agents.map(async (row) => { + const source = rowSources.get(row.paneKey) + // A hook handle counts only when a connected PTY confirmed it; otherwise resolve the live pane like `terminal show`. + const handle = + (source?.ptyId !== undefined ? source.terminalHandle : undefined) ?? + (row.hasLivePty ? terminals.getTerminalHandleForPaneKey(row.paneKey) : null) + if (!handle) { + return + } + const agentWait = await terminals.getTerminalInteractiveWait(handle) + // Undefined means the lookup could not evaluate, so the row claims no verdict. + if (agentWait !== undefined) { + row.agentWait = agentWait + } + }) + ) + ) + return page +} diff --git a/src/main/runtime/runtime-worktree-agent-source.ts b/src/main/runtime/runtime-worktree-agent-source.ts index 4671de31147..47c34f05ac3 100644 --- a/src/main/runtime/runtime-worktree-agent-source.ts +++ b/src/main/runtime/runtime-worktree-agent-source.ts @@ -11,6 +11,8 @@ export type RuntimeWorktreeAgentSource = Pick { if (!Number.isInteger(limit) || limit <= 0) { throw new Error('invalid_limit') @@ -117,26 +119,28 @@ export abstract class OrcaRuntimeWithGetWorktreePs extends OrcaRuntimeWithWorktr getTerminalHandlesForPty: (ptyId) => this.getExistingTerminalHandlesForPtyId(ptyId), getSummary }) + const rowSources = collectRuntimeWorktreeAgentSources({ + mirroredWorktreeIdByTabId, + connectedPtyEvidence, + hookSnapshots: this.getAgentStatusSnapshotFn?.() ?? [], + readStructuredSummary: getStructuredAgentSessionHost()?.readStatusSummary + }) attachRuntimeWorktreeAgentRows({ summaries, terminalTitleEvidenceByWorktreeId, - rowSources: collectRuntimeWorktreeAgentSources({ - mirroredWorktreeIdByTabId, - connectedPtyEvidence, - hookSnapshots: this.getAgentStatusSnapshotFn?.() ?? [], - readStructuredSummary: getStructuredAgentSessionHost()?.readStatusSummary - }), + rowSources, orchestrationByPaneKey: this.agentOrchestrationProjection.buildByPaneKey(), getSummary }) const sorted = [...summaries.values()].sort(compareWorktreePs) // Apply per-host pagination so hosts sorting last cannot disappear behind a global cap (#18104). - return buildWorktreeListingPage( + const page = buildWorktreeListingPage( sorted, limit, withoutHiddenHosts(this.listKnownExecutionHostIds(), runtimeHiddenHostIds(this.store)) ) + return includeAgentWait ? attachRuntimeWorktreeAgentRowWaits(page, rowSources, this) : page } /** Match desktop visibility, including imported worktrees, through the worktree.list gate. */ diff --git a/src/shared/cli-argument-boundary.ts b/src/shared/cli-argument-boundary.ts index 83a41d41d7c..e8d2cf71ced 100644 --- a/src/shared/cli-argument-boundary.ts +++ b/src/shared/cli-argument-boundary.ts @@ -3,6 +3,7 @@ export const CLI_GLOBAL_FLAGS: readonly string[] = ['help', 'json', ...CLI_GLOBA export const CLI_BOOLEAN_FLAGS = new Set([ 'accept-changed-state', + 'agent-wait', 'all', 'allow-failed-archive-hook', 'attachments', diff --git a/src/shared/rpc-contract/worktree-params.ts b/src/shared/rpc-contract/worktree-params.ts index 6a60b474f1e..3bd02841683 100644 --- a/src/shared/rpc-contract/worktree-params.ts +++ b/src/shared/rpc-contract/worktree-params.ts @@ -70,7 +70,9 @@ export const WorktreeTeardownMissingTerminalsParams = WorktreeDetectedListParams export const WorktreePsParams = z.object({ limit: OptionalFiniteNumber, afterSnapshotId: z.string().min(1).max(128).nullable().optional(), - supportsWorktreeVisibilitySourceDefaults: z.literal(true).optional() + supportsWorktreeVisibilitySourceDefaults: z.literal(true).optional(), + // Opt-in: wait lookups read screens and probe PTYs, too costly for pollers; older hosts omit agentWait. + includeAgentWait: z.literal(true).optional() }) export const WorktreeSortOrder = z.object({ diff --git a/src/shared/runtime-worktree-contracts.ts b/src/shared/runtime-worktree-contracts.ts index 1d73c315138..a455b343dd5 100644 --- a/src/shared/runtime-worktree-contracts.ts +++ b/src/shared/runtime-worktree-contracts.ts @@ -5,6 +5,7 @@ import type { ProgramStatusFacet } from './program-status/facet' import type { CheckStatus } from './github/pull-request-types' import type { AgentChildWorkView } from './agent-status-child-work-view' import type { AgentSubagentSnapshot } from './agent-status-subagent-snapshot' +import type { RuntimeTerminalInteractiveWait } from './runtime-terminal-contracts' import type { BaseRefSearchResult, Repo } from './repo-types' import type { CreateWorktreeResult, RemoveWorktreeResult } from './worktree/create-types' import type { @@ -38,6 +39,8 @@ export type RuntimeWorktreeAgentRow = { interrupted: boolean /** Main-agent state/verdict/clock even while subagents keep the row working; old hosts omit it, falling back to interrupted. */ mainAgent?: AgentMainAgentStatus + /** Same as `terminal show`, only when requested: null is "looked, no wait"; absent is not evaluated (structured workers, old hosts). */ + agentWait?: RuntimeTerminalInteractiveWait | null stateStartedAt: number updatedAt: number /** Native canonical views win over the CLI roster, including an explicit empty list. */