@@ -697,6 +697,65 @@ describe("SnapshotManager", () => {
697697 true
698698 ) ;
699699 } ) ;
700+
701+ it ( "should handle deprecated snapshot race condition - avoid false positives from stale polls" , async ( ) => {
702+ const onSnapshotChange = vi . fn ( ) ;
703+
704+ // Mock MetadataClient to simulate runner ID change (restore detected) on first call
705+ let isFirstCall = true ;
706+ const mockMetadataClient = {
707+ getEnvOverrides : vi . fn ( ) . mockImplementation ( ( ) => {
708+ if ( isFirstCall ) {
709+ isFirstCall = false ;
710+ return Promise . resolve ( [ null , { TRIGGER_RUNNER_ID : "test-runner-2" } ] ) ; // Different runner ID = restore
711+ }
712+ return Promise . resolve ( [ null , { TRIGGER_RUNNER_ID : "test-runner-2" } ] ) ; // Same runner ID afterward
713+ } ) ,
714+ } ;
715+
716+ const manager = new SnapshotManager ( {
717+ runnerId : "test-runner-1" ,
718+ runFriendlyId : "test-run-1" ,
719+ initialSnapshotId : "snapshot-1" ,
720+ initialStatus : "EXECUTING_WITH_WAITPOINTS" ,
721+ logger : mockLogger ,
722+ metadataClient : mockMetadataClient as any ,
723+ onSnapshotChange,
724+ onSuspendable : mockSuspendableHandler ,
725+ } ) ;
726+
727+ // First update: Process restore transition with deprecated statuses (normal case)
728+ // This simulates: EXECUTING_WITH_WAITPOINTS -> [SUSPENDED, QUEUED] -> PENDING_EXECUTING
729+ await manager . handleSnapshotChanges ( [
730+ createRunExecutionData ( { snapshotId : "snapshot-suspended" , executionStatus : "SUSPENDED" } ) ,
731+ createRunExecutionData ( { snapshotId : "snapshot-queued" , executionStatus : "QUEUED" } ) ,
732+ createRunExecutionData ( { snapshotId : "snapshot-2" , executionStatus : "PENDING_EXECUTING" } ) ,
733+ ] ) ;
734+
735+ // First call should be deprecated=false (restore detected)
736+ expect ( onSnapshotChange ) . toHaveBeenCalledWith (
737+ expect . objectContaining ( { snapshot : expect . objectContaining ( { friendlyId : "snapshot-2" } ) } ) ,
738+ false
739+ ) ;
740+
741+ onSnapshotChange . mockClear ( ) ;
742+
743+ // Second update: Should only get new snapshot (race condition case)
744+ // This simulates a stale poll that returns: getSnapshotsSince(snapshot-1) -> [SUSPENDED, QUEUED, snapshot-2, snapshot-3]
745+ // The SUSPENDED/QUEUED should be ignored as already seen
746+ await manager . handleSnapshotChanges ( [
747+ createRunExecutionData ( { snapshotId : "snapshot-suspended" , executionStatus : "SUSPENDED" } ) , // Already seen
748+ createRunExecutionData ( { snapshotId : "snapshot-queued" , executionStatus : "QUEUED" } ) , // Already seen
749+ createRunExecutionData ( { snapshotId : "snapshot-2" , executionStatus : "PENDING_EXECUTING" } ) , // Already processed
750+ createRunExecutionData ( { snapshotId : "snapshot-3" , executionStatus : "EXECUTING" } ) , // New
751+ ] ) ;
752+
753+ // Should call onSnapshotChange with deprecated = false (no new deprecated snapshots)
754+ expect ( onSnapshotChange ) . toHaveBeenCalledWith (
755+ expect . objectContaining ( { snapshot : expect . objectContaining ( { friendlyId : "snapshot-3" } ) } ) ,
756+ false
757+ ) ;
758+ } ) ;
700759} ) ;
701760
702761// Helper to generate RunExecutionData with sensible defaults
0 commit comments