@@ -179,15 +179,23 @@ describe("TaskHistoryStore reconcileDelegationState", () => {
179179 expect ( unchanged ?. awaitingChildId ) . toBe ( "child-4" )
180180 } )
181181
182- it ( "repairs invalid delegation: delegated parent with no awaitingChildId → active (clears delegatedToId)" , async ( ) => {
183- const parent = makeItem ( { id : "parent-5" , status : "delegated" , delegatedToId : "stale-child" } )
182+ it ( "repairs invalid delegation: delegated parent with no awaitingChildId → active (clears delegatedToId and awaitingChildId)" , async ( ) => {
183+ // awaitingChildId is falsy but explicitly set (empty string), delegatedToId is stale
184+ const parent = makeItem ( {
185+ id : "parent-5" ,
186+ status : "delegated" ,
187+ delegatedToId : "stale-child" ,
188+ awaitingChildId : "" ,
189+ } as any )
184190 await seedItems ( [ parent ] )
185191
186192 await store . initialize ( )
187193
188194 const repaired = store . get ( "parent-5" )
189195 expect ( repaired ?. status ) . toBe ( "active" )
190196 expect ( repaired ?. delegatedToId ) . toBeUndefined ( )
197+ // Fix #4: falsy awaitingChildId must also be cleared
198+ expect ( repaired ?. awaitingChildId ) . toBeUndefined ( )
191199 } )
192200
193201 it ( "does not touch active or completed tasks" , async ( ) => {
@@ -266,4 +274,119 @@ describe("TaskHistoryStore reconcileDelegationState", () => {
266274
267275 warnSpy . mockRestore ( )
268276 } )
277+
278+ it ( "invokes onWrite callback after startup repairs" , async ( ) => {
279+ const onWrite = vi . fn ( ) . mockResolvedValue ( undefined )
280+ store . dispose ( )
281+ store = new TaskHistoryStore ( tmpDir , { onWrite } )
282+
283+ const parent = makeItem ( { id : "parent-onwrite" , status : "delegated" , awaitingChildId : "nonexistent-child" } )
284+ await seedItems ( [ parent ] )
285+
286+ await store . initialize ( )
287+
288+ // The startup repair writes the repaired item, which must trigger onWrite
289+ expect ( onWrite ) . toHaveBeenCalled ( )
290+ // The final state passed to onWrite must contain the repaired item
291+ const lastCall = onWrite . mock . calls [ onWrite . mock . calls . length - 1 ] [ 0 ] as HistoryItem [ ]
292+ const repaired = lastCall . find ( ( i ) => i . id === "parent-onwrite" )
293+ expect ( repaired ?. status ) . toBe ( "active" )
294+ } )
295+ } )
296+
297+ // ─────────────────────────────────────────────────────────────────────────────
298+ // upsert — transition guard enforcement at the write boundary
299+ // ─────────────────────────────────────────────────────────────────────────────
300+
301+ describe ( "TaskHistoryStore upsert transition guard" , ( ) => {
302+ let tmpDir : string
303+ let store : TaskHistoryStore
304+
305+ async function seedItems ( items : HistoryItem [ ] ) : Promise < void > {
306+ const tasksDir = path . join ( tmpDir , "tasks" )
307+ await fs . mkdir ( tasksDir , { recursive : true } )
308+ for ( const item of items ) {
309+ const taskDir = path . join ( tasksDir , item . id )
310+ await fs . mkdir ( taskDir , { recursive : true } )
311+ await fs . writeFile ( path . join ( taskDir , "history_item.json" ) , JSON . stringify ( item ) )
312+ }
313+ }
314+
315+ beforeEach ( async ( ) => {
316+ tmpDir = await fs . mkdtemp ( path . join ( os . tmpdir ( ) , "upsert-guard-test-" ) )
317+ store = new TaskHistoryStore ( tmpDir )
318+ await store . initialize ( )
319+ } )
320+
321+ afterEach ( async ( ) => {
322+ store . dispose ( )
323+ await fs . rm ( tmpDir , { recursive : true , force : true } ) . catch ( ( ) => { } )
324+ } )
325+
326+ it ( "rejects completed → active transition, preserving the completed status" , async ( ) => {
327+ const item = makeItem ( { id : "task-guard-1" , status : "completed" } )
328+ await seedItems ( [ item ] )
329+ store . dispose ( )
330+ store = new TaskHistoryStore ( tmpDir )
331+ await store . initialize ( )
332+
333+ // Fire-and-forget late save: tries to write status: "active" over "completed"
334+ await expect ( store . upsert ( { ...item , status : "active" } ) ) . rejects . toThrow (
335+ "Invalid task status transition: completed → active" ,
336+ )
337+
338+ // The completed status must be preserved in the cache
339+ expect ( store . get ( "task-guard-1" ) ?. status ) . toBe ( "completed" )
340+ } )
341+
342+ it ( "rejects delegated → completed transition" , async ( ) => {
343+ // Must include a live active child so reconciliation doesn't repair the parent to active
344+ const child = makeItem ( { id : "child-guard-2" , status : "active" } )
345+ const item = makeItem ( { id : "task-guard-2" , status : "delegated" , awaitingChildId : "child-guard-2" } )
346+ await seedItems ( [ child , item ] )
347+ store . dispose ( )
348+ store = new TaskHistoryStore ( tmpDir )
349+ await store . initialize ( )
350+
351+ // Confirm reconciliation left the delegated status alone
352+ expect ( store . get ( "task-guard-2" ) ?. status ) . toBe ( "delegated" )
353+
354+ await expect ( store . upsert ( { ...item , status : "completed" } ) ) . rejects . toThrow (
355+ "Invalid task status transition: delegated → completed" ,
356+ )
357+
358+ expect ( store . get ( "task-guard-2" ) ?. status ) . toBe ( "delegated" )
359+ } )
360+
361+ it ( "allows valid active → completed transition" , async ( ) => {
362+ const item = makeItem ( { id : "task-guard-3" , status : "active" } )
363+ await seedItems ( [ item ] )
364+ store . dispose ( )
365+ store = new TaskHistoryStore ( tmpDir )
366+ await store . initialize ( )
367+
368+ await expect ( store . upsert ( { ...item , status : "completed" } ) ) . resolves . toBeDefined ( )
369+ expect ( store . get ( "task-guard-3" ) ?. status ) . toBe ( "completed" )
370+ } )
371+
372+ it ( "allows first insert with status: active (no prior record to transition from)" , async ( ) => {
373+ const item = makeItem ( { id : "task-guard-new" , status : "active" } )
374+ // Do NOT seed — this is the very first write for this task
375+ await expect ( store . upsert ( item ) ) . resolves . toBeDefined ( )
376+ expect ( store . get ( "task-guard-new" ) ?. status ) . toBe ( "active" )
377+ } )
378+
379+ it ( "allows upsert without a status field (no-op on status)" , async ( ) => {
380+ const item = makeItem ( { id : "task-guard-4" , status : "completed" } )
381+ await seedItems ( [ item ] )
382+ store . dispose ( )
383+ store = new TaskHistoryStore ( tmpDir )
384+ await store . initialize ( )
385+
386+ // Omitting status entirely — no transition should be validated
387+ const { status : _omit , ...noStatus } = item
388+ await expect ( store . upsert ( noStatus as HistoryItem ) ) . resolves . toBeDefined ( )
389+ // Status is preserved from the existing cache entry
390+ expect ( store . get ( "task-guard-4" ) ?. status ) . toBe ( "completed" )
391+ } )
269392} )
0 commit comments