diff --git a/src/renderer/src/lib/launch-structured-agent-session.test.ts b/src/renderer/src/lib/launch-structured-agent-session.test.ts index 4b77be456bd..5f46d5b60d2 100644 --- a/src/renderer/src/lib/launch-structured-agent-session.test.ts +++ b/src/renderer/src/lib/launch-structured-agent-session.test.ts @@ -131,6 +131,85 @@ describe('structured agent session launch', () => { expect(callStructuredAgentSession).toHaveBeenCalledOnce() }) + /** A worktree is not resolvable for a beat after createWorktree resolves, so the probe fails with + * selector_not_found instead of answering. That is "not ready", not "no". */ + it('retries a probe the host cannot answer yet, then creates', async () => { + const notResolvableYet = Object.assign(new Error('selector_not_found'), { + code: 'selector_not_found' + }) + vi.mocked(callStructuredAgentSession) + .mockRejectedValueOnce(notResolvableYet) + .mockRejectedValueOnce(notResolvableYet) + .mockImplementation(async (_target, method) => + method === 'agentSession.createSupport' + ? { supported: true } + : { ok: true, replayed: false, value: { sessionId: 'claude_1', fence: 1 } } + ) + + const intent = createStructuredAgentSessionLaunchIntent('workspace-1', 'claude') + await expect(launchStructuredAgentSession(intent)).resolves.toMatchObject({ + sessionId: 'claude_1' + }) + expect(vi.mocked(callStructuredAgentSession).mock.calls.map(([, method]) => method)).toEqual([ + 'agentSession.createSupport', + 'agentSession.createSupport', + 'agentSession.createSupport', + 'agentSession.create' + ]) + }) + + it('refuses once the retry budget for an unresolvable selector is spent', async () => { + vi.mocked(callStructuredAgentSession).mockRejectedValue( + Object.assign(new Error('selector_not_found'), { code: 'selector_not_found' }) + ) + + const intent = createStructuredAgentSessionLaunchIntent('workspace-1', 'claude') + + await expect(launchStructuredAgentSession(intent)).rejects.toBeInstanceOf( + StructuredAgentSessionCreateRefusalError + ) + // Bounded: the first ask plus the retry delays, and never agentSession.create. + expect(vi.mocked(callStructuredAgentSession).mock.calls.map(([, method]) => method)).toEqual([ + 'agentSession.createSupport', + 'agentSession.createSupport', + 'agentSession.createSupport', + 'agentSession.createSupport' + ]) + }) + + it('does not retry a host that answered no, or an unrelated failure', async () => { + vi.mocked(callStructuredAgentSession).mockResolvedValue({ supported: false, reason: 'wsl' }) + await expect( + launchStructuredAgentSession( + createStructuredAgentSessionLaunchIntent('workspace-1', 'claude') + ) + ).rejects.toBeInstanceOf(StructuredAgentSessionCreateRefusalError) + expect(callStructuredAgentSession).toHaveBeenCalledOnce() + + vi.mocked(callStructuredAgentSession).mockReset() + vi.mocked(callStructuredAgentSession).mockRejectedValue(new Error('runtime unreachable')) + await expect( + launchStructuredAgentSession( + createStructuredAgentSessionLaunchIntent('workspace-1', 'claude') + ) + ).rejects.toBeInstanceOf(StructuredAgentSessionCreateRefusalError) + expect(callStructuredAgentSession).toHaveBeenCalledOnce() + }) + + /** A message-wrapped token must not be confused with prose that merely mentions it. */ + it('does not retry a failure that only mentions the token in passing', async () => { + vi.mocked(callStructuredAgentSession).mockRejectedValue( + new Error('Access denied after a prior selector_not_found') + ) + + await expect( + launchStructuredAgentSession( + createStructuredAgentSessionLaunchIntent('workspace-1', 'claude') + ) + ).rejects.toBeInstanceOf(StructuredAgentSessionCreateRefusalError) + expect(callStructuredAgentSession).toHaveBeenCalledOnce() + }) + /** Codex's support answer is settled by the launch route and owned elsewhere; this pins that the * Claude probe did not change Codex's wire traffic. */ it('does not probe create support for Codex', async () => { diff --git a/src/renderer/src/lib/launch-structured-agent-session.ts b/src/renderer/src/lib/launch-structured-agent-session.ts index efc8e746a00..0d12ca91d40 100644 --- a/src/renderer/src/lib/launch-structured-agent-session.ts +++ b/src/renderer/src/lib/launch-structured-agent-session.ts @@ -8,6 +8,7 @@ import { createStructuredAgentSessionOperationId, structuredAgentSessionPayloadFingerprint } from '../../../shared/structured-agent-session-mutation' +import { hasRuntimeRpcErrorCode } from '../../../shared/runtime-rpc-error-code' import { callStructuredAgentSession } from '@/runtime/structured-agent-session-client' import { toRuntimeWorktreeSelector } from '@/runtime/runtime-worktree-selector' import { useAppStore } from '@/store' @@ -77,29 +78,67 @@ export function abandonStructuredAgentSessionLaunchIntent( ) } +/** The host answers a worktree selector it cannot resolve yet with this rather than a verdict. */ +const SELECTOR_NOT_RESOLVABLE_CODE = 'selector_not_found' + +/** + * A worktree is not resolvable for a beat after `createWorktree` resolves, so a probe fired + * immediately after creation fails instead of answering. Measured window: under ~250ms. These + * delays cover it with margin and bound the wait when the selector is genuinely absent. + */ +const CREATE_SUPPORT_RETRY_DELAYS_MS: readonly number[] = [50, 150, 300] + +function delay(ms: number): Promise { + return new Promise((resolve) => setTimeout(resolve, ms)) +} + +/** + * Whether the executing host supports creating this session — retrying only while the host cannot + * yet resolve the worktree. + * + * "Could not answer" and "answered no" are different states and only the second is a verdict. + * Collapsing them sends a launch to the terminal because a selector was a beat late, which is + * indistinguishable to the user from the gate refusing them. The retry is narrowed to that one + * transient code so every other failure still refuses on the first ask. + */ +async function hostSupportsCreate(intent: StructuredAgentSessionLaunchIntent): Promise { + for (let attempt = 0; ; attempt += 1) { + try { + const support = await callStructuredAgentSession<{ supported: boolean; reason?: string }>( + { kind: 'local' }, + 'agentSession.createSupport', + { worktree: intent.params.worktree, agent: intent.agent } + ) + return support.supported === true + } catch (error) { + const retryDelayMs = CREATE_SUPPORT_RETRY_DELAYS_MS[attempt] + if ( + retryDelayMs === undefined || + !hasRuntimeRpcErrorCode(error, SELECTOR_NOT_RESOLVABLE_CODE) + ) { + // An unanswered probe is still not a yes. + return false + } + await delay(retryDelayMs) + } + } +} + /** * Only the host that will execute the session can answer whether it supports creating one there — * on Windows that means reading the provider child's process start time, which a client cannot - * observe. Codex is absent on purpose: its answer is settled by the launch route and owned - * elsewhere, so probing here would change Codex's wire traffic. + * observe. + * + * Codex is absent on purpose: its answer is settled by the launch route and owned elsewhere, so + * probing here would change Codex's wire traffic. Note that this early return is also why the + * unresolvable-selector race above has never been able to refuse a Codex launch — the race is + * identical for Codex, nothing asks. Whoever gives Codex a probe inherits it. */ async function requireHostCreateSupport(intent: StructuredAgentSessionLaunchIntent): Promise { if (intent.agent !== 'claude') { return } - let supported = false - try { - const support = await callStructuredAgentSession<{ supported: boolean; reason?: string }>( - { kind: 'local' }, - 'agentSession.createSupport', - { worktree: intent.params.worktree, agent: intent.agent } - ) - supported = support.supported === true - } catch { - // An unanswered probe is not a yes. - supported = false - } - if (!supported) { + if (!(await hostSupportsCreate(intent))) { abandonStructuredAgentSessionLaunchIntent(intent) throw new StructuredAgentSessionCreateRefusalError('structured_agent_session_unsupported') }