1- import { mkdtempSync , rmSync } from "node:fs" ;
1+ import { existsSync , mkdtempSync , readdirSync , readFileSync , rmSync } from "node:fs" ;
22import { tmpdir } from "node:os" ;
33import { join } from "node:path" ;
44import { afterEach , beforeEach , describe , expect , it , vi } from "vitest" ;
55
6- const renameState = vi . hoisted ( ( ) => ( {
7- callCount : 0 ,
8- failOnCall : 0 ,
6+ const fsState = vi . hoisted ( ( ) => ( {
7+ renameCallCount : 0 ,
8+ failRenameOnCalls : [ ] as number [ ] ,
9+ rmCallCount : 0 ,
10+ failRmOnCalls : [ ] as number [ ] ,
911} ) ) ;
1012
1113vi . mock ( "node:fs/promises" , async ( ) => {
1214 const actual = await vi . importActual < typeof import ( "node:fs/promises" ) > ( "node:fs/promises" ) ;
1315 return {
1416 ...actual ,
1517 rename : vi . fn ( async ( from : string , to : string ) => {
16- renameState . callCount += 1 ;
17- if ( renameState . failOnCall !== 0 && renameState . callCount === renameState . failOnCall ) {
18+ fsState . renameCallCount += 1 ;
19+ if ( fsState . failRenameOnCalls . includes ( fsState . renameCallCount ) ) {
1820 throw new Error ( "promote failed" ) ;
1921 }
2022 return actual . rename ( from , to ) ;
2123 } ) ,
24+ rm : vi . fn ( async ( ...args : Parameters < typeof actual . rm > ) => {
25+ fsState . rmCallCount += 1 ;
26+ if ( fsState . failRmOnCalls . includes ( fsState . rmCallCount ) ) {
27+ throw new Error ( "cleanup failed" ) ;
28+ }
29+ return actual . rm ( ...args ) ;
30+ } ) ,
2231 } ;
2332} ) ;
2433
@@ -37,8 +46,10 @@ describe("target store atomic reset", () => {
3746
3847 beforeEach ( ( ) => {
3948 workspacePath = mkdtempSync ( join ( tmpdir ( ) , "supervisor-target-store-atomic-" ) ) ;
40- renameState . callCount = 0 ;
41- renameState . failOnCall = 0 ;
49+ fsState . renameCallCount = 0 ;
50+ fsState . failRenameOnCalls = [ ] ;
51+ fsState . rmCallCount = 0 ;
52+ fsState . failRmOnCalls = [ ] ;
4253 } ) ;
4354
4455 afterEach ( ( ) => {
@@ -77,7 +88,7 @@ describe("target store atomic reset", () => {
7788 attemptCount : 1 ,
7889 } ) ;
7990
80- renameState . failOnCall = 2 ;
91+ fsState . failRenameOnCalls = [ 2 ] ;
8192
8293 await expect (
8394 resetTargetFiles ( workspacePath , {
@@ -110,4 +121,80 @@ describe("target store atomic reset", () => {
110121 } ,
111122 ] ) ;
112123 } ) ;
124+
125+ it ( "keeps the promoted target live when backup cleanup fails" , async ( ) => {
126+ await createTargetFiles ( workspacePath , {
127+ targetId : "tgt-1" ,
128+ sessionId : "sess-1" ,
129+ workspaceId : "ws-1" ,
130+ objective : "Old objective" ,
131+ createdAt : 1 ,
132+ } ) ;
133+
134+ fsState . failRmOnCalls = [ 1 ] ;
135+
136+ await expect (
137+ resetTargetFiles ( workspacePath , {
138+ targetId : "tgt-1" ,
139+ sessionId : "sess-1" ,
140+ workspaceId : "ws-1" ,
141+ objective : "New objective" ,
142+ createdAt : 3 ,
143+ } )
144+ ) . resolves . toBeUndefined ( ) ;
145+
146+ const meta = await readTargetMeta ( workspacePath , "tgt-1" ) ;
147+ const memory = await loadTargetMemory ( workspacePath , "tgt-1" ) ;
148+ const cycles = await readTargetCycleRecords ( workspacePath , "tgt-1" ) ;
149+
150+ expect ( meta . objective ) . toBe ( "New objective" ) ;
151+ expect ( memory ) . toMatchObject ( {
152+ targetId : "tgt-1" ,
153+ planGenerated : false ,
154+ stalledCount : 0 ,
155+ updatedAt : 3 ,
156+ } ) ;
157+ expect ( cycles ) . toEqual ( [ ] ) ;
158+ } ) ;
159+
160+ it ( "preserves the backup target when both promotion and restore fail" , async ( ) => {
161+ await createTargetFiles ( workspacePath , {
162+ targetId : "tgt-1" ,
163+ sessionId : "sess-1" ,
164+ workspaceId : "ws-1" ,
165+ objective : "Old objective" ,
166+ createdAt : 1 ,
167+ } ) ;
168+
169+ fsState . failRenameOnCalls = [ 2 , 3 ] ;
170+
171+ await expect (
172+ resetTargetFiles ( workspacePath , {
173+ targetId : "tgt-1" ,
174+ sessionId : "sess-1" ,
175+ workspaceId : "ws-1" ,
176+ objective : "New objective" ,
177+ createdAt : 3 ,
178+ } )
179+ ) . rejects . toThrow ( "promote failed" ) ;
180+
181+ const targetsRoot = join ( workspacePath , ".coder-studio" , "supervisor" , "targets" ) ;
182+ const entries = readdirSync ( targetsRoot ) ;
183+ const backupEntry = entries . find ( ( entry ) => entry . startsWith ( "tgt-1.backup-" ) ) ;
184+ const stagingEntry = entries . find ( ( entry ) => entry . startsWith ( "tgt-1.reset-" ) ) ;
185+
186+ expect ( existsSync ( join ( targetsRoot , "tgt-1" ) ) ) . toBe ( false ) ;
187+ expect ( backupEntry ) . toBeDefined ( ) ;
188+ expect ( stagingEntry ) . toBeDefined ( ) ;
189+
190+ const backupMeta = JSON . parse (
191+ readFileSync ( join ( targetsRoot , backupEntry ! , "meta.json" ) , "utf-8" )
192+ ) as { objective : string } ;
193+ const stagedMeta = JSON . parse (
194+ readFileSync ( join ( targetsRoot , stagingEntry ! , "meta.json" ) , "utf-8" )
195+ ) as { objective : string } ;
196+
197+ expect ( backupMeta . objective ) . toBe ( "Old objective" ) ;
198+ expect ( stagedMeta . objective ) . toBe ( "New objective" ) ;
199+ } ) ;
113200} ) ;
0 commit comments