mirror of
https://github.com/stablyai/orca.git
synced 2026-11-15 18:37:24 +00:00
fix(cloud): RA-11 Restart resumes the pane's own conversation on the host; quieter restarting/failed strip
m4air run 3: same tab, same leaf, new PTY, and state 1 after, but the relaunch was a new conversation
(no --resume; new session id). Orca Cloud tabs are host-surface tabs: the desktop reconnect only
attaches, and the host woke the slept leaf with its original launch. After the reversible stop, the
desktop now asks the host to ensure the agent on the pane's own provider session, placed in the same
host tab and leaf, then reconnects. A pane with no session keeps the host's fresh launch ('Starts a
new conversation'). A host that does not stop or resume shows the failure with the reason.
Strip (design-squad): 'Restarting Claude…' is the spinner and words only, with no button and no ×.
The failure says it once: the body names the reason when known ('The workspace didn't respond.')
and is otherwise empty; Try again and × stay.
This commit is contained in:
@@ -173,5 +173,12 @@ export const runningRestarting = fixture({
|
||||
})
|
||||
/** RA-11: nothing relaunched in time. */
|
||||
export const runningRestartFailed = fixture({
|
||||
restart: { phase: 'failed', agent: 'claude', resumable: true, ptyId: 'p', launchedAt: null }
|
||||
restart: {
|
||||
phase: 'failed',
|
||||
agent: 'claude',
|
||||
resumable: true,
|
||||
ptyId: 'p',
|
||||
launchedAt: null,
|
||||
failure: 'hostUnresponsive'
|
||||
}
|
||||
})
|
||||
|
||||
@@ -291,10 +291,11 @@ export function CloudAgentSignInStrip({
|
||||
</DropdownMenu>
|
||||
) : null}
|
||||
{/* Renew and Restart are the whole action and the strip leaves on its own; Escape still cancels. */}
|
||||
{!model.dismiss &&
|
||||
(model.status === 'done' ||
|
||||
primary?.action.kind === 'renew' ||
|
||||
primary?.action.kind === 'restart') ? null : (
|
||||
{model.noClose ||
|
||||
(!model.dismiss &&
|
||||
(model.status === 'done' ||
|
||||
primary?.action.kind === 'renew' ||
|
||||
primary?.action.kind === 'restart')) ? null : (
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
|
||||
@@ -39,6 +39,8 @@ export type CloudStripModel = {
|
||||
codeStep?: boolean
|
||||
/** RA-11 state 2: a dismiss × with this label (the strip otherwise drops its × on Renew). */
|
||||
dismiss?: string
|
||||
/** RA-11 "Restarting…": nothing to close; the strip leaves with the result. */
|
||||
noClose?: boolean
|
||||
/** One sentence for the polite region. */
|
||||
announcement: string
|
||||
}
|
||||
|
||||
@@ -5,22 +5,34 @@ import { cloudRestartAttemptModel } from './cloud-running-strip-model'
|
||||
const attempt = { agent: 'claude' as const, resumable: true, ptyId: 'p', launchedAt: null }
|
||||
|
||||
describe('RA-11 Restart feedback copy', () => {
|
||||
it('restarting: spinner, title, the button disabled, no dismiss', () => {
|
||||
it('restarting: spinner and words only, no button and nothing to close', () => {
|
||||
const model = cloudRestartAttemptModel({ ...attempt, phase: 'restarting' }, translate)
|
||||
expect(model).toMatchObject({
|
||||
status: 'busy',
|
||||
title: 'Restarting Claude…',
|
||||
primary: { label: 'Restart Claude', action: { kind: 'restart' }, busy: true }
|
||||
detail: 'Picking up where you left off.',
|
||||
primary: null,
|
||||
noClose: true
|
||||
})
|
||||
expect(model.dismiss).toBeUndefined()
|
||||
const fresh = cloudRestartAttemptModel(
|
||||
{ ...attempt, resumable: false, phase: 'restarting' },
|
||||
translate
|
||||
)
|
||||
expect(fresh.detail).toBe('Starts a new conversation.')
|
||||
})
|
||||
|
||||
it('failed: says so and offers Try again and a dismiss', () => {
|
||||
it('failed: says so once, the reason when known, and offers Try again and a dismiss', () => {
|
||||
const unresponsive = cloudRestartAttemptModel(
|
||||
{ ...attempt, phase: 'failed', failure: 'hostUnresponsive' },
|
||||
translate
|
||||
)
|
||||
expect(unresponsive.detail).toBe('The workspace didn’t respond.')
|
||||
const model = cloudRestartAttemptModel({ ...attempt, phase: 'failed' }, translate)
|
||||
expect(model).toMatchObject({
|
||||
status: 'error',
|
||||
title: 'Couldn’t restart Claude',
|
||||
detail: 'Try again.',
|
||||
detail: '',
|
||||
primary: { label: 'Try again', action: { kind: 'restart' } },
|
||||
dismiss: 'Dismiss',
|
||||
announcement: 'Couldn’t restart Claude. Try again.'
|
||||
|
||||
@@ -69,12 +69,9 @@ export function cloudRestartAttemptModel(
|
||||
detail: attempt.resumable
|
||||
? t('agentSignInStrip.restartingResume', 'Picking up where you left off.')
|
||||
: t('agentSignInStrip.renewedFresh', 'Starts a new conversation.'),
|
||||
primary: {
|
||||
label: t('agentSignInStrip.restartAgent', 'Restart {{agent}}', { agent }),
|
||||
narrowLabel: t('agentSignInStrip.restart', 'Restart'),
|
||||
action: { kind: 'restart' },
|
||||
busy: true
|
||||
},
|
||||
// The spinner and the words are the whole state: no button to click twice.
|
||||
primary: null,
|
||||
noClose: true,
|
||||
announcement: title
|
||||
}
|
||||
}
|
||||
@@ -84,7 +81,11 @@ export function cloudRestartAttemptModel(
|
||||
status: 'error',
|
||||
title,
|
||||
compactTitle: title,
|
||||
detail: t('agentSignInStrip.tryAgainDetail', 'Try again.'),
|
||||
// Never "Try again." twice: the body names the reason when one is known, else stays empty.
|
||||
detail:
|
||||
attempt.failure === 'hostUnresponsive'
|
||||
? t('agentSignInStrip.restartHostUnresponsive', 'The workspace didn’t respond.')
|
||||
: '',
|
||||
primary: {
|
||||
label: t('agentSignInStrip.tryAgain', 'Try again'),
|
||||
action: { kind: 'restart' }
|
||||
|
||||
+59
@@ -262,6 +262,65 @@ describe('RA-11 Restart on a paired (remote) pane', () => {
|
||||
expect(useAppStore.getState().suppressedPtyExitIds[REMOTE]).toBeUndefined()
|
||||
})
|
||||
|
||||
it('a resumable pane is relaunched on its own conversation in the same pane, after the stop', async () => {
|
||||
clickRemoteRestart()
|
||||
const order: string[] = []
|
||||
const providerSession = { key: 'session_id' as const, id: 'sess-tab-1' }
|
||||
const resumeRemotePane = vi.fn(async () => {
|
||||
order.push('resume')
|
||||
})
|
||||
const handleRestartPane = vi.fn(() => order.push('reconnect'))
|
||||
runReadyPaneAccountRestarts({
|
||||
panes: [{ id: 7, leafId: LEAF }],
|
||||
ptyIdOf: () => REMOTE,
|
||||
readyRestartPtyIds: selectReadyAccountRestartPtyIds(useAppStore.getState(), TAB),
|
||||
tabId: TAB,
|
||||
consumePendingPaneAccountRestart: useAppStore.getState().consumePendingPaneAccountRestart,
|
||||
buildAccountRestartStartup: () => ({
|
||||
command: 'claude',
|
||||
resumeProviderSession: providerSession
|
||||
}),
|
||||
handleRestartPane,
|
||||
stopRemotePane: async () => {
|
||||
order.push('stop')
|
||||
},
|
||||
resumeRemotePane
|
||||
})
|
||||
await vi.waitFor(() => expect(handleRestartPane).toHaveBeenCalledTimes(1))
|
||||
expect(order).toEqual(['stop', 'resume', 'reconnect'])
|
||||
expect(resumeRemotePane).toHaveBeenCalledWith({
|
||||
ptyId: REMOTE,
|
||||
worktreeId: 'wt-1',
|
||||
tabId: TAB,
|
||||
leafId: LEAF,
|
||||
agent: 'claude',
|
||||
providerSession
|
||||
})
|
||||
})
|
||||
|
||||
it('a failed resume on the host shows the failure and never reconnects', async () => {
|
||||
clickRemoteRestart()
|
||||
const warn = vi.spyOn(console, 'warn').mockImplementation(() => undefined)
|
||||
const handleRestartPane = vi.fn()
|
||||
runReadyPaneAccountRestarts({
|
||||
panes: [{ id: 7, leafId: LEAF }],
|
||||
ptyIdOf: () => REMOTE,
|
||||
readyRestartPtyIds: selectReadyAccountRestartPtyIds(useAppStore.getState(), TAB),
|
||||
tabId: TAB,
|
||||
consumePendingPaneAccountRestart: useAppStore.getState().consumePendingPaneAccountRestart,
|
||||
buildAccountRestartStartup: () => ({
|
||||
command: 'claude',
|
||||
resumeProviderSession: { key: 'session_id' as const, id: 'sess-tab-1' }
|
||||
}),
|
||||
handleRestartPane,
|
||||
stopRemotePane: () => Promise.resolve(),
|
||||
resumeRemotePane: () => Promise.reject(new Error('agent_session_resume_failed'))
|
||||
})
|
||||
await vi.waitFor(() => expect(cloudAgentRestartAttempt(PANE_KEY, 0)?.phase).toBe('failed'))
|
||||
warn.mockRestore()
|
||||
expect(handleRestartPane).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('a reconnect that never binds a new PTY fails at the timeout', async () => {
|
||||
clickRemoteRestart()
|
||||
const handleRestartPane = runRemote(() => Promise.resolve())
|
||||
|
||||
+35
-8
@@ -12,7 +12,11 @@ import {
|
||||
} from '@/lib/orca-cloud/cloud-agent-restart-attempt'
|
||||
import type { TerminalPaneCloseController } from '../layout/close/use-terminal-pane-close-actions'
|
||||
import type { usePaneProcessRestart } from '../process-exit/use-pane-process-restart'
|
||||
import { stopRemotePaneTerminalForRestart } from '@/lib/orca-cloud/cloud-pane-restart-stop'
|
||||
import {
|
||||
resumeRemotePaneAgent,
|
||||
stopRemotePaneTerminalForRestart
|
||||
} from '@/lib/orca-cloud/cloud-pane-restart-stop'
|
||||
import type { AgentProviderSessionMetadata } from '../../../../../shared/agent-session/lifecycle/agent-session-resume'
|
||||
import { isRemoteRuntimePtyId } from '../../../../../shared/remote-runtime/remote-runtime-pty-id'
|
||||
import type { AccountRestartAgent } from '@/store/terminals/terminal-contracts'
|
||||
import type { usePaneAccountRestartStartup } from './use-pane-account-restart-startup'
|
||||
@@ -80,7 +84,9 @@ function worktreeIdForTab(tabId: string): string | null {
|
||||
return null
|
||||
}
|
||||
|
||||
export function runReadyPaneAccountRestarts<Startup>(input: {
|
||||
export function runReadyPaneAccountRestarts<
|
||||
Startup extends { resumeProviderSession?: AgentProviderSessionMetadata }
|
||||
>(input: {
|
||||
panes: readonly ReadyRestartPane[]
|
||||
ptyIdOf: (paneId: number) => string | null
|
||||
readyRestartPtyIds: readonly string[]
|
||||
@@ -90,8 +96,14 @@ export function runReadyPaneAccountRestarts<Startup>(input: {
|
||||
handleRestartPane: (paneId: number, startup: Startup, options: { focus: boolean }) => void
|
||||
/** Stops the one host terminal behind a paired pane before its restart reconnects. */
|
||||
stopRemotePane?: (ptyId: string, worktreeId: string) => Promise<void>
|
||||
/** Relaunches the paired pane's agent on its own conversation, in the same pane. */
|
||||
resumeRemotePane?: typeof resumeRemotePaneAgent
|
||||
}): void {
|
||||
const args = { stopRemotePane: stopRemotePaneTerminalForRestart, ...input }
|
||||
const args = {
|
||||
stopRemotePane: stopRemotePaneTerminalForRestart,
|
||||
resumeRemotePane: resumeRemotePaneAgent,
|
||||
...input
|
||||
}
|
||||
for (const pane of args.panes) {
|
||||
const ptyId = args.ptyIdOf(pane.id)
|
||||
if (
|
||||
@@ -136,10 +148,25 @@ export function runReadyPaneAccountRestarts<Startup>(input: {
|
||||
// terminal first (its exit is ours, not the pane's), then reconnect.
|
||||
const store = useAppStore.getState()
|
||||
store.suppressPtyExit(ptyId)
|
||||
void args.stopRemotePane(ptyId, worktreeId).then(launch, (err: unknown) => {
|
||||
console.warn('[orca-cloud] restart could not stop the pane’s host terminal:', err)
|
||||
useAppStore.getState().consumeSuppressedPtyExit(ptyId)
|
||||
failCloudAgentRestart(ptyId)
|
||||
})
|
||||
const providerSession = launchedStartup.resumeProviderSession ?? null
|
||||
void args
|
||||
.stopRemotePane(ptyId, worktreeId)
|
||||
.then(() =>
|
||||
providerSession
|
||||
? args.resumeRemotePane({
|
||||
ptyId,
|
||||
worktreeId,
|
||||
tabId: args.tabId,
|
||||
leafId: pane.leafId,
|
||||
agent,
|
||||
providerSession
|
||||
})
|
||||
: undefined
|
||||
)
|
||||
.then(launch, (err: unknown) => {
|
||||
console.warn('[orca-cloud] restart could not relaunch the pane’s agent on its host:', err)
|
||||
useAppStore.getState().consumeSuppressedPtyExit(ptyId)
|
||||
failCloudAgentRestart(ptyId)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -28061,7 +28061,7 @@
|
||||
"restarting": "Restarting {{agent}}…",
|
||||
"restartingResume": "Picking up where you left off.",
|
||||
"restartFailed": "Couldn’t restart {{agent}}",
|
||||
"tryAgainDetail": "Try again.",
|
||||
"restartHostUnresponsive": "The workspace didn’t respond.",
|
||||
"restartFailedAnnounce": "Couldn’t restart {{agent}}. Try again.",
|
||||
"restartFailedIn": "Couldn’t restart {{agent}} in {{workspace}}.",
|
||||
"openWorkspace": "Open workspace"
|
||||
|
||||
@@ -25,6 +25,8 @@ export type CloudAgentRestartAttempt = {
|
||||
ptyId: string
|
||||
/** Set when the pane's restart actually ran (a new PTY was asked for). */
|
||||
launchedAt: number | null
|
||||
/** Why it failed, when known: the workspace's host did not do what was asked. */
|
||||
failure?: 'hostUnresponsive'
|
||||
}
|
||||
|
||||
export const CLOUD_AGENT_RESTART_TIMEOUT_MS = 10_000
|
||||
@@ -99,13 +101,16 @@ export function noteCloudAgentRestartLaunched(ptyId: string, now = Date.now()):
|
||||
}
|
||||
|
||||
/** The pane's restart could not run (no startup to launch, or it threw). */
|
||||
export function failCloudAgentRestart(ptyId: string): void {
|
||||
export function failCloudAgentRestart(
|
||||
ptyId: string,
|
||||
failure?: CloudAgentRestartAttempt['failure']
|
||||
): void {
|
||||
forcedRestarts.delete(ptyId)
|
||||
const paneKey = attemptKeyForPty(ptyId)
|
||||
const attempt = paneKey ? restarts.get(paneKey) : undefined
|
||||
if (paneKey && attempt) {
|
||||
stopRestartTimer(paneKey)
|
||||
restarts.set(paneKey, { ...attempt, phase: 'failed' })
|
||||
restarts.set(paneKey, { ...attempt, phase: 'failed', ...(failure ? { failure } : {}) })
|
||||
notifyCloudRunningStrips()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,7 +11,13 @@ vi.mock('@/runtime/rpc/calls/runtime-rpc-call', () => ({
|
||||
}
|
||||
}))
|
||||
|
||||
const { stopRemotePaneTerminalForRestart } = await import('./cloud-pane-restart-stop')
|
||||
vi.mock(
|
||||
'@/runtime/hosted-terminals/creation/agent-launch/agent-session-ensure-provider-session',
|
||||
() => ({ agentProviderSessionForHostEnsure: async (_env: string, session: unknown) => session })
|
||||
)
|
||||
|
||||
const { resumeRemotePaneAgent, stopRemotePaneTerminalForRestart } =
|
||||
await import('./cloud-pane-restart-stop')
|
||||
|
||||
describe('stopRemotePaneTerminalForRestart (RA-11 paired pane)', () => {
|
||||
beforeEach(() => {
|
||||
@@ -47,3 +53,31 @@ describe('stopRemotePaneTerminalForRestart (RA-11 paired pane)', () => {
|
||||
expect(calls).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
describe('resumeRemotePaneAgent (RA-11 paired pane)', () => {
|
||||
beforeEach(() => {
|
||||
calls.length = 0
|
||||
})
|
||||
|
||||
it('ensures the agent on its own session, placed in the same host tab and leaf', async () => {
|
||||
await resumeRemotePaneAgent({
|
||||
ptyId: 'remote:env-1@@term_a',
|
||||
worktreeId: 'wt-1',
|
||||
tabId: 'web-terminal-host-tab%3A%3Aleaf',
|
||||
leafId: 'leaf-1',
|
||||
agent: 'claude',
|
||||
providerSession: { key: 'session_id', id: 'sess-1' }
|
||||
})
|
||||
expect(calls).toHaveLength(1)
|
||||
expect(calls[0]).toMatchObject({
|
||||
method: 'terminal.ensureAgentSession',
|
||||
environmentId: 'env-1',
|
||||
params: {
|
||||
kind: 'explicit',
|
||||
agent: 'claude',
|
||||
providerSession: { key: 'session_id', id: 'sess-1' },
|
||||
placement: { tabId: 'host-tab::leaf', leafId: 'leaf-1' }
|
||||
}
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -7,6 +7,10 @@ import { parseRemoteRuntimePtyId } from '../../../../shared/remote-runtime/remot
|
||||
import type { RuntimeTerminalShow } from '../../../../shared/runtime-terminal-contracts'
|
||||
import { callRuntimeRpc } from '@/runtime/rpc/calls/runtime-rpc-call'
|
||||
import { toRuntimeWorktreeSelector } from '@/runtime/rpc/targets/runtime-worktree-selector'
|
||||
import { agentProviderSessionForHostEnsure } from '@/runtime/hosted-terminals/creation/agent-launch/agent-session-ensure-provider-session'
|
||||
import { toHostSessionTabId } from '../../../../shared/workspace-layout/terminal-surface-id'
|
||||
import type { AgentProviderSessionMetadata } from '../../../../shared/agent-session/lifecycle/agent-session-resume'
|
||||
import type { AccountRestartAgent } from '@/store/terminals/terminal-contracts'
|
||||
|
||||
type StopExactResult = {
|
||||
stoppedPtyIds?: string[]
|
||||
@@ -52,3 +56,36 @@ export async function stopRemotePaneTerminalForRestart(
|
||||
throw new Error(result.postStopFailure ?? 'restart_remote_pane_stop_unverified')
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Relaunch the pane's agent on its own conversation, in the same pane. Without this the host wakes
|
||||
* the sleeping pane with its original launch (a new conversation). Only for a resumable session;
|
||||
* a pane with none takes the host's fresh launch, which is what "Starts a new conversation" says.
|
||||
*/
|
||||
export async function resumeRemotePaneAgent(args: {
|
||||
ptyId: string
|
||||
worktreeId: string
|
||||
tabId: string
|
||||
leafId: string
|
||||
agent: AccountRestartAgent
|
||||
providerSession: AgentProviderSessionMetadata
|
||||
}): Promise<void> {
|
||||
const environmentId = parseRemoteRuntimePtyId(args.ptyId)?.environmentId
|
||||
if (!environmentId) {
|
||||
throw new Error('restart_remote_pane_unowned')
|
||||
}
|
||||
await callRuntimeRpc(
|
||||
{ kind: 'environment', environmentId },
|
||||
'terminal.ensureAgentSession',
|
||||
{
|
||||
kind: 'explicit',
|
||||
worktree: toRuntimeWorktreeSelector(args.worktreeId),
|
||||
agent: args.agent,
|
||||
providerSession: await agentProviderSessionForHostEnsure(environmentId, args.providerSession),
|
||||
// The same pane: the host tab and leaf the stopped terminal lived in.
|
||||
placement: { tabId: toHostSessionTabId(args.tabId), leafId: args.leafId },
|
||||
presentation: 'background'
|
||||
},
|
||||
{ timeoutMs: 15_000 }
|
||||
)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user