mirror of
https://github.com/stablyai/orca.git
synced 2026-09-22 00:02:31 +00:00
* fix(mobile): name a create's launch so a lost reply cannot build two workspaces `agent.launch` admits a caller-supplied `operationId` through a durable ledger, so exactly one execution happens and every replay returns the recorded answer. No client sent one, so the machinery was inert and the original defect was still live: mobile retries a lost create by design, and a retried launch built a second agent in a second workspace. Mobile now mints an operation id per create candidate and sends it whenever the host advertises `agent.launch.replay.v1`. The invariant is one operation per candidate. `computeAgentLaunchFingerprint` folds `target` whole, so the workspace name is inside the fingerprint; carrying one id across a name-collision bump would meet its own row under a differing fingerprint and refuse `agent_session_operation_conflict`, failing the create outright on the second candidate. The id is therefore minted beside `clientMutationId` at the top of each loop iteration and reused verbatim by every retry arm inside that candidate — never re-minted, since a new id is a new operation. Admission runs ahead of every effect, so `_invalid` / `_expired` / `_capacity` prove nothing launched: those re-send the same candidate unnamed rather than let bookkeeping fail a create the host would have performed. `_unknown` is the one refusal that is not safe to re-send, and it surfaces. Also corrects a false comment: the legacy path caches the whole launch under `clientMutationId`, so inside its 60s window a replay adds neither a workspace nor a surface, and outside it adds both — not "a second surface, never a second workspace". * fix(mobile): preserve launch identity on refusals * fix(mobile): use launch receipts to authorize replay * test: move mobile launch replay coverage outside node project * fix(mobile): enforce replay-safe launch delivery at the host * test: run mobile launch contracts in mobile checks * test: cover mobile launch contract workflow dependencies
249 lines
7.6 KiB
TypeScript
249 lines
7.6 KiB
TypeScript
import { describe, expect, it } from 'vitest'
|
|
import {
|
|
AGENT_LAUNCH_REPLAY_REQUIRED_RUNTIME_CAPABILITY,
|
|
AGENT_LAUNCH_REPLAY_RUNTIME_CAPABILITY,
|
|
AGENT_LAUNCH_RUNTIME_CAPABILITY
|
|
} from '../../../src/shared/protocol-version'
|
|
import type { RpcClient } from '../transport/rpc-client'
|
|
import { LogicalClientCutoverError } from '../transport/stable-logical-rpc-client'
|
|
import { readNewWorktreeRuntimeCapabilities } from './worktree-create-capability'
|
|
import {
|
|
WORKTREE_CREATE_DEDUPE_TTL_CLIENT_CEILING_MS,
|
|
WORKTREE_CREATE_DEDUPE_TTL_LEGACY_HOST_MS
|
|
} from './worktree-create-idempotency-policy'
|
|
|
|
type StatusOutcome =
|
|
| 'cutover'
|
|
| 'error'
|
|
| string[]
|
|
| {
|
|
capabilities?: string[]
|
|
worktreeCreateIdempotency?: unknown
|
|
}
|
|
|
|
function statusClient(outcomes: StatusOutcome[]): RpcClient {
|
|
let call = 0
|
|
return {
|
|
sendRequest: async () => {
|
|
const outcome = outcomes[Math.min(call, outcomes.length - 1)]!
|
|
call += 1
|
|
if (outcome === 'cutover') {
|
|
throw new LogicalClientCutoverError()
|
|
}
|
|
if (outcome === 'error') {
|
|
throw new Error('offline')
|
|
}
|
|
const result = Array.isArray(outcome)
|
|
? { capabilities: outcome, hostPlatform: 'darwin' }
|
|
: { ...outcome, hostPlatform: 'darwin' }
|
|
return {
|
|
id: '1',
|
|
ok: true,
|
|
result,
|
|
_meta: { runtimeId: 'r' }
|
|
}
|
|
}
|
|
} as unknown as RpcClient
|
|
}
|
|
|
|
describe('readNewWorktreeRuntimeCapabilities', () => {
|
|
it('reads task and idempotent-create support from status.get', async () => {
|
|
await expect(
|
|
readNewWorktreeRuntimeCapabilities(
|
|
statusClient([
|
|
{
|
|
capabilities: ['mobile.tasks.v1', 'worktree.create-idempotency.v1'],
|
|
worktreeCreateIdempotency: { dedupeTtlMs: 20_000 }
|
|
}
|
|
])
|
|
)
|
|
).resolves.toEqual({
|
|
tasksSupported: true,
|
|
worktreeCreateIdempotency: { dedupeTtlMs: 20_000 },
|
|
agentLaunch: false,
|
|
hostPlatform: 'darwin'
|
|
})
|
|
})
|
|
|
|
it('reads agent.launch support from the same status.get probe', async () => {
|
|
// Why: mobile must not send `agent.launch` to a host that never advertised it, and one probe
|
|
// has to answer that alongside create idempotency so a create cannot straddle two answers.
|
|
await expect(
|
|
readNewWorktreeRuntimeCapabilities(
|
|
statusClient([{ capabilities: [AGENT_LAUNCH_RUNTIME_CAPABILITY] }])
|
|
)
|
|
).resolves.toEqual({
|
|
tasksSupported: false,
|
|
worktreeCreateIdempotency: false,
|
|
// Advertising the method is not advertising the ledger: `operationId` degrades silently on a
|
|
// host that has one and not the other, so the two are answered separately.
|
|
agentLaunch: { replay: false },
|
|
hostPlatform: 'darwin'
|
|
})
|
|
})
|
|
|
|
it('reads launch replay support only when the host advertises the ledger', async () => {
|
|
await expect(
|
|
readNewWorktreeRuntimeCapabilities(
|
|
statusClient([
|
|
{
|
|
capabilities: [
|
|
AGENT_LAUNCH_RUNTIME_CAPABILITY,
|
|
AGENT_LAUNCH_REPLAY_REQUIRED_RUNTIME_CAPABILITY
|
|
]
|
|
}
|
|
])
|
|
)
|
|
).resolves.toEqual({
|
|
tasksSupported: false,
|
|
worktreeCreateIdempotency: false,
|
|
agentLaunch: { replay: true },
|
|
hostPlatform: 'darwin'
|
|
})
|
|
})
|
|
|
|
// A host cannot admit an operation for a method it does not have, so the ledger capability alone
|
|
// must not turn the launch route on.
|
|
it('ignores the replay capability on a host without agent.launch', async () => {
|
|
await expect(
|
|
readNewWorktreeRuntimeCapabilities(
|
|
statusClient([{ capabilities: [AGENT_LAUNCH_REPLAY_REQUIRED_RUNTIME_CAPABILITY] }])
|
|
)
|
|
).resolves.toMatchObject({ agentLaunch: false })
|
|
})
|
|
|
|
it('does not authorize replay from the optional-identity capability alone', async () => {
|
|
await expect(
|
|
readNewWorktreeRuntimeCapabilities(
|
|
statusClient([
|
|
{
|
|
capabilities: [AGENT_LAUNCH_RUNTIME_CAPABILITY, AGENT_LAUNCH_REPLAY_RUNTIME_CAPABILITY]
|
|
}
|
|
])
|
|
)
|
|
).resolves.toMatchObject({ agentLaunch: { replay: false } })
|
|
})
|
|
|
|
it('uses the bounded fallback for an old idempotent host without an advertisement', async () => {
|
|
await expect(
|
|
readNewWorktreeRuntimeCapabilities(statusClient([['worktree.create-idempotency.v1']]))
|
|
).resolves.toEqual({
|
|
tasksSupported: false,
|
|
worktreeCreateIdempotency: { dedupeTtlMs: WORKTREE_CREATE_DEDUPE_TTL_CLIENT_CEILING_MS },
|
|
agentLaunch: false,
|
|
hostPlatform: 'darwin'
|
|
})
|
|
})
|
|
|
|
it('fails closed when a valid idempotency container omits dedupeTtlMs', async () => {
|
|
await expect(
|
|
readNewWorktreeRuntimeCapabilities(
|
|
statusClient([
|
|
{
|
|
capabilities: ['worktree.create-idempotency.v1'],
|
|
worktreeCreateIdempotency: {}
|
|
}
|
|
])
|
|
)
|
|
).resolves.toEqual({
|
|
tasksSupported: false,
|
|
worktreeCreateIdempotency: { dedupeTtlMs: 0 },
|
|
agentLaunch: false,
|
|
hostPlatform: 'darwin'
|
|
})
|
|
})
|
|
|
|
it.each([
|
|
{ label: 'null', value: null },
|
|
{ label: 'string', value: 'nonsense' },
|
|
{ label: 'number', value: 20_000 },
|
|
{ label: 'empty array', value: [] }
|
|
])(
|
|
'fails closed for a malformed idempotency container ($label)',
|
|
async ({ value: worktreeCreateIdempotency }) => {
|
|
await expect(
|
|
readNewWorktreeRuntimeCapabilities(
|
|
statusClient([
|
|
{
|
|
capabilities: ['worktree.create-idempotency.v1'],
|
|
worktreeCreateIdempotency
|
|
}
|
|
])
|
|
)
|
|
).resolves.toEqual({
|
|
tasksSupported: false,
|
|
worktreeCreateIdempotency: { dedupeTtlMs: 0 },
|
|
agentLaunch: false,
|
|
hostPlatform: 'darwin'
|
|
})
|
|
}
|
|
)
|
|
|
|
it('clamps an over-large host advertisement to the client fallback ceiling', async () => {
|
|
await expect(
|
|
readNewWorktreeRuntimeCapabilities(
|
|
statusClient([
|
|
{
|
|
capabilities: ['worktree.create-idempotency.v1'],
|
|
worktreeCreateIdempotency: { dedupeTtlMs: 600_000 }
|
|
}
|
|
])
|
|
)
|
|
).resolves.toEqual({
|
|
tasksSupported: false,
|
|
worktreeCreateIdempotency: { dedupeTtlMs: WORKTREE_CREATE_DEDUPE_TTL_LEGACY_HOST_MS },
|
|
agentLaunch: false,
|
|
hostPlatform: 'darwin'
|
|
})
|
|
})
|
|
|
|
it.each(['60000', -1, Number.NaN, 60_000.5, {}])(
|
|
'fails closed for malformed host advertisement %j',
|
|
async (advertisedDedupeTtlMs) => {
|
|
await expect(
|
|
readNewWorktreeRuntimeCapabilities(
|
|
statusClient([
|
|
{
|
|
capabilities: ['worktree.create-idempotency.v1'],
|
|
worktreeCreateIdempotency: { dedupeTtlMs: advertisedDedupeTtlMs }
|
|
}
|
|
])
|
|
)
|
|
).resolves.toEqual({
|
|
tasksSupported: false,
|
|
worktreeCreateIdempotency: { dedupeTtlMs: 0 },
|
|
agentLaunch: false,
|
|
hostPlatform: 'darwin'
|
|
})
|
|
}
|
|
)
|
|
|
|
it('retries the safe status probe after a connection cutover', async () => {
|
|
await expect(
|
|
readNewWorktreeRuntimeCapabilities(
|
|
statusClient([
|
|
'cutover',
|
|
{
|
|
capabilities: ['worktree.create-idempotency.v1'],
|
|
worktreeCreateIdempotency: { dedupeTtlMs: 30_000 }
|
|
}
|
|
])
|
|
)
|
|
).resolves.toEqual({
|
|
tasksSupported: false,
|
|
worktreeCreateIdempotency: { dedupeTtlMs: 30_000 },
|
|
agentLaunch: false,
|
|
hostPlatform: 'darwin'
|
|
})
|
|
})
|
|
|
|
it('fails closed when capability detection is unavailable', async () => {
|
|
await expect(readNewWorktreeRuntimeCapabilities(statusClient(['error']))).resolves.toEqual({
|
|
tasksSupported: false,
|
|
worktreeCreateIdempotency: false,
|
|
agentLaunch: false,
|
|
hostPlatform: null
|
|
})
|
|
})
|
|
})
|