diff --git a/CHANGELOG.md b/CHANGELOG.md index bf946055d..f904a2c39 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,7 +5,11 @@ All notable changes to Agent Relay will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). -## [Unreleased] +## [Unreleased - Patch] + +### Fixed + +- `fleet spawn --sandbox` automatically resumes its own sandbox after an interrupted Cloud provisioning response, preserving one sandbox identity instead of requiring a manual `--sandbox-id` replay. ## [13.0.1] - 2026-10-02 diff --git a/packages/cli/src/cli/commands/fleet.test.ts b/packages/cli/src/cli/commands/fleet.test.ts index 7236424cb..e2b412b11 100644 --- a/packages/cli/src/cli/commands/fleet.test.ts +++ b/packages/cli/src/cli/commands/fleet.test.ts @@ -1245,7 +1245,7 @@ describe('fleet command support', () => { }); }); - it('plain --sandbox materializes the inferred repository through Relayfile and starts in its live relative cwd', async () => { + it('plain --sandbox resumes an interrupted owned provision and starts in its live relative cwd', async () => { vi.stubEnv('RELAY_AGENT_TOKEN', undefined); const revision = '0123456789abcdef0123456789abcdef01234567'; const repositorySelection = { @@ -1269,8 +1269,17 @@ describe('fleet command support', () => { sentinelPath: '/github/repos/AgentWorkforce/cloud/.relayfile/clone.json', }; }); - const ensureCloudFleetSandbox = vi.fn(async () => { + const ensureCloudFleetSandbox = vi.fn(async (input: { sandboxId?: string; name?: string }) => { events.push('ensure'); + if (ensureCloudFleetSandbox.mock.calls.length === 1) { + throw new CloudFleetSandboxProvisionError('gateway timed out', { + cloudWorkspaceId: 'cloud-workspace', + sandboxId: input.sandboxId, + nodeName: input.name, + providerId: 'agent37', + outcomeUnknown: true, + }); + } return { outcome: 'provisioned' as const, providerId: 'agent37' as const, @@ -1346,7 +1355,7 @@ describe('fleet command support', () => { { from: 'user' } ); - expect(events).toEqual(['materialize', 'ensure', 'spawn']); + expect(events).toEqual(['materialize', 'ensure', 'ensure', 'spawn']); expect(materializeCloudRelayfileRepository).toHaveBeenCalledWith({ workspaceId: 'rw_abc', repository: 'AgentWorkforce/cloud', @@ -1363,6 +1372,11 @@ describe('fleet command support', () => { ], }) ); + expect(ensureCloudFleetSandbox).toHaveBeenCalledTimes(2); + expect(ensureCloudFleetSandbox.mock.calls[1]?.[0]).toMatchObject({ + sandboxId: ensureCloudFleetSandbox.mock.calls[0]?.[0].sandboxId, + name: ensureCloudFleetSandbox.mock.calls[0]?.[0].name, + }); const ensureInput = ensureCloudFleetSandbox.mock.calls[0]?.[0] as Record; expect(ensureInput.repos).toBeUndefined(); expect(ensureInput.repoRevisions).toBeUndefined(); diff --git a/packages/cli/src/cli/commands/fleet.ts b/packages/cli/src/cli/commands/fleet.ts index ba8a884e1..9ee65dca7 100644 --- a/packages/cli/src/cli/commands/fleet.ts +++ b/packages/cli/src/cli/commands/fleet.ts @@ -94,6 +94,7 @@ const FLEET_CLIS = new Set([ const MIN_VERIFIED_CONFIRM_TIMEOUT_MS = 95_000; const CLOUD_SANDBOX_ID_PATTERN = /^sbx_[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/; +const MAX_OWNED_SANDBOX_RESUME_ATTEMPTS = 3; function spawnInvocationWithPlacement(invocation: Record): Record { return { ...invocation, placement: spawnPlacementReceipt(invocation) }; @@ -238,6 +239,30 @@ export interface FleetCommandDependencies { retireOwnedBindings: typeof retireOwnedIntegrationBindings; } +async function ensureOwnedCloudFleetSandbox( + deps: Pick, + input: Parameters[0], + ownedSandboxId: string | undefined +): Promise { + for (let resumeAttempt = 0; ; resumeAttempt += 1) { + try { + return await deps.ensureCloudFleetSandbox(input); + } catch (error) { + if ( + ownedSandboxId === undefined || + !(error instanceof CloudFleetSandboxProvisionError) || + !error.outcomeUnknown || + resumeAttempt >= MAX_OWNED_SANDBOX_RESUME_ATTEMPTS + ) { + throw error; + } + deps.warn( + `Cloud interrupted preparation of caller-owned sandbox '${ownedSandboxId}'; resuming the same sandbox (${resumeAttempt + 1}/${MAX_OWNED_SANDBOX_RESUME_ATTEMPTS}).` + ); + } + } +} + function withFleetDefaults(overrides: Partial = {}): FleetCommandDependencies { const core = overrides.core ?? withDefaults(); const sdk = overrides.sdk ?? withSdkDefaults(); @@ -714,27 +739,31 @@ export function registerFleetCommands( ? 'long-running-agent' : 'standard-long-running-agent'; try { - sandbox = await deps.ensureCloudFleetSandbox({ - workspaceId: relayWorkspaceId, - requiredCapability: `spawn:${cli}`, - maxAgents: 1, - mountRelayfile: mountSandboxRelayfile, - ...(liveRepository - ? { relayfilePaths: liveRelayfileMountPaths(liveRepository, sandboxRelayfilePaths) } - : sandboxRelayfilePaths === undefined - ? {} - : { relayfilePaths: sandboxRelayfilePaths }), - ...(sandboxId === undefined ? {} : { sandboxId }), - forceProvision: true, - ...(sandboxProvider === undefined ? {} : { providerId: sandboxProvider }), - workloadProfile, - waitTimeoutMs: 90_000, - ...(effectiveSandboxName === undefined ? {} : { name: effectiveSandboxName }), - ...(checkoutRepository && sandboxRepository ? { repos: [sandboxRepository.repository] } : {}), - ...(checkoutRepository && sandboxRepository - ? { repoRevisions: { [sandboxRepository.repository]: sandboxRepository.revision } } - : {}), - }); + sandbox = await ensureOwnedCloudFleetSandbox( + deps, + { + workspaceId: relayWorkspaceId, + requiredCapability: `spawn:${cli}`, + maxAgents: 1, + mountRelayfile: mountSandboxRelayfile, + ...(liveRepository + ? { relayfilePaths: liveRelayfileMountPaths(liveRepository, sandboxRelayfilePaths) } + : sandboxRelayfilePaths === undefined + ? {} + : { relayfilePaths: sandboxRelayfilePaths }), + ...(sandboxId === undefined ? {} : { sandboxId }), + forceProvision: true, + ...(sandboxProvider === undefined ? {} : { providerId: sandboxProvider }), + workloadProfile, + waitTimeoutMs: 90_000, + ...(effectiveSandboxName === undefined ? {} : { name: effectiveSandboxName }), + ...(checkoutRepository && sandboxRepository ? { repos: [sandboxRepository.repository] } : {}), + ...(checkoutRepository && sandboxRepository + ? { repoRevisions: { [sandboxRepository.repository]: sandboxRepository.revision } } + : {}), + }, + shouldCleanupSandbox ? sandboxId : undefined + ); assertSandboxRepositoryRevision(sandbox, checkoutRepository ? sandboxRepository : undefined); } catch (error) { if (