@@ -34,6 +34,7 @@ import {
3434 setSandboxQuotaPeriod ,
3535} from "./project-sandbox-metering" ;
3636import { assertProjectSandboxOwnerActive } from "./project-sandbox-owner-admission" ;
37+ import { PROC_PREFIX , PROCESS_PORT_ALLOC_KEY } from "./project-sandbox-process-support" ;
3738import { ProjectSandboxProvisioning } from "./project-sandbox-provisioning" ;
3839import type {
3940 ParsedProjectCleanupWorkspaceInput ,
@@ -46,6 +47,9 @@ import {
4647} from "./project-sandbox-workspace-state" ;
4748
4849const ClearWorkspaceEvidenceSchema = z . object ( { cleared : z . literal ( true ) } ) . strict ( ) ;
50+ const RUNTIME_MANIFEST_PATH = "/workspace/.cheatcode/runtime.json" ;
51+ const RUNTIME_RESET_PENDING_KEY = "sandbox_runtime_reset_pending" ;
52+ const SKILL_RUNTIME_DIRECTORY = "/workspace/.cheatcode/runtime" ;
4953
5054export abstract class ProjectSandboxLifecycle extends DurableObject < ProjectSandboxEnv > {
5155 private accountDeletionCompleted = false ;
@@ -56,6 +60,7 @@ export abstract class ProjectSandboxLifecycle extends DurableObject<ProjectSandb
5660 private daytonaClient : DaytonaClient | undefined ;
5761 private daytonaId : string | undefined ;
5862 private sandboxMutationTail : Promise < void > = Promise . resolve ( ) ;
63+ private sandboxRuntimeUpdateInProgress = false ;
5964 private startedVerifiedAtMs = 0 ;
6065 private readonly identityState : ProjectSandboxIdentityState ;
6166 private readonly provisioning : ProjectSandboxProvisioning ;
@@ -146,13 +151,15 @@ export abstract class ProjectSandboxLifecycle extends DurableObject<ProjectSandb
146151 operation : ( ) => Promise < T > ,
147152 isSharedMutation = false ,
148153 shouldLeaseUnknownWorkspace = false ,
154+ allowRuntimeUpdate = false ,
149155 ) : Promise < T > {
150156 let release : ( ( ) => void ) | undefined ;
151157 try {
152158 release = this . acquireActiveOperation (
153159 workspaceScope ,
154160 isSharedMutation ,
155161 shouldLeaseUnknownWorkspace ,
162+ allowRuntimeUpdate ,
156163 ) ;
157164 return operation ( ) . finally ( release ) ;
158165 } catch ( error ) {
@@ -187,7 +194,7 @@ export abstract class ProjectSandboxLifecycle extends DurableObject<ProjectSandb
187194 protected withActiveSandboxCleanupSignal ( operation : ( ) => Promise < void > ) : Promise < void > {
188195 return this . accountDeletionInProgress || ! this . identityState . hasRegisteredOwner ( )
189196 ? Promise . resolve ( )
190- : this . withActiveSandboxOperation ( operation ) ;
197+ : this . withActiveOperation ( null , operation , false , false , true ) ;
191198 }
192199
193200 private async initializeIdentityState ( ) : Promise < void > {
@@ -245,7 +252,7 @@ export abstract class ProjectSandboxLifecycle extends DurableObject<ProjectSandb
245252 remaining . push ( { runId, startedMs : Date . now ( ) } ) ;
246253 await this . ctx . storage . put ( RUN_LEASES_KEY , remaining ) ;
247254 try {
248- const id = await this . ensureSandbox ( ) ;
255+ const id = await this . ensureSandbox ( runId ) ;
249256 await this . client ( )
250257 . setAutoStopInterval ( id , 0 )
251258 . catch ( ( ) => undefined ) ;
@@ -441,10 +448,14 @@ export abstract class ProjectSandboxLifecycle extends DurableObject<ProjectSandb
441448 private acquireActiveSandboxOperation (
442449 allowUnregisteredOwner = false ,
443450 allowWorkspaceCleanup = false ,
451+ allowRuntimeUpdate = false ,
444452 ) : ( ) => void {
445453 if ( this . accountDeletionInProgress ) {
446454 throw accountSandboxDeletedError ( ) ;
447455 }
456+ if ( this . sandboxRuntimeUpdateInProgress && ! allowRuntimeUpdate ) {
457+ throw sandboxRuntimeUpdatePending ( this . env . DAYTONA_SANDBOX_SNAPSHOT ) ;
458+ }
448459 if ( ! allowUnregisteredOwner && ! this . identityState . hasRegisteredOwner ( ) ) {
449460 throw accountSandboxDeletedError ( ) ;
450461 }
@@ -468,8 +479,9 @@ export abstract class ProjectSandboxLifecycle extends DurableObject<ProjectSandb
468479 workspaceScope : string | readonly string [ ] | null ,
469480 isSharedMutation = false ,
470481 shouldLeaseUnknownWorkspace = false ,
482+ allowRuntimeUpdate = false ,
471483 ) : ( ) => void {
472- const releaseSandbox = this . acquireActiveSandboxOperation ( ) ;
484+ const releaseSandbox = this . acquireActiveSandboxOperation ( false , false , allowRuntimeUpdate ) ;
473485 let releaseWorkspace : ( ( ) => void ) | undefined ;
474486 try {
475487 const workspaceSlugs =
@@ -529,12 +541,12 @@ export abstract class ProjectSandboxLifecycle extends DurableObject<ProjectSandb
529541 this . startedVerifiedAtMs = Date . now ( ) ;
530542 }
531543
532- protected async ensureSandbox ( ) : Promise < string > {
544+ protected async ensureSandbox ( startingRunId ?: string ) : Promise < string > {
533545 return this . withSandboxMutation ( async ( ) => {
534546 if ( this . daytonaId && Date . now ( ) - this . startedVerifiedAtMs < STARTED_REVERIFY_MS ) {
535547 return this . daytonaId ;
536548 }
537- return this . resolveStartedSandbox ( ) ;
549+ return this . resolveStartedSandbox ( startingRunId ) ;
538550 } ) ;
539551 }
540552
@@ -588,11 +600,14 @@ export abstract class ProjectSandboxLifecycle extends DurableObject<ProjectSandb
588600 }
589601 }
590602
591- private async resolveStartedSandbox ( ) : Promise < string > {
603+ private async resolveStartedSandbox ( startingRunId ?: string ) : Promise < string > {
592604 const client = await this . ensureClient ( ) ;
593605 let resolved : DaytonaSandbox ;
594606 try {
595607 resolved = await this . provisioning . resolve ( client ) ;
608+ if ( ! this . provisioning . isDesired ( resolved ) ) {
609+ resolved = await this . replaceSandboxRuntime ( client , resolved , startingRunId ) ;
610+ }
596611 } catch ( error ) {
597612 throw this . toUpstreamError ( error , "Daytona sandbox lookup failed." ) ;
598613 }
@@ -603,10 +618,78 @@ export abstract class ProjectSandboxLifecycle extends DurableObject<ProjectSandb
603618 retriable : true ,
604619 } ) ;
605620 }
621+ await this . clearPersistedRuntimeProjection ( client , resolved . id ) ;
606622 this . startedVerifiedAtMs = Date . now ( ) ;
607623 return resolved . id ;
608624 }
609625
626+ private async replaceSandboxRuntime (
627+ client : DaytonaClient ,
628+ current : DaytonaSandbox ,
629+ startingRunId ?: string ,
630+ ) : Promise < DaytonaSandbox > {
631+ this . sandboxRuntimeUpdateInProgress = true ;
632+ try {
633+ await this . assertSandboxReplacementAllowed ( startingRunId ) ;
634+ this . provisioning . assertRuntimeReplacementSafe ( current ) ;
635+ await this . prepareForSandboxReplacement ( ) ;
636+ await this . provisioning . deleteForReplacement ( client , current ) ;
637+ const replacement = await this . provisioning . create ( client ) ;
638+ if ( ! this . provisioning . isDesired ( replacement ) ) {
639+ throw sandboxRuntimeUpdatePending ( this . env . DAYTONA_SANDBOX_SNAPSHOT ) ;
640+ }
641+ createLogger ( ) . info ( "sandbox_runtime_replaced" , {
642+ sandboxId : this . sandboxName ( ) ,
643+ snapshot : this . env . DAYTONA_SANDBOX_SNAPSHOT ,
644+ } ) ;
645+ return replacement ;
646+ } finally {
647+ this . sandboxRuntimeUpdateInProgress = false ;
648+ }
649+ }
650+
651+ private async assertSandboxReplacementAllowed ( startingRunId ?: string ) : Promise < void > {
652+ const leases = await this . runLeases ( ) ;
653+ const activeRunLeases = leases . filter (
654+ ( lease ) => Date . now ( ) - lease . startedMs < STALE_RUN_LEASE_MS ,
655+ ) ;
656+ if ( activeRunLeases . length !== leases . length ) {
657+ await this . ctx . storage . put ( RUN_LEASES_KEY , activeRunLeases ) ;
658+ }
659+ const otherRunLeases = activeRunLeases . filter ( ( lease ) => lease . runId !== startingRunId ) ;
660+ if ( this . activeOperationCount > 1 || otherRunLeases . length > 0 ) {
661+ throw sandboxRuntimeUpdatePending ( this . env . DAYTONA_SANDBOX_SNAPSHOT ) ;
662+ }
663+ }
664+
665+ private async prepareForSandboxReplacement ( ) : Promise < void > {
666+ this . daytonaId = undefined ;
667+ this . startedVerifiedAtMs = 0 ;
668+ await this . ctx . storage . delete ( DAYTONA_ID_KEY ) ;
669+ await this . ctx . storage . put ( RUNTIME_RESET_PENDING_KEY , true ) ;
670+ const processRecords = await this . ctx . storage . list ( { prefix : PROC_PREFIX } ) ;
671+ if ( processRecords . size > 0 ) {
672+ await this . ctx . storage . delete ( [ ...processRecords . keys ( ) ] ) ;
673+ }
674+ await this . ctx . storage . delete ( PROCESS_PORT_ALLOC_KEY ) ;
675+ }
676+
677+ private async clearPersistedRuntimeProjection (
678+ client : DaytonaClient ,
679+ sandboxId : string ,
680+ ) : Promise < void > {
681+ if ( ( await this . ctx . storage . get ( RUNTIME_RESET_PENDING_KEY ) ) !== true ) {
682+ return ;
683+ }
684+ try {
685+ await client . deleteFilePath ( sandboxId , RUNTIME_MANIFEST_PATH , false ) ;
686+ await client . deleteFilePath ( sandboxId , SKILL_RUNTIME_DIRECTORY , true ) ;
687+ await this . ctx . storage . delete ( RUNTIME_RESET_PENDING_KEY ) ;
688+ } catch ( error ) {
689+ throw this . toUpstreamError ( error , "Daytona runtime reset failed." ) ;
690+ }
691+ }
692+
610693 protected async existingSandboxId ( ) : Promise < string | null > {
611694 const client = await this . ensureClient ( ) ;
612695 try {
@@ -708,3 +791,16 @@ export abstract class ProjectSandboxLifecycle extends DurableObject<ProjectSandb
708791 this . startedVerifiedAtMs = 0 ;
709792 }
710793}
794+
795+ function sandboxRuntimeUpdatePending ( expectedSnapshot : string ) : APIError {
796+ return new APIError (
797+ 503 ,
798+ "unavailable_maintenance" ,
799+ "This computer is updating to the current runtime." ,
800+ {
801+ details : { expectedSnapshot } ,
802+ hint : "Retry after the active operation finishes." ,
803+ retriable : true ,
804+ } ,
805+ ) ;
806+ }
0 commit comments