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 <neil@stably.ai>
This commit is contained in:
小七
2026-10-27 20:46:00 -07:00
committed by GitHub
co-authored by Neil
parent 40721a849b
commit ea6c69be9a
17 changed files with 165 additions and 19 deletions
+1
View File
@@ -81,6 +81,7 @@ ORCA repo set-base-ref --repo id:<repoId> --ref origin/main --json
ORCA repo search-refs --repo id:<repoId> --query main --limit 10 --json
ORCA worktree list --repo id:<repoId> --json
ORCA worktree ps --json
ORCA worktree ps --agent-wait --json
ORCA worktree current --json
ORCA worktree show --worktree <selector> --json
ORCA worktree create --repo id:<repoId> --name related-task --json
+1
View File
@@ -464,6 +464,7 @@ const ORCA_CLI_MARKDOWN = [
"ORCA repo search-refs --repo id:<repoId> --query main --limit 10 --json",
"ORCA worktree list --repo id:<repoId> --json",
"ORCA worktree ps --json",
"ORCA worktree ps --agent-wait --json",
"ORCA worktree current --json",
"ORCA worktree show --worktree <selector> --json",
"ORCA worktree create --repo id:<repoId> --name related-task --json",
+1
View File
@@ -2,6 +2,7 @@ export const FLAG_HELP_TEXT: Record<string, string> = {
'external-worktree-visibility':
'--external-worktree-visibility show|hide|inherit Override or inherit non-Orca worktree visibility',
agent: '--agent <id> 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 <ref> Base branch/ref to create the worktree from',
command: '--command <text> Command to run in the terminal on startup',
comment: '--comment <text> Comment stored in Orca metadata',
+4 -1
View File
@@ -92,7 +92,10 @@ export const WORKTREE_HANDLERS: Record<string, CommandHandler> = {
'worktree ps': async ({ flags, client, json }) => {
const result = await client.call<WithAnnotatedHostScope<RuntimeWorktreePsResult>>(
'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)
+1 -1
View File
@@ -55,7 +55,7 @@ export const ROOT_HELP_TEXT_SECONDARY = [
' orca worktree current [--json]',
' orca worktree set --worktree <selector> [--display-name <name>] [--issue <number|null>] [--pr <number|null>] [--linear-issue <identifier-or-url|null>] [--gitlab-issue <number-or-url|null>] [--gitlab-mr <number-or-url|null>] [--comment <text>] [--workspace-status <id>] [--unread|--read] [--parent-worktree <selector>|--no-parent] [--json]',
' orca worktree rm --worktree <selector> [--force] [--run-hooks] [--allow-failed-archive-hook] [--json]',
' orca worktree ps [--limit <n>] [--json]',
' orca worktree ps [--limit <n>] [--agent-wait] [--json]',
' orca ports [--worktree <selector>] [--all] [--json]',
' orca file open <path> [--line <n> [--column <n>]] [--worktree <selector>] [--focus] [--json]',
' orca file diff <path> [--staged] [--worktree <selector>] [--focus] [--json]',
+6 -3
View File
@@ -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 <n>] [--json]',
allowedFlags: [...GLOBAL_FLAGS, 'limit'],
notes: [...WORKTREE_LISTING_SCOPE_NOTES]
usage: 'orca worktree ps [--limit <n>] [--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'],
@@ -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()
})
})
+1
View File
@@ -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')
@@ -28,7 +28,8 @@ export const WORKTREE_CATALOG_METHODS = [
supportsWorktreeVisibilitySourceDefaults(
context,
params.supportsWorktreeVisibilitySourceDefaults
)
),
params.includeAgentWait === true
),
context,
pendingAtScan
@@ -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)
})
})
@@ -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<string, RuntimeWorktreeAgentSource>,
terminals: {
getTerminalInteractiveWait(
handle: string
): Promise<RuntimeTerminalInteractiveWait | null | undefined>
getTerminalHandleForPaneKey(paneKey: string): string | null
}
): Promise<TPage> {
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
}
@@ -11,6 +11,8 @@ export type RuntimeWorktreeAgentSource = Pick<AgentStatusIpcPayload, 'subagents'
children?: AgentChildWorkView[]
paneKey: string
ptyId?: string
/** Issued handle the hook row was observed under; keys the agentWait lookup. */
terminalHandle?: string
tabId?: string
/** A connected PTY on this host backs the row. */
hasLivePty?: true
@@ -60,6 +60,7 @@ export function collectRuntimeWorktreePtyAgentSources(args: {
ptyId: entry.terminalHandle
? args.connectedPtyEvidence.ptyIdByTerminalHandle.get(entry.terminalHandle)
: undefined,
terminalHandle: entry.terminalHandle,
tabId: entry.tabId,
worktreeId: entry.worktreeId,
connectionId: entry.connectionId,
@@ -16,6 +16,7 @@ import {
} from '../runtime-worktree-ps-activity'
import { applyRuntimeWorktreePsUnverifiableTerminals } from '../runtime-worktree-ps-unverifiable-terminals'
import { attachRuntimeWorktreeAgentRows } from '../runtime-worktree-agent-rows'
import { attachRuntimeWorktreeAgentRowWaits } from '../runtime-worktree-agent-row-waits'
import { compareWorktreePs } from '../runtime-worktree-status-projection'
import type { Repo } from '../../../shared/repo-types'
import type { StructuredAgentId } from '../../../shared/agent-session-provider-handle'
@@ -64,7 +65,8 @@ import { isWorkspaceNotificationMuted } from '../workspace-notification-mute'
export abstract class OrcaRuntimeWithGetWorktreePs extends OrcaRuntimeWithWorktreePsDependencies {
async getWorktreePs(
limit = DEFAULT_WORKTREE_PS_LIMIT,
sourceDefaultsSupported = true
sourceDefaultsSupported = true,
includeAgentWait = false
): Promise<RuntimeWorktreePsResult> {
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. */
+1
View File
@@ -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',
+3 -1
View File
@@ -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({
+3
View File
@@ -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. */