mirror of
https://github.com/stablyai/orca.git
synced 2026-09-29 16:02:50 +00:00
Merge commit '6ade5578596e4a1d173f7da6763e35ae9e0e9950' into brennanb2025/claude-sdk-ready-mainsync
This commit is contained in:
@@ -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 () => {
|
||||
|
||||
@@ -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<void> {
|
||||
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<boolean> {
|
||||
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<void> {
|
||||
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')
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user