diff --git a/src/main/automations/runtime-terminal-run-observer.test.ts b/src/main/automations/runtime-terminal-run-observer.test.ts index 0e0a27151d8..20d8450f2c6 100644 --- a/src/main/automations/runtime-terminal-run-observer.test.ts +++ b/src/main/automations/runtime-terminal-run-observer.test.ts @@ -23,7 +23,7 @@ type FakeWaiter = { function createFakeRuntime( initial: Partial, - readinessOptions: { resolveUnknown?: boolean } = {} + readinessOptions: { resolveUnknown?: boolean; resolveBusy?: boolean } = {} ) { const pane: FakePane = { lastAgentStatus: null, @@ -61,6 +61,12 @@ function createFakeRuntime( readiness: { state: 'unknown' as const } }) } + if (readinessOptions.resolveBusy && waitOptions?.timeoutMs !== undefined) { + return Promise.resolve({ + satisfied: false, + readiness: { state: 'busy' as const } + }) + } return new Promise((resolve, reject) => { const waiter: FakeWaiter = { resolve, @@ -210,6 +216,22 @@ describe('createRuntimeAutomationRunTerminalObserver', () => { await run.promise }) + it('accepts a title-only working observation before the pane returns to ready', async () => { + const runtime = createFakeRuntime( + { lastAgentStatus: null, paneTitle: '✳ Claude — working' }, + { resolveBusy: true } + ) + const run = observe(runtime) + + await vi.advanceTimersByTimeAsync(1_000) + expect(run.settled).toEqual([]) + + runtime.setPane({ paneTitle: '✳ Claude — idle' }) + await vi.advanceTimersByTimeAsync(10) + expect(run.settled[0]?.status).toBe('completed') + await run.promise + }) + it('stops re-arming the tui-idle wait instead of looping for the process lifetime', async () => { const runtime = createFakeRuntime({ lastAgentStatus: 'working' }) const run = observe(runtime) diff --git a/src/main/runtime/orchestration/db/federation/remote-dispatch-attachment-authority.ts b/src/main/runtime/orchestration/db/federation/remote-dispatch-attachment-authority.ts index ac85fc7c3c9..968939042c0 100644 --- a/src/main/runtime/orchestration/db/federation/remote-dispatch-attachment-authority.ts +++ b/src/main/runtime/orchestration/db/federation/remote-dispatch-attachment-authority.ts @@ -137,6 +137,29 @@ export function markRemoteAttachmentReady( return this.getRemoteDispatchAttachment(dispatchId) as RemoteDispatchAttachmentRow } +/** Preserve a prepared attachment and its capability when readiness remains unverifiable. */ +export function markRemoteAttachmentStartUnknown( + this: OrchestrationDb, + dispatchId: string, + stage: string, + reason: string +): RemoteDispatchAttachmentRow { + const result = this.db + .prepare( + `UPDATE remote_dispatch_attachments + SET state = 'start_unknown', stage = ?, last_error = ?, updated_at = datetime('now') + WHERE dispatch_id = ? AND state = 'starting'` + ) + .run(stage, reason, dispatchId) + if (result.changes !== 1) { + throw new OrchestrationError( + 'dispatch_inactive', + `Remote Dispatch ${dispatchId} is not starting.` + ) + } + return this.getRemoteDispatchAttachment(dispatchId) as RemoteDispatchAttachmentRow +} + export function failRemoteAttachment( this: OrchestrationDb, dispatchId: string, @@ -209,6 +232,7 @@ export function isRemoteAttachmentProcessCurrent( export type RemoteDispatchAttachmentAuthorityMethods = { prepareRemoteAttachmentAuthority: typeof prepareRemoteAttachmentAuthority markRemoteAttachmentReady: typeof markRemoteAttachmentReady + markRemoteAttachmentStartUnknown: typeof markRemoteAttachmentStartUnknown failRemoteAttachment: typeof failRemoteAttachment verifyRemoteAttachmentAuthority: typeof verifyRemoteAttachmentAuthority isRemoteAttachmentProcessCurrent: typeof isRemoteAttachmentProcessCurrent @@ -218,6 +242,7 @@ export function attachRemoteDispatchAttachmentAuthority(ctor: { prototype: objec Object.assign(ctor.prototype, { prepareRemoteAttachmentAuthority, markRemoteAttachmentReady, + markRemoteAttachmentStartUnknown, failRemoteAttachment, verifyRemoteAttachmentAuthority, isRemoteAttachmentProcessCurrent diff --git a/src/main/runtime/rpc/methods/orchestration/federation/federated-attach-receipt.ts b/src/main/runtime/rpc/methods/orchestration/federation/federated-attach-receipt.ts index 6fac7950954..39b2e91ea22 100644 --- a/src/main/runtime/rpc/methods/orchestration/federation/federated-attach-receipt.ts +++ b/src/main/runtime/rpc/methods/orchestration/federation/federated-attach-receipt.ts @@ -3,6 +3,7 @@ import type { OrchestrationWorkerLaunchReceipt } from '../worker/worker-launch-p export type RemoteFederatedWorkerStartReceipt = { dispatchId: string state: string + stage?: string runtimeEpoch?: string worktreeId?: string terminalHandle?: string diff --git a/src/main/runtime/rpc/methods/orchestration/federation/federated-worker-start-receipt.test.ts b/src/main/runtime/rpc/methods/orchestration/federation/federated-worker-start-receipt.test.ts index 561e6706b28..6d19b629e20 100644 --- a/src/main/runtime/rpc/methods/orchestration/federation/federated-worker-start-receipt.test.ts +++ b/src/main/runtime/rpc/methods/orchestration/federation/federated-worker-start-receipt.test.ts @@ -52,6 +52,7 @@ describe('federated worker start receipt validation', () => { } }) + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the RPC receipt is checked by toMatchObject below. const result = (await startFederatedWorker({ params: { task: task.id, @@ -88,4 +89,83 @@ describe('federated worker start receipt validation', () => { }) } }) + + it('retains remote terminal custody when readiness is unknown before prompt delivery', async () => { + const db = new OrchestrationDb(':memory:') + const runtime = new OrcaRuntimeService() + runtime.setOrchestrationDb(db) + databases.push(db) + const run = db.createRun({ + objective: 'federated readiness', + coordinatorHandle: 'term_coord', + coordinatorPaneKey: 'tab_coord:leaf_coord' + }) + const task = db.createTask({ spec: 'remote work', runId: run.id }) + vi.spyOn(runtime, 'resolveOrchestrationWorkerServer').mockReturnValue({ + environmentId: 'environment_remote', + name: 'remote', + peerFingerprint: 'remote_peer', + pairingRevision: 73 + }) + vi.spyOn(runtime, 'callOrchestrationWorkerServer').mockImplementation( + async (_environmentId, method, params) => { + if (method === 'status.get') { + return { + capabilities: [ + ORCHESTRATION_CONTRACT_RUNTIME_CAPABILITY, + ORCHESTRATION_FEDERATION_RUNTIME_CAPABILITY + ] + } + } + return { + dispatchId: (params as { dispatchId: string }).dispatchId, + state: 'outcome_unknown', + runtimeEpoch: 'epoch_remote', + worktreeId: 'worktree_remote', + terminalHandle: 'term_remote', + setup: { state: 'running' }, + effects: [{ kind: 'terminal', action: 'created', id: 'term_remote' }], + residualResources: [{ kind: 'terminal', id: 'term_remote' }], + failedStage: 'agent_readiness', + lastError: 'Agent startup readiness could not be verified (unknown).' + } + } + ) + + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the RPC receipt is checked by toMatchObject below. + const result = (await startFederatedWorker({ + params: { + task: task.id, + from: 'term_coord', + on: 'remote', + worktree: 'id:worktree_remote', + agent: 'codex' + }, + runtime, + db, + runId: run.id, + task, + orchestrationMutation: { + callerFingerprint: 'caller', + requestId: 'remote_unknown', + method: 'orchestration.workerStart', + payloadHash: 'payload' + } + })) as { dispatchId: string; state: string; terminalHandle?: string } + + expect(result).toMatchObject({ + state: 'outcome_unknown', + terminalHandle: 'term_remote' + }) + expect(db.getWorkerDispatch(result.dispatchId)).toMatchObject({ + state: 'start_unknown', + agent_terminal_handle: 'term_remote', + worktree_id: 'worktree_remote' + }) + expect(db.getFederatedDispatch(result.dispatchId)).toMatchObject({ + remote_runtime_epoch: 'epoch_remote', + remote_worktree_id: 'worktree_remote', + remote_terminal_handle: 'term_remote' + }) + }) }) diff --git a/src/main/runtime/rpc/methods/orchestration/federation/federated-worker-start-receipts.ts b/src/main/runtime/rpc/methods/orchestration/federation/federated-worker-start-receipts.ts index 271d2ee90c3..983645e47a0 100644 --- a/src/main/runtime/rpc/methods/orchestration/federation/federated-worker-start-receipts.ts +++ b/src/main/runtime/rpc/methods/orchestration/federation/federated-worker-start-receipts.ts @@ -1,4 +1,6 @@ import type { OrchestrationWorkerLaunchReceipt } from '../worker/worker-launch-preferences' +import type { OrchestrationDb } from '../../../../orchestration/db' +import type { RemoteFederatedWorkerStartReceipt } from './federated-attach-receipt' export type RemoteStartReceipt = { dispatchId: string @@ -29,7 +31,13 @@ export function federatedUnknownReceipt( worker: { dispatch_id: string; state: string; stage: string; last_error: string | null }, taskId: string, serverName: string, - launch: OrchestrationWorkerLaunchReceipt + launch: OrchestrationWorkerLaunchReceipt, + details?: { + setup?: { state: string } + terminalHandle?: string + effects?: unknown[] + residualResources?: unknown[] + } ): unknown { return { taskId, @@ -38,13 +46,60 @@ export function federatedUnknownReceipt( stage: worker.stage, server: { name: serverName }, launch, + ...(details?.setup ? { setup: details.setup } : {}), + ...(details?.terminalHandle ? { terminalHandle: details.terminalHandle } : {}), + ...(details?.effects ? { effects: details.effects } : { effects: [] }), + ...(details?.residualResources + ? { residualResources: details.residualResources } + : { residualResources: [] }), failedStage: worker.stage, lastError: worker.last_error, - effects: [], - residualResources: [], nextCommands: [ `orca orchestration worker-show --dispatch ${worker.dispatch_id} --json`, + ...(details?.terminalHandle + ? [`orca terminal read --terminal ${details.terminalHandle} --screen`] + : []), `orca orchestration worker-abandon --dispatch ${worker.dispatch_id} --json` ] } } + +export function preserveFederatedWorkerStartUnknown(args: { + db: OrchestrationDb + dispatchId: string + taskId: string + serverName: string + launch: OrchestrationWorkerLaunchReceipt + remote: RemoteFederatedWorkerStartReceipt +}): unknown { + const stage = args.remote.failedStage ?? args.remote.stage ?? 'remote_attach' + if (args.remote.runtimeEpoch && args.remote.worktreeId && args.remote.terminalHandle) { + args.db.updateFederatedDispatchResources({ + dispatchId: args.dispatchId, + remoteRuntimeEpoch: args.remote.runtimeEpoch, + worktreeId: args.remote.worktreeId, + terminalHandle: args.remote.terminalHandle + }) + } + args.db.recordWorkerStage({ + dispatchId: args.dispatchId, + stage, + worktreeId: args.remote.worktreeId, + terminalHandle: args.remote.terminalHandle, + setupState: args.remote.setup?.state, + effects: args.remote.effects, + residualResources: args.remote.residualResources + }) + const worker = args.db.markWorkerStartUnknown( + args.dispatchId, + stage, + args.remote.lastError ?? 'The worker server reported an unknown start outcome.', + args.remote.effects + ) + return federatedUnknownReceipt(worker, args.taskId, args.serverName, args.launch, { + setup: args.remote.setup, + terminalHandle: args.remote.terminalHandle, + effects: args.remote.effects, + residualResources: args.remote.residualResources + }) +} diff --git a/src/main/runtime/rpc/methods/orchestration/federation/federated-worker-start.ts b/src/main/runtime/rpc/methods/orchestration/federation/federated-worker-start.ts index a7c59b7064b..4c47a0fbb33 100644 --- a/src/main/runtime/rpc/methods/orchestration/federation/federated-worker-start.ts +++ b/src/main/runtime/rpc/methods/orchestration/federation/federated-worker-start.ts @@ -29,7 +29,8 @@ import { import { isWorkerStartTimeoutWithinTimerLimit } from '../../../../../../shared/orchestration-timing-budgets' import { federatedUnknownReceipt, - isKnownRemoteStartFailure + isKnownRemoteStartFailure, + preserveFederatedWorkerStartUnknown } from './federated-worker-start-receipts' import { parseTaskDeps } from '../worker/task-deps-argument' @@ -239,12 +240,14 @@ export async function startFederatedWorker(args: { } } if (remote.state === 'outcome_unknown') { - const worker = db.markWorkerStartUnknown( - started.dispatch.id, - remote.failedStage ?? 'remote_attach', - remote.lastError ?? 'The worker server reported an unknown start outcome.' - ) - return federatedUnknownReceipt(worker, taskForRemote.id, server.name, launch) + return preserveFederatedWorkerStartUnknown({ + db, + dispatchId: started.dispatch.id, + taskId: taskForRemote.id, + serverName: server.name, + launch, + remote + }) } const worker = db.failWorkerStart( started.dispatch.id, diff --git a/src/main/runtime/rpc/methods/orchestration/federation/federation-readiness-custody.test.ts b/src/main/runtime/rpc/methods/orchestration/federation/federation-readiness-custody.test.ts new file mode 100644 index 00000000000..4521444566d --- /dev/null +++ b/src/main/runtime/rpc/methods/orchestration/federation/federation-readiness-custody.test.ts @@ -0,0 +1,123 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type { RuntimeRpcResponse } from '../../../../../../shared/runtime-rpc-envelope' +import { OrcaRuntimeService } from '../../../../orca-runtime' +import { OrchestrationDb } from '../../../../orchestration/db' +import type { OrchestrationEnvironmentTransport } from '../../../../orchestration/environment-transport' +import { RpcDispatcher } from '../../../dispatcher' +import { ORCHESTRATION_METHODS } from '../../orchestration' +import { createFederationWorkerStartRequest } from './federation-request.test-support' +import { configureFederationWorkerRuntime } from './federation-runtime.test-support' + +describe('federated readiness custody', () => { + let homeDb: OrchestrationDb + let workerDb: OrchestrationDb + let homeRuntime: OrcaRuntimeService + let workerRuntime: OrcaRuntimeService + let homeDispatcher: RpcDispatcher + + beforeEach(() => { + homeDb = new OrchestrationDb(':memory:') + workerDb = new OrchestrationDb(':memory:') + workerRuntime = new OrcaRuntimeService() + workerRuntime.setOrchestrationDb(workerDb) + configureFederationWorkerRuntime(workerRuntime) + const workerDispatcher = new RpcDispatcher({ + runtime: workerRuntime, + methods: ORCHESTRATION_METHODS + }) + const transport: OrchestrationEnvironmentTransport = { + resolve: () => ({ + environmentId: 'environment_windows', + name: 'windows', + peerFingerprint: 'windows_peer_fingerprint', + pairingRevision: 73 + }), + call: async (_selector, method, params, _timeoutMs, envelope) => { + if (method === 'status.get') { + return { + id: 'status', + ok: true, + result: workerRuntime.getStatus(), + _meta: { runtimeId: workerRuntime.getRuntimeId() } + } + } + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: RpcDispatcher returns the transport envelope required by this adapter. + return (await workerDispatcher.dispatch({ + id: `remote_${method}`, + authToken: 'run-home-device-token', + method, + params, + orchestrationContractVersion: envelope?.orchestrationContractVersion, + orchestrationRequestId: envelope?.orchestrationRequestId, + orchestrationCapability: envelope?.orchestrationCapability + })) as RuntimeRpcResponse + } + } + homeRuntime = new OrcaRuntimeService(null, undefined, { + orchestrationEnvironmentTransport: transport + }) + homeRuntime.setOrchestrationDb(homeDb) + homeDispatcher = new RpcDispatcher({ runtime: homeRuntime, methods: ORCHESTRATION_METHODS }) + vi.spyOn(homeRuntime, 'getTerminalPaneKey').mockReturnValue( + 'tab_coord:aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa' + ) + }) + + afterEach(() => { + homeRuntime.stopOrchestrationFederationRelay() + homeDb.close() + workerDb.close() + }) + + it('preserves paired-runtime custody when startup readiness is unsupported', async () => { + vi.mocked(workerRuntime.waitForTerminal).mockResolvedValueOnce({ + handle: 'term_windows_worker', + condition: 'tui-idle', + satisfied: false, + status: 'running', + exitCode: null, + readiness: { state: 'unsupported', source: 'capability', agent: 'codex' } + }) + const run = homeDb.createRun({ + objective: 'Mac to Windows', + coordinatorHandle: 'term_coord', + coordinatorPaneKey: 'tab_coord:aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa' + }) + const task = homeDb.createTask({ spec: 'Audit Windows behavior', runId: run.id }) + + const response = await homeDispatcher.dispatch(createFederationWorkerStartRequest(task.id)) + + expect(response).toMatchObject({ + ok: true, + result: { + state: 'outcome_unknown', + stage: 'agent_readiness', + terminalHandle: 'term_windows_worker' + } + }) + const dispatch = homeDb.getDispatchContext(task.id) + expect(dispatch).toBeDefined() + if (!dispatch) { + return + } + expect(homeDb.getTask(task.id)?.status).toBe('blocked') + expect(homeDb.getWorkerDispatch(dispatch.id)).toMatchObject({ + state: 'start_unknown', + worktree_id: 'repo::windows-worktree', + agent_terminal_handle: 'term_windows_worker' + }) + expect(homeDb.getFederatedDispatch(dispatch.id)).toMatchObject({ + remote_runtime_epoch: workerRuntime.getRuntimeId(), + remote_worktree_id: 'repo::windows-worktree', + remote_terminal_handle: 'term_windows_worker' + }) + expect(workerDb.getRemoteDispatchAttachment(dispatch.id)).toMatchObject({ + state: 'start_unknown', + stage: 'agent_readiness', + worktree_id: 'repo::windows-worktree', + terminal_handle: 'term_windows_worker', + capability_hash: expect.any(String) + }) + expect(workerRuntime.sendTerminalAgentPrompt).not.toHaveBeenCalled() + }) +}) diff --git a/src/main/runtime/rpc/methods/orchestration/federation/federation-readiness-outcome.ts b/src/main/runtime/rpc/methods/orchestration/federation/federation-readiness-outcome.ts new file mode 100644 index 00000000000..58182a7ffff --- /dev/null +++ b/src/main/runtime/rpc/methods/orchestration/federation/federation-readiness-outcome.ts @@ -0,0 +1,83 @@ +import type { RuntimeTerminalWait } from '../../../../../../shared/runtime-types' +import { describeTerminalWaitBlockedReason } from '../../../../../../shared/terminal-wait-blocked-reason-legacy-alias' +import type { OrcaRuntimeService } from '../../../../orca-runtime' +import type { OrchestrationDb } from '../../../../orchestration/db' +import type { WorkerSetupReceipt } from '../worker/worker-topology' +import type { OrchestrationWorkerLaunchReceipt } from '../worker/worker-launch-preferences' +import type { FederationEffect } from './federation-effects' + +type FederatedReadinessOutcome = + | { state: 'ready'; capability: string } + | { state: 'failed'; stage: string; reason: string } + | { state: 'unknown'; receipt: unknown } + +export function prepareFederatedReadinessOutcome(args: { + runtime: OrcaRuntimeService + db: OrchestrationDb + dispatchId: string + wait: RuntimeTerminalWait + worktreeId: string + terminalHandle: string + terminalOwnership: 'created' | 'external' + setup: WorkerSetupReceipt + launch: OrchestrationWorkerLaunchReceipt + effects: FederationEffect[] +}): FederatedReadinessOutcome { + const reason = args.wait.blockedReason + ? `Agent startup blocked: ${describeTerminalWaitBlockedReason(args.wait.blockedReason)}` + : `Agent startup readiness could not be verified (${args.wait.readiness?.state ?? args.wait.status}); no prompt was submitted.` + if (!args.wait.satisfied) { + const setupFailed = + args.setup.startupPolicy === 'wait-for-setup' && + (args.setup.state === 'failed' || args.wait.status === 'exited') + const knownAgentExit = + args.setup.startupPolicy !== 'wait-for-setup' && args.wait.status === 'exited' + if (setupFailed || knownAgentExit) { + return { state: 'failed', stage: setupFailed ? 'setup_wait' : 'agent_readiness', reason } + } + } + + const authority = args.runtime.getOrchestrationDispatchAuthority(args.terminalHandle) + const paneKey = authority?.paneKey ?? args.runtime.getTerminalPaneKey(args.terminalHandle) + const processIncarnation = + authority?.processIncarnation ?? args.runtime.getTerminalProcessIncarnation(args.terminalHandle) + if (!paneKey || !processIncarnation) { + throw new Error('stable_pane_required') + } + const capability = args.db.prepareRemoteAttachmentAuthority({ + dispatchId: args.dispatchId, + paneKey, + processIncarnation, + worktreeId: args.worktreeId, + terminalHandle: args.terminalHandle, + setupState: args.setup.state, + effects: args.effects, + hostScope: authority?.hostScope ? JSON.stringify(authority.hostScope) : null, + terminalOwnership: args.terminalOwnership + }) + if (args.wait.satisfied) { + return { state: 'ready', capability } + } + + const attachment = args.db.markRemoteAttachmentStartUnknown( + args.dispatchId, + 'agent_readiness', + reason + ) + return { + state: 'unknown', + receipt: { + dispatchId: args.dispatchId, + state: 'outcome_unknown', + stage: attachment.stage, + runtimeEpoch: args.runtime.getRuntimeId(), + worktreeId: args.worktreeId, + terminalHandle: args.terminalHandle, + setup: args.setup, + launch: args.launch, + effects: args.effects, + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: attachment authority persists residual_resources as a JSON array. + residualResources: JSON.parse(attachment.residual_resources) as unknown[] + } + } +} diff --git a/src/main/runtime/rpc/methods/orchestration/federation/federation.ts b/src/main/runtime/rpc/methods/orchestration/federation/federation.ts index 8d9d92547c8..976b76a55b1 100644 --- a/src/main/runtime/rpc/methods/orchestration/federation/federation.ts +++ b/src/main/runtime/rpc/methods/orchestration/federation/federation.ts @@ -1,5 +1,4 @@ import type { TuiAgent } from '../../../../../../shared/tui-agent' -import { describeTerminalWaitBlockedReason } from '../../../../../../shared/terminal-wait-blocked-reason-legacy-alias' import { buildDispatchPreamble } from '../../../../orchestration/preamble' import { OrchestrationError } from '../../../../orchestration/orchestration-error' import { defineMethod } from '../../../core' @@ -24,6 +23,7 @@ import { resolveWorkerStartReadinessTimeoutMs } from '../../../../../../shared/orchestration-timing-budgets' import { assertWorkerStartTaskSpecWithinPromptBudget } from '../worker/worker-start-prompt-budget' +import { prepareFederatedReadinessOutcome } from './federation-readiness-outcome' export const ORCHESTRATION_FEDERATION_ATTACH_METHODS = [ defineMethod({ @@ -217,34 +217,27 @@ export const ORCHESTRATION_FEDERATION_ATTACH_METHODS = [ timeoutMs: readinessTimeoutMs }) persistFederatedSetupWaitOutcome({ ...setupStage, wait }) - if (!wait.satisfied) { - if (setup.state === 'failed') { - failedStage = 'setup_wait' - } - throw new Error( - wait.blockedReason - ? `Agent startup blocked: ${describeTerminalWaitBlockedReason(wait.blockedReason)}` - : `Agent did not become ready (${wait.status}).` - ) - } - const authority = runtime.getOrchestrationDispatchAuthority(terminalHandle) - const paneKey = authority?.paneKey ?? runtime.getTerminalPaneKey(terminalHandle) - const processIncarnation = - authority?.processIncarnation ?? runtime.getTerminalProcessIncarnation(terminalHandle) - if (!paneKey || !processIncarnation) { - throw new Error('stable_pane_required') - } - const capability = db.prepareRemoteAttachmentAuthority({ + const readiness = prepareFederatedReadinessOutcome({ + runtime, + db, dispatchId: params.dispatchId, - paneKey, - processIncarnation, worktreeId: worktree.id, terminalHandle, - setupState: setup.state, - effects, - hostScope: authority?.hostScope ? JSON.stringify(authority.hostScope) : null, - terminalOwnership: params.terminal ? 'external' : 'created' + terminalOwnership: params.terminal ? 'external' : 'created', + wait, + setup, + launch: launch.receipt, + effects }) + if (readiness.state === 'failed') { + failedStage = readiness.stage + throw new Error(readiness.reason) + } + if (readiness.state === 'unknown') { + monitorFederatedSetup({ ...setupStage, runtime }) + return readiness.receipt + } + const { capability } = readiness failedStage = 'dispatch_input' const prompt = await runtime.sendTerminalAgentPrompt( terminalHandle, diff --git a/src/main/runtime/terminal-wait-name-only-idle.test.ts b/src/main/runtime/terminal-wait-name-only-idle.test.ts index e7adaf36dff..a6b27521354 100644 --- a/src/main/runtime/terminal-wait-name-only-idle.test.ts +++ b/src/main/runtime/terminal-wait-name-only-idle.test.ts @@ -125,6 +125,18 @@ describe('tui-idle evidence ranking', () => { ).resolves.toMatchObject({ satisfied: true }) }) + it('reports an explicit provider working title as busy instead of unknown', async () => { + const pty = makeTuiIdlePty({ lastAgentStatus: 'working', lastOscTitle: '⠋ Codex' }) + const { wait } = createWait({ pty, agent: 'codex' }) + const result = wait.wait(HANDLE, { condition: 'tui-idle', timeoutMs: 100 }) + + await vi.advanceTimersByTimeAsync(100) + await expect(result).resolves.toMatchObject({ + satisfied: false, + readiness: { state: 'busy', source: 'title', agent: 'codex' } + }) + }) + it('accepts a provider-specific ready screen when launch metadata is absent', async () => { const pty = makeTuiIdlePty({ preview: 'OpenAI Codex\nModel: gpt-5\nDirectory: /tmp/repo' diff --git a/src/main/runtime/tui-idle-evidence.ts b/src/main/runtime/tui-idle-evidence.ts index 69913e3846d..b5345a4b7e8 100644 --- a/src/main/runtime/tui-idle-evidence.ts +++ b/src/main/runtime/tui-idle-evidence.ts @@ -1,4 +1,4 @@ -import type { AgentStatus } from '../../shared/agent-detection' +import { detectAgentStatusFromTitle, type AgentStatus } from '../../shared/agent-detection' import { isFreshNonDoneAgentStatus } from '../../shared/agent-status-freshness' import type { AgentStatusState } from '../../shared/agent-status-types' import { getAgentReadinessCapability } from '../../shared/agent-readiness-capabilities' @@ -112,6 +112,18 @@ export function observeTuiIdle(input: TuiIdleSatisfactionInput): TuiIdleObservat if (hasFreshWorkingFirstPartyStatus(input.firstPartyStatus)) { return { state: 'busy', source: 'first-party', agent } } + // A provider-owned working title is a positive busy observation for title-capable launch + // paths. Automation consumers use this edge to distinguish a real working turn from an + // unknown/unsupported pane; it never satisfies `tui-idle` and therefore cannot prove readiness. + if ( + agent && + supportsEvidence(agent, 'title') && + [input.rendererTitle, input.record.lastOscTitle].some( + (title) => title && detectAgentStatusFromTitle(title) === 'working' + ) + ) { + return { state: 'busy', source: 'title', agent } + } if (hasExplicitIdleTitle(input.record, input.rendererTitle) && supportsEvidence(agent, 'title')) { return { state: 'ready', source: 'title', agent } }