mirror of
https://github.com/stablyai/orca.git
synced 2026-11-14 01:47:24 +00:00
test(terminal): prove refused starts recover through their own Retry
Start the replaced-while-listed Sleep's successor only after the listed process's exit has settled its files, so its cold read is not refused mid-write. Prove a fresh pane refused before it started arms Retry and starts fresh on it, and that an agent session refused for unavailable host history is re-ensured with its original request and attached on Retry. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
81c1e42d04
commit
aaa6db6eac
+3
@@ -277,6 +277,9 @@ describe('host Sleep through disk-only Wake', () => {
|
||||
const stale = relaunched.shutdown(sessionId, { keepHistory: true })
|
||||
await held
|
||||
listed._simulateExit(0)
|
||||
// The successor's cold read starts once the listed process's exit has settled its files.
|
||||
await waitFor(() => harness!.server.host.listSessions().length === 0, 10_000)
|
||||
await harness!.server.host.historyJournals!.settleAll()
|
||||
await relaunched.spawn(request(sessionId))
|
||||
const successor = child()
|
||||
release.resolve()
|
||||
|
||||
+51
-4
@@ -1,6 +1,7 @@
|
||||
import { bindPtyConnectionTestMocks } from '../test-fixtures/pty-connection-mocks.spec'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { flushAsyncTicks } from '../test-fixtures/pty-connection-test-async'
|
||||
import { sendTerminalInputThroughPane } from '../test-fixtures/pty-connection-test-dom'
|
||||
import {
|
||||
LEAF_2,
|
||||
createMockTransport,
|
||||
@@ -32,20 +33,24 @@ export function registerPtyConnectionOwnerErrorRetryTests(): void {
|
||||
return buildPaneConnectionDeps(() => mockStoreState, overrides)
|
||||
}
|
||||
|
||||
function seedUnverifiableRestoredPane() {
|
||||
function seedPaneLayout(ptyId: string | null) {
|
||||
mockStoreState = {
|
||||
...mockStoreState,
|
||||
tabsByWorktree: { 'wt-1': [{ id: 'tab-1', ptyId: 'unverifiable-pty' }] },
|
||||
ptyIdsByTabId: { 'tab-1': ['unverifiable-pty'] },
|
||||
tabsByWorktree: { 'wt-1': [{ id: 'tab-1', ptyId }] },
|
||||
ptyIdsByTabId: { 'tab-1': ptyId ? [ptyId] : [] },
|
||||
terminalLayoutsByTabId: {
|
||||
'tab-1': {
|
||||
root: { type: 'leaf', leafId: LEAF_2 },
|
||||
activeLeafId: LEAF_2,
|
||||
expandedLeafId: null,
|
||||
ptyIdsByLeafId: { [LEAF_2]: 'unverifiable-pty' }
|
||||
ptyIdsByLeafId: ptyId ? { [LEAF_2]: ptyId } : {}
|
||||
}
|
||||
}
|
||||
} as StoreState
|
||||
}
|
||||
|
||||
function seedUnverifiableRestoredPane() {
|
||||
seedPaneLayout('unverifiable-pty')
|
||||
return createDeps({
|
||||
restoredLeafId: LEAF_2,
|
||||
restoredPtyIdByLeafId: { [LEAF_2]: 'unverifiable-pty' }
|
||||
@@ -175,6 +180,48 @@ export function registerPtyConnectionOwnerErrorRetryTests(): void {
|
||||
})
|
||||
})
|
||||
|
||||
// R1: a fresh start refused before it began offers Retry, which starts the same pane fresh.
|
||||
it('starts a refused fresh pane on Retry and delivers its input', async () => {
|
||||
const { connectPanePty } = await import('../connect-pane-pty')
|
||||
const agentTitleMocks = await vi.importMock<PtyConnectionAgentTitleMocks>(
|
||||
'@/lib/agent-status/aggregation/worktree-agent-status'
|
||||
)
|
||||
const transport = createMockTransport()
|
||||
transport.connect.mockImplementation(async (opts: { callbacks?: ConnectCallbacks }) => {
|
||||
opts.callbacks?.onError?.('terminal_history_unavailable')
|
||||
return undefined
|
||||
})
|
||||
transportFactoryQueue.push(transport)
|
||||
seedPaneLayout(null)
|
||||
const deps = createDeps()
|
||||
const pane = createPane(2)
|
||||
|
||||
connectPanePty(
|
||||
...mockPanePtyArgs(pane, createManager(2), bindPaneAgentTitleMocks(deps, agentTitleMocks))
|
||||
)
|
||||
await flushAsyncTicks()
|
||||
|
||||
expect(deps.onPtyErrorRef.current).toHaveBeenCalledWith(
|
||||
2,
|
||||
'terminal_history_unavailable_before_start'
|
||||
)
|
||||
expect(deps.syncPanePtyLayoutBinding).not.toHaveBeenCalled()
|
||||
transport.connect.mockImplementation(async ({ callbacks }) => {
|
||||
callbacks?.onConnect?.()
|
||||
return { id: 'fresh-pty' }
|
||||
})
|
||||
// The refused start settles through several turns before it arms Retry.
|
||||
await vi.waitFor(() => expect(transport.retryRecovery?.()).toBe(true))
|
||||
await flushAsyncTicks()
|
||||
|
||||
expect(transport.connect).toHaveBeenCalledTimes(2)
|
||||
expect(transport.connect.mock.calls[1]?.[0].sessionId).toBeUndefined()
|
||||
expect(createdTransportOptions).toHaveLength(1)
|
||||
expect(deps.onPtyErrorRef.current).toHaveBeenCalledTimes(1)
|
||||
sendTerminalInputThroughPane(pane, 'typed')
|
||||
expect(transport.sendInput.mock.lastCall?.[0]).toBe('typed')
|
||||
})
|
||||
|
||||
it.each([false, true])(
|
||||
'preserves an exhausted pane until explicit Retry and clears only the current owner error (fresh: %s)',
|
||||
async (fresh) => {
|
||||
|
||||
+86
-24
@@ -75,7 +75,13 @@ describe('remote terminal whose host history is briefly unavailable', () => {
|
||||
})
|
||||
})
|
||||
|
||||
type CreateParams = { clientMutationId?: string; reconcileExisting?: boolean }
|
||||
type CreateParams = {
|
||||
clientMutationId?: string
|
||||
reconcileExisting?: boolean
|
||||
providerSession?: { id: string }
|
||||
}
|
||||
|
||||
const RESUME = { key: 'session_id', id: 'provider-session-1' } as const
|
||||
|
||||
// GH1346: the host may refuse a create for unavailable history after spawning its terminal.
|
||||
describe('remote terminal create refused for unavailable host history', () => {
|
||||
@@ -86,45 +92,81 @@ describe('remote terminal create refused for unavailable host history', () => {
|
||||
runtimeCall.mockImplementation(
|
||||
async (request: { method: string; selector: string; params?: CreateParams }) => {
|
||||
if (request.method === 'status.get') {
|
||||
return { ok: true, result: { capabilities } }
|
||||
return {
|
||||
ok: true,
|
||||
result: {
|
||||
runtimeProtocolVersion: 3,
|
||||
minCompatibleRuntimeClientVersion: 2,
|
||||
capabilities
|
||||
}
|
||||
}
|
||||
}
|
||||
if (request.method !== 'terminal.create') {
|
||||
const ensure = request.method === 'terminal.ensureAgentSession'
|
||||
if (request.method !== 'terminal.create' && !ensure) {
|
||||
return healthyCall?.(request)
|
||||
}
|
||||
const mutation = request.params?.clientMutationId
|
||||
if (!request.params?.reconcileExisting || !spawned.includes(mutation)) {
|
||||
spawned.push(mutation)
|
||||
// An ensure adopts its provider session's terminal; a create adopts only when reconciling.
|
||||
const key = ensure ? request.params?.providerSession?.id : request.params?.clientMutationId
|
||||
const existing = spawned.includes(key)
|
||||
if (!existing || !(ensure || request.params?.reconcileExisting)) {
|
||||
spawned.push(key)
|
||||
}
|
||||
return historyReady
|
||||
? { ok: true, result: { terminal: { handle: 'terminal-1' } } }
|
||||
? {
|
||||
ok: true,
|
||||
result: {
|
||||
terminal: { handle: 'terminal-1' },
|
||||
...(ensure ? { disposition: existing ? 'adopted' : 'created' } : {})
|
||||
}
|
||||
}
|
||||
: { ok: false, error: { code: 'runtime_error', message: 'terminal_history_unavailable' } }
|
||||
}
|
||||
)
|
||||
const creates = (): CreateParams[] =>
|
||||
const calls = (method: string): CreateParams[] =>
|
||||
runtimeCall.mock.calls
|
||||
.filter(([request]) => request.method === 'terminal.create')
|
||||
.filter(([request]) => request.method === method)
|
||||
.map(([request]) => request.params)
|
||||
return {
|
||||
spawned,
|
||||
creates,
|
||||
creates: () => calls('terminal.create'),
|
||||
ensures: () => calls('terminal.ensureAgentSession'),
|
||||
restoreHistory: () => {
|
||||
historyReady = true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function mountRefusedPane() {
|
||||
async function mountRefusedPane(agent?: {
|
||||
launchAgent: 'claude'
|
||||
resumeProviderSession: typeof RESUME
|
||||
}) {
|
||||
const { createRemoteRuntimePtyTransport } = await import('./remote-runtime-pty-transport')
|
||||
const transport = createRemoteRuntimePtyTransport('env-1', {
|
||||
worktreeId: 'wt-1',
|
||||
tabId: 'tab-1',
|
||||
leafId: 'pane:1'
|
||||
leafId: 'pane:1',
|
||||
...agent
|
||||
})
|
||||
void transport.connect({ url: '', cols: 80, rows: 24, callbacks: {} })
|
||||
await vi.advanceTimersByTimeAsync(REMOTE_RUNTIME_AUTO_RECOVERY_TIMEOUT_MS + 1_000)
|
||||
return transport
|
||||
}
|
||||
|
||||
async function attachAndType(transport: Awaited<ReturnType<typeof mountRefusedPane>>) {
|
||||
expect(transport.retryRecovery?.()).toBe(true)
|
||||
await vi.waitFor(() => expect(subscribeFrameCount()).toBe(1))
|
||||
const { streamId } = latestSubscribePayload()
|
||||
callbacks?.onResponse({ ok: true, result: { type: 'subscribed', streamId } })
|
||||
emitSnapshot(streamId, 'prompt$ ')
|
||||
await vi.advanceTimersByTimeAsync(0)
|
||||
expect(transport.sendInput('typed\r', 'driving')).toBe(true)
|
||||
await vi.waitFor(() => expect(inputFrameTexts().join('')).toBe('typed\r'))
|
||||
expect(subscribedTerminalHandles()).toEqual(['terminal-1'])
|
||||
expect(new Set(runtimeCall.mock.calls.map(([request]) => request.selector))).toEqual(
|
||||
new Set(['env-1'])
|
||||
)
|
||||
}
|
||||
|
||||
it('reconciles that one create, then Retry attaches it and delivers input', async () => {
|
||||
vi.useFakeTimers()
|
||||
try {
|
||||
@@ -137,15 +179,8 @@ describe('remote terminal create refused for unavailable host history', () => {
|
||||
expect(reconciles.length).toBeGreaterThan(0)
|
||||
|
||||
host.restoreHistory()
|
||||
expect(transport.retryRecovery?.()).toBe(true)
|
||||
await vi.waitFor(() => expect(subscribeFrameCount()).toBe(1))
|
||||
const { streamId } = latestSubscribePayload()
|
||||
callbacks?.onResponse({ ok: true, result: { type: 'subscribed', streamId } })
|
||||
emitSnapshot(streamId, 'prompt$ ')
|
||||
await vi.advanceTimersByTimeAsync(0)
|
||||
expect(transport.sendInput('typed\r', 'driving')).toBe(true)
|
||||
await attachAndType(transport)
|
||||
|
||||
await vi.waitFor(() => expect(inputFrameTexts().join('')).toBe('typed\r'))
|
||||
expect(host.spawned).toEqual([first.clientMutationId])
|
||||
expect(first.clientMutationId).toBeTruthy()
|
||||
for (const reconcile of host.creates().slice(1)) {
|
||||
@@ -154,10 +189,37 @@ describe('remote terminal create refused for unavailable host history', () => {
|
||||
reconcileExisting: true
|
||||
})
|
||||
}
|
||||
expect(subscribedTerminalHandles()).toEqual(['terminal-1'])
|
||||
expect(new Set(runtimeCall.mock.calls.map(([request]) => request.selector))).toEqual(
|
||||
new Set(['env-1'])
|
||||
)
|
||||
transport.destroy?.()
|
||||
} finally {
|
||||
vi.useRealTimers()
|
||||
}
|
||||
})
|
||||
|
||||
it('re-ensures an agent session with its original request, then Retry attaches it', async () => {
|
||||
vi.useFakeTimers()
|
||||
try {
|
||||
const host = installHost(['agent-session.host-authority.v1'])
|
||||
const transport = await mountRefusedPane({
|
||||
launchAgent: 'claude',
|
||||
resumeProviderSession: RESUME
|
||||
})
|
||||
|
||||
expect(transport.getRecoveryState?.().phase).toBe('disconnected')
|
||||
const [first, ...reensures] = host.ensures()
|
||||
expect(first).toMatchObject({
|
||||
providerSession: RESUME,
|
||||
placement: { tabId: 'tab-1', leafId: 'pane:1' }
|
||||
})
|
||||
expect(reensures.length).toBeGreaterThan(0)
|
||||
|
||||
host.restoreHistory()
|
||||
await attachAndType(transport)
|
||||
|
||||
expect(host.spawned).toEqual([RESUME.id])
|
||||
for (const reensure of host.ensures().slice(1)) {
|
||||
expect(reensure).toEqual(first)
|
||||
}
|
||||
expect(host.creates()).toEqual([])
|
||||
transport.destroy?.()
|
||||
} finally {
|
||||
vi.useRealTimers()
|
||||
|
||||
Reference in New Issue
Block a user