Skip to content

Commit c751849

Browse files
authored
fix(sandbox): preserve stopped resume failures (#184)
1 parent 2773458 commit c751849

2 files changed

Lines changed: 299 additions & 49 deletions

File tree

src/sandbox/index.test.ts

Lines changed: 204 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -252,7 +252,9 @@ describe('ensureWorkspaceSandbox lifecycle', () => {
252252

253253
it('deletes and recreates on harness mismatch', async () => {
254254
const stale = fakeBox({ name: 'box-w1', metadata: { harness: 'claude-code' } })
255-
listMock.mockResolvedValue([stale])
255+
listMock.mockImplementation(({ status }: { status: string }) =>
256+
Promise.resolve(status === 'running' ? [stale] : []),
257+
)
256258
createMock.mockResolvedValue(fakeBox())
257259
await ensureWorkspaceSandbox(shell(), { workspaceId: 'w1', harness: 'opencode' })
258260
expect(stale.delete).toHaveBeenCalledTimes(1)
@@ -276,11 +278,13 @@ describe('ensureWorkspaceSandbox lifecycle', () => {
276278
expect(createMock).not.toHaveBeenCalled()
277279
})
278280

279-
it('creates fresh when list fails (list error does not abort provisioning)', async () => {
280-
listMock.mockRejectedValue(new Error('list down'))
281-
createMock.mockResolvedValue(fakeBox())
282-
await ensureWorkspaceSandbox(shell(), { workspaceId: 'w1', harness: 'opencode' })
283-
expect(createMock).toHaveBeenCalledTimes(1)
281+
it('preserves a stopped-box lookup error instead of creating with the same identity', async () => {
282+
const error = new Error('list down')
283+
listMock.mockRejectedValue(error)
284+
await expect(
285+
ensureWorkspaceSandbox(shell(), { workspaceId: 'w1', harness: 'opencode' }),
286+
).rejects.toBe(error)
287+
expect(createMock).not.toHaveBeenCalled()
284288
})
285289

286290
it('refreshes when the created box has no runtimeUrl', async () => {
@@ -708,6 +712,200 @@ describe('ensureWorkspaceSandbox — new seams', () => {
708712
expect(box).toBe(stopped)
709713
})
710714

715+
it('preserves a typed egress recovery error from a stopped-box resume', async () => {
716+
class EgressProxyRecoveryError extends Error {
717+
readonly code = 'EGRESS_PROXY_RECOVERY_REQUIRED'
718+
readonly status = 409
719+
readonly phase = 'egress_proxy_recovery'
720+
721+
constructor() {
722+
super('Egress proxy recovery required')
723+
this.name = 'EgressProxyRecoveryError'
724+
}
725+
}
726+
727+
const error = new EgressProxyRecoveryError()
728+
const stopped = fakeBox({
729+
name: 'box-w1',
730+
resume: vi.fn().mockRejectedValue(error),
731+
})
732+
listMock.mockImplementation(({ status }: { status: string }) =>
733+
Promise.resolve(status === 'stopped' ? [stopped] : []),
734+
)
735+
736+
await expect(
737+
ensureWorkspaceSandbox(shellFor({ apiKey: 'k', baseUrl: 'u' }), {
738+
workspaceId: 'w1',
739+
harness: 'opencode',
740+
}),
741+
).rejects.toBe(error)
742+
743+
expect(stopped.delete).not.toHaveBeenCalled()
744+
expect(createMock).not.toHaveBeenCalled()
745+
})
746+
747+
it('preserves a generic stopped-box resume error instead of creating', async () => {
748+
const error = new Error('resume unavailable')
749+
const stopped = fakeBox({
750+
name: 'box-w1',
751+
resume: vi.fn().mockRejectedValue(error),
752+
})
753+
listMock.mockImplementation(({ status }: { status: string }) =>
754+
Promise.resolve(status === 'stopped' ? [stopped] : []),
755+
)
756+
757+
await expect(
758+
ensureWorkspaceSandbox(shellFor({ apiKey: 'k', baseUrl: 'u' }), {
759+
workspaceId: 'w1',
760+
harness: 'opencode',
761+
}),
762+
).rejects.toBe(error)
763+
764+
expect(stopped.delete).not.toHaveBeenCalled()
765+
expect(createMock).not.toHaveBeenCalled()
766+
})
767+
768+
it('lets product recovery choose deletion, restore, and a fresh create identity', async () => {
769+
const error = new Error('resume unavailable')
770+
const stopped = fakeBox({
771+
name: 'box-w1',
772+
resume: vi.fn().mockRejectedValue(error),
773+
})
774+
const recovered = fakeBox({ name: 'box-w1-recovery' })
775+
const restore = { fromSnapshot: 'snapshot-1', fromSandboxId: stopped.id }
776+
const recoverStoppedSandbox = vi.fn(async (failure: { box: SandboxInstance }) => {
777+
await failure.box.delete()
778+
return {
779+
succeeded: true as const,
780+
value: {
781+
replacementBoxKey: 'box-w1-recovery',
782+
restore,
783+
},
784+
}
785+
})
786+
listMock.mockImplementation(({ status }: { status: string }) =>
787+
Promise.resolve(status === 'stopped' ? [stopped] : []),
788+
)
789+
createMock.mockResolvedValue(recovered)
790+
791+
await ensureWorkspaceSandbox(shellFor({ apiKey: 'k', baseUrl: 'u' }, {
792+
recoverStoppedSandbox,
793+
}), { workspaceId: 'w1', harness: 'opencode' })
794+
795+
expect(recoverStoppedSandbox).toHaveBeenCalledWith(expect.objectContaining({
796+
box: stopped,
797+
error,
798+
boxKey: 'box-w1',
799+
scope: { workspaceId: 'w1' },
800+
}))
801+
expect(stopped.delete).toHaveBeenCalledOnce()
802+
expect(createMock).toHaveBeenCalledOnce()
803+
expect(createMock.mock.calls[0]![0]).toMatchObject({
804+
name: 'box-w1-recovery',
805+
idempotencyKey: 'box-w1-recovery',
806+
fromSnapshot: 'snapshot-1',
807+
fromSandboxId: stopped.id,
808+
})
809+
})
810+
811+
it('lets product recovery decline and preserve the original resume error', async () => {
812+
const error = new Error('resume unavailable')
813+
const stopped = fakeBox({
814+
name: 'box-w1',
815+
resume: vi.fn().mockRejectedValue(error),
816+
})
817+
const recoverStoppedSandbox = vi.fn(async () => ({
818+
succeeded: true as const,
819+
value: null,
820+
}))
821+
listMock.mockImplementation(({ status }: { status: string }) =>
822+
Promise.resolve(status === 'stopped' ? [stopped] : []),
823+
)
824+
825+
await expect(
826+
ensureWorkspaceSandbox(shellFor({ apiKey: 'k', baseUrl: 'u' }, {
827+
recoverStoppedSandbox,
828+
}), { workspaceId: 'w1', harness: 'opencode' }),
829+
).rejects.toBe(error)
830+
831+
expect(stopped.delete).not.toHaveBeenCalled()
832+
expect(createMock).not.toHaveBeenCalled()
833+
})
834+
835+
it('preserves a failed product recovery outcome', async () => {
836+
const resumeError = new Error('resume unavailable')
837+
const recoveryError = new Error('snapshot failed')
838+
const stopped = fakeBox({
839+
name: 'box-w1',
840+
resume: vi.fn().mockRejectedValue(resumeError),
841+
})
842+
const recoverStoppedSandbox = vi.fn(async () => ({
843+
succeeded: false as const,
844+
error: recoveryError,
845+
}))
846+
listMock.mockImplementation(({ status }: { status: string }) =>
847+
Promise.resolve(status === 'stopped' ? [stopped] : []),
848+
)
849+
850+
await expect(
851+
ensureWorkspaceSandbox(shellFor({ apiKey: 'k', baseUrl: 'u' }, {
852+
recoverStoppedSandbox,
853+
}), { workspaceId: 'w1', harness: 'opencode' }),
854+
).rejects.toBe(recoveryError)
855+
856+
expect(stopped.delete).not.toHaveBeenCalled()
857+
expect(createMock).not.toHaveBeenCalled()
858+
})
859+
860+
it('lets product recovery suppress a configured restore', async () => {
861+
const error = new Error('resume unavailable')
862+
const stopped = fakeBox({
863+
name: 'box-w1',
864+
resume: vi.fn().mockRejectedValue(error),
865+
})
866+
const recovered = fakeBox({ name: 'box-w1-recovery' })
867+
const recoverStoppedSandbox = vi.fn(async () => ({
868+
succeeded: true as const,
869+
value: { replacementBoxKey: 'box-w1-recovery', restore: null },
870+
}))
871+
listMock.mockImplementation(({ status }: { status: string }) =>
872+
Promise.resolve(status === 'stopped' ? [stopped] : []),
873+
)
874+
createMock.mockResolvedValue(recovered)
875+
876+
await ensureWorkspaceSandbox(shellFor({ apiKey: 'k', baseUrl: 'u' }, {
877+
restore: () => ({ fromSnapshot: 'normal-snapshot', fromSandboxId: 'normal-sandbox' }),
878+
recoverStoppedSandbox,
879+
}), { workspaceId: 'w1', harness: 'opencode' })
880+
881+
const payload = createMock.mock.calls[0]![0]
882+
expect(payload.fromSnapshot).toBeUndefined()
883+
expect(payload.fromSandboxId).toBeUndefined()
884+
})
885+
886+
it('rejects stopped-box recovery that reuses the failed create identity', async () => {
887+
const error = new Error('resume unavailable')
888+
const stopped = fakeBox({
889+
name: 'box-w1',
890+
resume: vi.fn().mockRejectedValue(error),
891+
})
892+
const recoverStoppedSandbox = vi.fn(async () => ({
893+
succeeded: true as const,
894+
value: { replacementBoxKey: 'box-w1' },
895+
}))
896+
listMock.mockImplementation(({ status }: { status: string }) =>
897+
Promise.resolve(status === 'stopped' ? [stopped] : []),
898+
)
899+
900+
await expect(
901+
ensureWorkspaceSandbox(shellFor({ apiKey: 'k', baseUrl: 'u' }, {
902+
recoverStoppedSandbox,
903+
}), { workspaceId: 'w1', harness: 'opencode' }),
904+
).rejects.toThrow(/fresh replacement box key/)
905+
906+
expect(createMock).not.toHaveBeenCalled()
907+
})
908+
711909
it('spreads storage + restore into the create payload', async () => {
712910
listMock.mockResolvedValue([])
713911
createMock.mockResolvedValue(fakeBox({ waitFor: vi.fn(), refresh: vi.fn(), connection: { runtimeUrl: 'x' } as never }))

0 commit comments

Comments
 (0)