@@ -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 ( / f r e s h r e p l a c e m e n t b o x k e y / )
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