@@ -15,12 +15,30 @@ vi.mock("../../../utils/storage", () => ({
1515 } ) ,
1616} ) )
1717
18- // Mock safeWriteJson to use plain fs writes in tests (avoids proper-lockfile issues)
18+ // Mock safeWriteJson to use plain fs writes but honor the merge callback.
1919vi . mock ( "../../../utils/safeWriteJson" , ( ) => ( {
20- safeWriteJson : vi . fn ( ) . mockImplementation ( async ( filePath : string , data : any ) => {
21- await fs . mkdir ( path . dirname ( filePath ) , { recursive : true } )
22- await fs . writeFile ( filePath , JSON . stringify ( data , null , "\t" ) , "utf8" )
23- } ) ,
20+ safeWriteJson : vi
21+ . fn ( )
22+ . mockImplementation (
23+ async (
24+ filePath : string ,
25+ data : unknown ,
26+ options ?: { merge ?: ( existing : unknown , incoming : unknown ) => unknown } ,
27+ ) => {
28+ await fs . mkdir ( path . dirname ( filePath ) , { recursive : true } )
29+ if ( options ?. merge ) {
30+ let existing : unknown = null
31+ try {
32+ const raw = await fs . readFile ( filePath , "utf8" )
33+ existing = JSON . parse ( raw )
34+ } catch {
35+ // File does not exist or is corrupt
36+ }
37+ data = options . merge ( existing , data )
38+ }
39+ await fs . writeFile ( filePath , JSON . stringify ( data , null , "\t" ) , "utf8" )
40+ } ,
41+ ) ,
2442} ) )
2543
2644function makeHistoryItem ( overrides : Partial < HistoryItem > = { } ) : HistoryItem {
@@ -166,12 +184,12 @@ describe("TaskHistoryStore cross-instance safety", () => {
166184 } )
167185
168186 /**
169- * Regression for #1231: two hosts each hold only their own task in cache and
170- * flush `_index.json` without watcher reconciliation. The last writer's
171- * index reflects only its own cache (LWW). Per-task files remain intact,
172- * and reconcile recovers the full set .
187+ * Two hosts each hold only their own task in cache
188+ * and flush `_index.json` without watcher reconciliation. The merge
189+ * callback in writeIndex preserves peer entries so neither host's
190+ * flush drops the other's task .
173191 */
174- it ( "reconcile recovers peer entries after a last-writer-wins index flush " , async ( ) => {
192+ it ( "index flush merges peer entries instead of clobbering them " , async ( ) => {
175193 await storeA . initialize ( )
176194 await storeB . initialize ( )
177195
@@ -181,6 +199,7 @@ describe("TaskHistoryStore cross-instance safety", () => {
181199 await storeA . upsert ( makeHistoryItem ( { id : "task-a" , task : "from A" , ts : 1000 } ) )
182200 await storeB . upsert ( makeHistoryItem ( { id : "task-b" , task : "from B" , ts : 2000 } ) )
183201
202+ // Each cache is intentionally partial.
184203 expect ( storeA . get ( "task-a" ) ) . toBeDefined ( )
185204 expect ( storeA . get ( "task-b" ) ) . toBeUndefined ( )
186205 expect ( storeB . get ( "task-b" ) ) . toBeDefined ( )
@@ -189,28 +208,97 @@ describe("TaskHistoryStore cross-instance safety", () => {
189208 await storeA . flushIndex ( )
190209 await storeB . flushIndex ( )
191210
192- // Per-task files survive regardless of which host flushed last .
211+ // Both entries survive in the index — no reconcile needed .
193212 const tasksDir = path . join ( tmpDir , "tasks" )
213+ const indexRaw = await fs . readFile ( path . join ( tasksDir , GlobalFileNames . historyIndex ) , "utf8" )
214+ const index = JSON . parse ( indexRaw ) as { entries : HistoryItem [ ] }
215+ const indexIds = index . entries . map ( ( entry ) => entry . id ) . sort ( )
216+ expect ( indexIds ) . toEqual ( [ "task-a" , "task-b" ] )
217+ } )
194218
195- // The index reflects the last writer's partial cache (LWW clobber).
196- const indexRawBefore = await fs . readFile ( path . join ( tasksDir , GlobalFileNames . historyIndex ) , "utf8" )
197- const indexBefore = JSON . parse ( indexRawBefore ) as { entries : HistoryItem [ ] }
198- expect ( indexBefore . entries . map ( ( e ) => e . id ) ) . toEqual ( [ "task-b" ] )
199- const taskDirs = ( await fs . readdir ( tasksDir ) ) . filter ( ( name ) => ! name . startsWith ( "_" ) && ! name . startsWith ( "." ) )
200- expect ( taskDirs . sort ( ) ) . toEqual ( [ "task-a" , "task-b" ] )
219+ /**
220+ * A deleted task must not reappear in the index after a subsequent flush.
221+ * The merge keeps peer entries only if their task directory exists.
222+ */
223+ it ( "index flush does not resurrect deleted tasks via the merge" , async ( ) => {
224+ await storeA . initialize ( )
225+ await storeB . initialize ( )
201226
202- // Reconcile rebuilds the full picture from authoritative per-task files.
203- await storeA . reconcile ( { forceRefresh : true } )
204- expect ( storeA . get ( "task-a" ) ) . toBeDefined ( )
205- expect ( storeA . get ( "task-b" ) ) . toBeDefined ( )
206- expect ( storeA . getAll ( ) ) . toHaveLength ( 2 )
227+ disableBackgroundReconciliation ( storeA )
228+ disableBackgroundReconciliation ( storeB )
229+
230+ await storeA . upsert ( makeHistoryItem ( { id : "keep" , ts : 1000 } ) )
231+ await storeA . upsert ( makeHistoryItem ( { id : "doomed" , ts : 2000 } ) )
232+ await storeA . flushIndex ( )
233+
234+ // Delete doomed — removes from cache and unlinks the file.
235+ await storeA . delete ( "doomed" )
236+ const taskDir = path . join ( tmpDir , "tasks" , "doomed" )
237+ await fs . rm ( taskDir , { recursive : true , force : true } )
207238
208- // A post-reconcile flush writes the complete set .
239+ // Flush again — the merge must not re-add "doomed" from the old index .
209240 await storeA . flushIndex ( )
241+
242+ const tasksDir = path . join ( tmpDir , "tasks" )
210243 const indexRaw = await fs . readFile ( path . join ( tasksDir , GlobalFileNames . historyIndex ) , "utf8" )
211244 const index = JSON . parse ( indexRaw ) as { entries : HistoryItem [ ] }
212245 const indexIds = index . entries . map ( ( entry ) => entry . id ) . sort ( )
213- expect ( indexIds ) . toEqual ( [ "task-a" , "task-b" ] )
246+ expect ( indexIds ) . toEqual ( [ "keep" ] )
247+ } )
248+
249+ /**
250+ * Host B completes a task on disk while host A's cache still has it
251+ * active. Host A's next save updates only totalCost (a full-object
252+ * upsert — the realistic production shape). The diff-delta merge
253+ * preserves B's status because status did not change in A's cache.
254+ */
255+ it ( "per-task diff-delta preserves a peer's status change on full-object upsert" , async ( ) => {
256+ await storeA . initialize ( )
257+
258+ // Base item with an explicit status — mirrors real production items.
259+ const base = makeHistoryItem ( { id : "shared-task" , status : "active" , totalCost : 0.01 , ts : 1000 } )
260+ await storeA . upsert ( base )
261+
262+ // Host B completes the task on disk; A's cache still has "active".
263+ const filePath = path . join ( tmpDir , "tasks" , "shared-task" , GlobalFileNames . historyItem )
264+ const onDisk = JSON . parse ( await fs . readFile ( filePath , "utf8" ) )
265+ onDisk . status = "completed"
266+ onDisk . completionResultSummary = "done by host B"
267+ await fs . writeFile ( filePath , JSON . stringify ( onDisk ) , "utf8" )
268+
269+ // Host A does a full-object upsert (the realistic path — spread the
270+ // cached item and change one field). The cached item has status: "active".
271+ await storeA . upsert ( { ...storeA . get ( "shared-task" ) ! , totalCost : 9.99 } )
272+
273+ const final = JSON . parse ( await fs . readFile ( filePath , "utf8" ) ) as HistoryItem
274+ expect ( final . totalCost ) . toBe ( 9.99 )
275+ // Status is preserved from disk because A's delta does not include
276+ // status — it was unchanged relative to A's cache.
277+ expect ( final . status ) . toBe ( "completed" )
278+ expect ( final . completionResultSummary ) . toBe ( "done by host B" )
279+ } )
280+
281+ /**
282+ * When both hosts change the same field, the last writer wins.
283+ * This is expected — true conflict resolution requires application
284+ * semantics that a generic merge cannot provide.
285+ */
286+ it ( "same-field changes from both hosts are last-writer-wins" , async ( ) => {
287+ await storeA . initialize ( )
288+ await storeB . initialize ( )
289+
290+ const base = makeHistoryItem ( { id : "shared-task" , status : "active" , totalCost : 0.01 , ts : 1000 } )
291+ await storeA . upsert ( base )
292+ await storeB . reconcile ( )
293+
294+ // Both hosts change totalCost.
295+ await storeA . upsert ( { ...storeA . get ( "shared-task" ) ! , totalCost : 1.0 } )
296+ await storeB . upsert ( { ...storeB . get ( "shared-task" ) ! , totalCost : 2.0 } )
297+
298+ const filePath = path . join ( tmpDir , "tasks" , "shared-task" , GlobalFileNames . historyItem )
299+ const final = JSON . parse ( await fs . readFile ( filePath , "utf8" ) ) as HistoryItem
300+ // B wrote last, so B's value wins.
301+ expect ( final . totalCost ) . toBe ( 2.0 )
214302 } )
215303} )
216304
0 commit comments