fix(runtime): a new terminal the host can't confirm says why, instead of nothing (#30725)

* fix(runtime): say why a new terminal on a host failed, with a retry

* fix(runtime): never offer a retry or say refused when the host's reply was lost

* fix(runtime): only a host error reply counts as refused; the wake flow keeps its own toast

* refactor(runtime): one 'couldn't confirm' notice carrying the reason, no failure classification

* fix(runtime): one create notice per workspace, so retried seeding can't stack toasts
This commit is contained in:
Neil
2026-10-19 05:46:10 -07:00
committed by GitHub
parent 5a9e26ef9f
commit 1bfebad8ec
12 changed files with 332 additions and 102 deletions
+2 -1
View File
@@ -18219,7 +18219,8 @@
"reconnectTimeout": "The server did not reconnect on the updated version."
},
"webRuntimeSession": {
"remoteHostDisconnected": "The workspace is not connected to a remote Orca host."
"remoteHostDisconnected": "The workspace is not connected to a remote Orca host.",
"terminalCreateUnconfirmed": "Orca couldn’t confirm that the new terminal opened on the host."
},
"gitlabJobTraceClient": {
"loadFailed": "Failed to load the GitLab job log.",
@@ -98,15 +98,18 @@ describe('launchAgentInNewTab paired web runtime', () => {
pasteDraftAfterLaunch: false
})
)
expect(mocks.createWebRuntimeSessionTerminal).toHaveBeenCalledWith({
worktreeId: 'wt-1',
environmentId: 'web-runtime',
targetGroupId: 'group-1',
activate: true,
agentSessionKind: 'fresh',
agent: 'claude',
viewMode: 'terminal'
})
expect(mocks.createWebRuntimeSessionTerminal).toHaveBeenCalledWith(
{
worktreeId: 'wt-1',
environmentId: 'web-runtime',
targetGroupId: 'group-1',
activate: true,
agentSessionKind: 'fresh',
agent: 'claude',
viewMode: 'terminal'
},
{ notifyFailure: false }
)
expect(mocks.createTab).not.toHaveBeenCalled()
await Promise.resolve()
// Why: host creation is async, so the user may be viewing another worktree by the time it lands.
@@ -134,25 +137,28 @@ describe('launchAgentInNewTab paired web runtime', () => {
pasteDraftAfterLaunch: false
})
)
expect(mocks.createWebRuntimeSessionTerminal).toHaveBeenCalledWith({
worktreeId: 'wt-1',
environmentId: 'web-runtime',
targetGroupId: 'group-1',
activate: true,
agentSessionKind: 'fresh',
launchAgent: 'codex',
command: "codex '--model' 'gpt-5' '--reasoning-effort' 'high' 'fix the spinner'",
env: { CODEX_PROFILE: 'captured' },
launchConfig: {
agentCommand: "codex '--model' 'gpt-5' '--reasoning-effort' 'high'",
agentArgs: '--model gpt-5 --reasoning-effort high',
agentEnv: { CODEX_PROFILE: 'captured' }
expect(mocks.createWebRuntimeSessionTerminal).toHaveBeenCalledWith(
{
worktreeId: 'wt-1',
environmentId: 'web-runtime',
targetGroupId: 'group-1',
activate: true,
agentSessionKind: 'fresh',
launchAgent: 'codex',
command: "codex '--model' 'gpt-5' '--reasoning-effort' 'high' 'fix the spinner'",
env: { CODEX_PROFILE: 'captured' },
launchConfig: {
agentCommand: "codex '--model' 'gpt-5' '--reasoning-effort' 'high'",
agentArgs: '--model gpt-5 --reasoning-effort high',
agentEnv: { CODEX_PROFILE: 'captured' }
},
startupCommandDelivery: 'shell-ready',
prompt: 'fix the spinner',
promptDelivery: 'auto-submit',
viewMode: 'terminal'
},
startupCommandDelivery: 'shell-ready',
prompt: 'fix the spinner',
promptDelivery: 'auto-submit',
viewMode: 'terminal'
})
{ notifyFailure: false }
)
expect(mocks.createTab).not.toHaveBeenCalled()
})
})
@@ -544,15 +544,13 @@ describe('launchAgentInNewTab', () => {
launchAgentInNewTab({ requestId: 'request-12', agent: 'codex', worktreeId: 'wt-1' })
expect(mockCreateWebRuntimeSessionTerminal).toHaveBeenCalledWith(
expect.objectContaining({
worktreeId: 'wt-1',
environmentId: 'web-runtime',
agentSessionKind: 'fresh',
agent: 'codex',
viewMode: 'terminal'
})
)
expect(mockCreateWebRuntimeSessionTerminal.mock.calls[0]?.[0]).toMatchObject({
worktreeId: 'wt-1',
environmentId: 'web-runtime',
agentSessionKind: 'fresh',
agent: 'codex',
viewMode: 'terminal'
})
})
it('propagates the resolved terminal mode to paired web runtime launches', async () => {
@@ -568,15 +566,13 @@ describe('launchAgentInNewTab', () => {
launchAgentInNewTab({ requestId: 'request-13', agent: 'codex', worktreeId: 'wt-1' })
expect(mockCreateWebRuntimeSessionTerminal).toHaveBeenCalledWith(
expect.objectContaining({
worktreeId: 'wt-1',
environmentId: 'web-runtime',
agentSessionKind: 'fresh',
agent: 'codex',
viewMode: 'terminal'
})
)
expect(mockCreateWebRuntimeSessionTerminal.mock.calls[0]?.[0]).toMatchObject({
worktreeId: 'wt-1',
environmentId: 'web-runtime',
agentSessionKind: 'fresh',
agent: 'codex',
viewMode: 'terminal'
})
})
it('surfaces a toast when host agent launch fails in paired web clients', async () => {
@@ -122,7 +122,7 @@ export function launchAgentInWebHostTab(args: {
launchDraft: prompt
}).then((outcome) => handleCreation({ outcome, promptDelivered: outcome.status === 'created' }))
}
return createWebRuntimeSessionTerminal(launch).then((outcome) =>
return createWebRuntimeSessionTerminal(launch, { notifyFailure: false }).then((outcome) =>
handleCreation({ outcome, promptDelivered: outcome.status === 'created' && hasPrompt })
)
}
@@ -178,24 +178,28 @@ describe('launchAiVaultSessionInNewTab', () => {
})
expect(result.tabId).toBeNull()
expect(runtimeMocks.createWebRuntimeSessionTerminal).toHaveBeenCalledWith({
worktreeId: 'wt-1',
environmentId: 'env-1',
targetGroupId: 'group-1',
agentSessionKind: 'resume',
launchAgent: 'codex',
command: "codex resume 'session-1'",
env: { CODEX_PROFILE: 'runtime' },
envToDelete: ['CODEX_HOME', 'ORCA_CODEX_HOME'],
launchConfig: {
agentCommand: 'codex',
expect(runtimeMocks.createWebRuntimeSessionTerminal).toHaveBeenCalledWith(
{
worktreeId: 'wt-1',
environmentId: 'env-1',
targetGroupId: 'group-1',
agentSessionKind: 'resume',
launchAgent: 'codex',
command: "codex resume 'session-1'",
env: { CODEX_PROFILE: 'runtime' },
envToDelete: ['CODEX_HOME', 'ORCA_CODEX_HOME'],
launchConfig: {
agentCommand: 'codex',
agentArgs: '',
agentEnv: { CODEX_PROFILE: 'runtime' }
},
providerSession: { key: 'session_id', id: 'session-1' },
agentArgs: '',
agentEnv: { CODEX_PROFILE: 'runtime' }
activate: true
},
providerSession: { key: 'session_id', id: 'session-1' },
agentArgs: '',
activate: true
})
// Its callers toast the failure with the agent's own wording.
{ notifyFailure: false }
)
expect(mockCreateTab).not.toHaveBeenCalled()
expect(queuedStartups()).toEqual([])
@@ -220,7 +224,8 @@ describe('launchAiVaultSessionInNewTab', () => {
expect(mockCreateEmptySplitGroup).toHaveBeenCalledWith('wt-1', 'group-1', 'right')
expect(runtimeMocks.createWebRuntimeSessionTerminal).toHaveBeenCalledWith(
expect.objectContaining({ targetGroupId: 'group-new' })
expect.objectContaining({ targetGroupId: 'group-new' }),
{ notifyFailure: false }
)
if (result.tabId === null) {
await result.runtimeLaunch
+18 -15
View File
@@ -44,21 +44,24 @@ export function launchAiVaultSessionInNewTab(args: {
? store.createEmptySplitGroup(args.worktreeId, split.sourceGroupId, split.splitDirection)
: null
const targetGroupId = splitGroupId ?? args.targetGroupId
const runtimeLaunch = createWebRuntimeSessionTerminal({
worktreeId: args.worktreeId,
environmentId: runtimeEnvironmentId,
...(targetGroupId ? { targetGroupId } : {}),
agentSessionKind: 'resume',
launchAgent: args.agent,
command: args.command,
...(args.cwd ? { cwd: args.cwd } : {}),
...(args.env ? { env: args.env } : {}),
...(args.envToDelete ? { envToDelete: args.envToDelete } : {}),
...(args.launchConfig ? { launchConfig: args.launchConfig } : {}),
...(args.providerSession ? { providerSession: args.providerSession } : {}),
...(args.launchConfig ? { agentArgs: args.launchConfig.agentArgs } : {}),
activate: true
})
const runtimeLaunch = createWebRuntimeSessionTerminal(
{
worktreeId: args.worktreeId,
environmentId: runtimeEnvironmentId,
...(targetGroupId ? { targetGroupId } : {}),
agentSessionKind: 'resume',
launchAgent: args.agent,
command: args.command,
...(args.cwd ? { cwd: args.cwd } : {}),
...(args.env ? { env: args.env } : {}),
...(args.envToDelete ? { envToDelete: args.envToDelete } : {}),
...(args.launchConfig ? { launchConfig: args.launchConfig } : {}),
...(args.providerSession ? { providerSession: args.providerSession } : {}),
...(args.launchConfig ? { agentArgs: args.launchConfig.agentArgs } : {}),
activate: true
},
{ notifyFailure: false }
)
const observedRuntimeLaunch = runtimeLaunch.then((outcome) => {
if (outcome.status === 'created') {
useAppStore.getState().setActiveTabType('terminal', args.worktreeId)
@@ -115,26 +115,30 @@ export function ensureWebRuntimeWorktreeTerminalAfterWake(
const startup = opts?.startup
// Why: sleep keeps tab rows but terminal.stop clears host PTYs, while a failed create receipt leaves a selected agent with no host surface.
void createWebRuntimeSessionTerminal({
worktreeId,
environmentId: runtimeEnvironmentId,
...(startup
? {
command: startup.command,
...(startup.env ? { env: startup.env } : {}),
...(startup.launchConfig ? { launchConfig: startup.launchConfig } : {}),
...(startup.launchToken ? { launchToken: startup.launchToken } : {}),
...(launchAgent ? { launchAgent, preparedAgentCommand: true } : {}),
...(startup.startupCommandDelivery
? { startupCommandDelivery: startup.startupCommandDelivery }
: {})
}
: launchAgent
? { agent: launchAgent }
: {}),
activate: opts?.activate !== false,
selectWorktree: false
})
void createWebRuntimeSessionTerminal(
{
worktreeId,
environmentId: runtimeEnvironmentId,
...(startup
? {
command: startup.command,
...(startup.env ? { env: startup.env } : {}),
...(startup.launchConfig ? { launchConfig: startup.launchConfig } : {}),
...(startup.launchToken ? { launchToken: startup.launchToken } : {}),
...(launchAgent ? { launchAgent, preparedAgentCommand: true } : {}),
...(startup.startupCommandDelivery
? { startupCommandDelivery: startup.startupCommandDelivery }
: {})
}
: launchAgent
? { agent: launchAgent }
: {}),
activate: opts?.activate !== false,
selectWorktree: false
},
// This flow toasts every failure itself, including a disconnected host.
{ notifyFailure: false }
)
.then((outcome) => {
if (outcome.status === 'failed') {
toast.error(outcome.message, {
@@ -47,6 +47,8 @@ export type CreateWebRuntimeSessionTerminalArgs = {
export type CreatedWebRuntimeSessionTerminal = {
outcome: WebRuntimeTerminalCreateOutcome
hostTabId?: string
/** A live session existed and the create was not confirmed, as opposed to no session to ask. */
hostFailed?: true
}
export type CreatedAgentTerminalIdentity = Pick<RuntimeTerminalCreate, 'tabId' | 'paneKey'> & {
@@ -0,0 +1,171 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { createWebRuntimeSessionTerminal } from './web-runtime-terminal-creation'
import { resetWebSessionCloseIntentForTests } from './web-session-close-intent'
import {
WORKTREE_ID,
resetTerminalCreateEnvironment,
stubTerminalCreateEnvironment
} from './web-runtime-session-test-harness'
import { stubRuntimeEnvironmentBridge } from './runtime-environment-bridge-fixture'
const mocks = vi.hoisted(() => ({
getState: vi.fn(),
setState: vi.fn(),
subscribe: vi.fn(),
setActiveWorktree: vi.fn(),
createBrowserTab: vi.fn(),
closeEmptyGroup: vi.fn(),
moveUnifiedTabToGroup: vi.fn(),
setRemoteBrowserPageHandle: vi.fn(),
focusBrowserTabInWorktree: vi.fn(),
applyWebSessionTabsSnapshot: vi.fn(),
decideWebSessionTabsSnapshot: vi.fn(() => ({ apply: true, settlesHostMirror: true })),
getWebSessionTabsTrackingGeneration: vi.fn(() => 0),
acceptReplayedWebSessionTabsSnapshot: vi.fn(),
resolveHostSessionTabIdForWebSessionTab: vi.fn(),
trackTerminalPaneSplit: vi.fn(),
deliverLaunchPromptToAgentTab: vi.fn(),
seedNativeChatLaunchDraftForAgentTab: vi.fn(),
hasMaterializedWebRuntimeBrowserPage: vi.fn(),
toastError: vi.fn()
}))
vi.mock('../store', () => ({
useAppStore: {
getState: mocks.getState,
setState: mocks.setState,
subscribe: mocks.subscribe
}
}))
vi.mock('./web-session-tabs-sync/tracking-lifecycle', () => ({
acceptReplayedWebSessionTabsSnapshot: mocks.acceptReplayedWebSessionTabsSnapshot,
getWebSessionTabsTrackingGeneration: mocks.getWebSessionTabsTrackingGeneration
}))
vi.mock('./web-session-tabs-sync/snapshot-api', () => ({
applyWebSessionTabsSnapshot: mocks.applyWebSessionTabsSnapshot
}))
vi.mock('./web-session-tabs-sync/tracking-decisions', () => ({
decideWebSessionTabsSnapshot: mocks.decideWebSessionTabsSnapshot
}))
vi.mock('./web-session-tabs-sync/store-patch', () => ({
applyWebSessionTabsStorePatch: (buildPatch: (state: unknown) => unknown) => {
mocks.setState(buildPatch)
// The production caller invokes the returned settle receipt.
return () => {}
}
}))
vi.mock('./web-session-tabs-sync/tracking-mappings', () => ({
resolveHostSessionTabIdForWebSessionTab: mocks.resolveHostSessionTabIdForWebSessionTab
}))
vi.mock('@/lib/feature-education-telemetry', () => ({
trackTerminalPaneSplit: mocks.trackTerminalPaneSplit
}))
vi.mock('@/lib/agent-launch-prompt-delivery', () => ({
deliverLaunchPromptToAgentTab: mocks.deliverLaunchPromptToAgentTab,
seedNativeChatLaunchDraftForAgentTab: mocks.seedNativeChatLaunchDraftForAgentTab
}))
vi.mock('./web-runtime-browser-materialization', () => ({
hasMaterializedWebRuntimeBrowserPage: mocks.hasMaterializedWebRuntimeBrowserPage
}))
vi.mock('sonner', () => ({ toast: { error: mocks.toastError } }))
afterEach(() => resetWebSessionCloseIntentForTests())
const HOST_REFUSAL = 'Profile state write failed without a confirmed rollback'
describe('a terminal the host fails to create', () => {
beforeEach(() => {
stubTerminalCreateEnvironment(mocks)
})
afterEach(() => {
resetTerminalCreateEnvironment()
})
it('passes on the host’s reason, since no tab or pane will ever show it', async () => {
stubRuntimeEnvironmentBridge({
call: vi.fn().mockResolvedValue({
id: 'create',
ok: false,
error: { code: 'runtime_error', message: HOST_REFUSAL }
})
})
vi.spyOn(console, 'warn').mockImplementation(() => undefined)
await expect(
createWebRuntimeSessionTerminal({ worktreeId: WORKTREE_ID, activate: true })
).resolves.toMatchObject({ status: 'failed' })
expect(mocks.toastError).toHaveBeenCalledExactlyOnceWith(
'Orca couldn’t confirm that the new terminal opened on the host.',
expect.objectContaining({ description: expect.stringContaining(HOST_REFUSAL) })
)
// One toast per workspace however often automatic seeding retries.
expect(mocks.toastError.mock.calls[0][1]).toMatchObject({
id: `web-runtime-terminal-create:active:${WORKTREE_ID}`
})
})
it('says unconfirmed, never failed, when the reply was lost', async () => {
stubRuntimeEnvironmentBridge({
call: vi.fn().mockResolvedValue({
id: 'create',
ok: false,
error: { code: 'runtime_timeout', message: 'Timed out waiting for the remote Orca runtime' }
})
})
vi.spyOn(console, 'warn').mockImplementation(() => undefined)
await createWebRuntimeSessionTerminal({ worktreeId: WORKTREE_ID, activate: true })
expect(mocks.toastError).toHaveBeenCalledExactlyOnceWith(
'Orca couldn’t confirm that the new terminal opened on the host.',
expect.objectContaining({ description: expect.stringContaining('Timed out') })
)
})
it('says the same for a browser client request timeout', async () => {
stubRuntimeEnvironmentBridge({
call: vi.fn().mockRejectedValue(new Error('Request timed out: session.tabs.createTerminal'))
})
vi.spyOn(console, 'warn').mockImplementation(() => undefined)
await createWebRuntimeSessionTerminal({ worktreeId: WORKTREE_ID, activate: true })
expect(mocks.toastError).toHaveBeenCalledExactlyOnceWith(
'Orca couldn’t confirm that the new terminal opened on the host.',
expect.objectContaining({ description: expect.stringContaining('Request timed out') })
)
})
it('stays quiet for a caller that reports the failure itself', async () => {
stubRuntimeEnvironmentBridge({
call: vi.fn().mockResolvedValue({
id: 'create',
ok: false,
error: { code: 'runtime_error', message: HOST_REFUSAL }
})
})
vi.spyOn(console, 'warn').mockImplementation(() => undefined)
await createWebRuntimeSessionTerminal(
{ worktreeId: WORKTREE_ID, activate: true },
{ notifyFailure: false }
)
expect(mocks.toastError).not.toHaveBeenCalled()
})
it('stays quiet when no host session exists, so callers can fall back to a local tab', async () => {
vi.stubGlobal('__ORCA_WEB_CLIENT__', false)
mocks.getState.mockReturnValue({ ...mocks.getState(), settings: {} })
await expect(
createWebRuntimeSessionTerminal({ worktreeId: WORKTREE_ID, activate: true })
).resolves.toMatchObject({ status: 'failed' })
expect(mocks.toastError).not.toHaveBeenCalled()
})
})
@@ -0,0 +1,24 @@
import { toast } from 'sonner'
import { translate } from '@/i18n/i18n'
/**
* The user asked for a terminal and the host never mirrored one, so no pane exists to show why.
* "Couldn't confirm" stays true when the reply was lost and the terminal appears later; the reason
* carries the host's refusal or the transport failure as reported. No retry: it could duplicate.
*/
export function notifyWebRuntimeTerminalCreateFailure(
reason: string,
scope: { environmentId: string | null; worktreeId: string }
): void {
toast.error(
translate(
'auto.runtime.webRuntimeSession.terminalCreateUnconfirmed',
'Orca couldn’t confirm that the new terminal opened on the host.'
),
{
// Why an id: automatic seeding retries on every reconnect and must not stack toasts.
id: `web-runtime-terminal-create:${scope.environmentId ?? 'active'}:${scope.worktreeId}`,
...(reason ? { description: reason } : {})
}
)
}
@@ -296,7 +296,8 @@ export async function createWebRuntimeSessionTerminalResult(
// to retry with a new operation ID and can duplicate a fresh agent.
return {
outcome: hostCreated ? { status: 'created' } : { status: 'failed', message },
...(createdTabId ? { hostTabId: createdTabId } : {})
...(createdTabId ? { hostTabId: createdTabId } : {}),
...(hostCreated ? {} : { hostFailed: true as const })
}
}
}
@@ -4,16 +4,33 @@ import {
seedNativeChatLaunchDraftForAgentTab
} from '../lib/agent-launch-prompt-delivery'
import { createWebRuntimeSessionTerminalResult } from './web-runtime-terminal-create-operation'
import { notifyWebRuntimeTerminalCreateFailure } from './web-runtime-terminal-create-failure-notice'
import { toWebTerminalSurfaceTabId } from '../../../shared/terminal-surface-id'
import type {
CreateWebRuntimeSessionTerminalArgs,
WebRuntimeTerminalCreateOutcome
} from './web-runtime-session-types'
/**
* A host-side failure is toasted here because the host never mirrors a tab for it, so no pane
* exists to show the error. `notifyFailure: false` is for callers that report it themselves.
*/
export async function createWebRuntimeSessionTerminal(
args: CreateWebRuntimeSessionTerminalArgs
args: CreateWebRuntimeSessionTerminalArgs,
options: { notifyFailure?: boolean } = {}
): Promise<WebRuntimeTerminalCreateOutcome> {
return (await createWebRuntimeSessionTerminalResult(args)).outcome
const created = await createWebRuntimeSessionTerminalResult(args)
if (
created.hostFailed &&
created.outcome.status === 'failed' &&
options.notifyFailure !== false
) {
notifyWebRuntimeTerminalCreateFailure(created.outcome.message, {
environmentId: args.environmentId ?? null,
worktreeId: args.worktreeId
})
}
return created.outcome
}
export async function createWebRuntimeAgentSessionTerminal(