mirror of
https://github.com/stablyai/orca.git
synced 2026-10-30 16:47:34 +00:00
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:
@@ -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
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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]',
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
@@ -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. */
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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. */
|
||||
|
||||
Reference in New Issue
Block a user