@@ -42,7 +42,24 @@ function mergeWithDisk(delta: Partial<HistoryItem>): (existing: unknown, incomin
4242 if ( ! existing || typeof existing !== "object" || ! ( "id" in existing ) ) {
4343 return incoming
4444 }
45- return { ...existing , ...delta }
45+ const disk = existing as HistoryItem
46+ if ( delta . status !== undefined ) {
47+ const diskStatus : HistoryItemStatus = disk . status ?? "active"
48+ if ( delta . status !== diskStatus ) {
49+ const validTargets = VALID_TRANSITIONS [ diskStatus ]
50+ if ( ! validTargets ?. includes ( delta . status as HistoryItemStatus ) ) {
51+ console . warn (
52+ `[TaskHistoryStore] Dropped stale delta for task ${ disk . id } : disk status ${ diskStatus } rejects transition to ${ delta . status } ` ,
53+ )
54+ return disk
55+ }
56+ }
57+ }
58+ const merged = { ...disk , ...delta }
59+ if ( delta . childIds && disk . childIds ) {
60+ merged . childIds = [ ...new Set ( [ ...disk . childIds , ...delta . childIds ] ) ]
61+ }
62+ return merged
4663 }
4764}
4865
@@ -131,7 +148,7 @@ export class TaskHistoryStore {
131148 // ────────────────────────────── Lifecycle ──────────────────────────────
132149
133150 /**
134- * Load index , reconcile if needed , start watchers.
151+ * Scan task files , reconcile delegation state , start watchers.
135152 */
136153 async initialize ( ) : Promise < void > {
137154 try {
@@ -211,8 +228,8 @@ export class TaskHistoryStore {
211228 /**
212229 * Insert or update a history item.
213230 *
214- * Writes the per-task file immediately (source of truth),
215- * updates the in-memory Map, and schedules a debounced index write .
231+ * Writes the per-task file immediately (source of truth)
232+ * and updates the in-memory cache .
216233 */
217234 async upsert ( item : HistoryItem ) : Promise < HistoryItem [ ] > {
218235 return this . withLock ( ( ) => this . upsertCore ( item ) )
@@ -239,18 +256,25 @@ export class TaskHistoryStore {
239256 if ( ! options . skipTransitionCheck && existing && item . status !== undefined ) {
240257 const normalizedExisting : HistoryItemStatus = existing . status ?? "active"
241258 if ( item . status !== normalizedExisting ) {
242- assertValidTransition ( existing . status , item . status )
259+ try {
260+ assertValidTransition ( existing . status , item . status )
261+ } catch {
262+ // Cache may be stale from a peer write. Re-read disk
263+ // under the store lock before rejecting the transition.
264+ const diskItem = await this . readTaskFile ( item . id )
265+ assertValidTransition ( diskItem ?. status , item . status )
266+ }
243267 }
244268 }
245269
246270 // Merge: preserve existing metadata unless explicitly overwritten
247271 const merged = existing ? { ...existing , ...item } : item
248272
249- const delta = existing ? ( { id : item . id , ... this . computeDelta ( existing , item ) } as HistoryItem ) : undefined
250- await this . writeTaskFile ( merged , delta )
273+ const delta = existing ? this . buildDelta ( item . id , existing , item ) : undefined
274+ const written = await this . writeTaskFile ( merged , delta )
251275
252- // Update in-memory cache
253- this . cache . set ( merged . id , merged )
276+ // Update in-memory cache with what was actually persisted
277+ this . cache . set ( written . id , written )
254278
255279 const all = this . getAll ( )
256280
@@ -312,7 +336,7 @@ export class TaskHistoryStore {
312336 // ────────────────────────────── Reconciliation ──────────────────────────────
313337
314338 /**
315- * Scan task directories vs index and fix any drift.
339+ * Scan task directories and fix any drift between disk and cache .
316340 *
317341 * - Tasks on disk but missing from cache: read and add
318342 * - Tasks in cache but missing from disk: remove
@@ -350,15 +374,25 @@ export class TaskHistoryStore {
350374 }
351375
352376 const item = await this . readTaskFile ( taskId )
353- if ( item ) {
377+ if ( item ?. id === taskId ) {
354378 const previous = this . cache . get ( taskId )
355379 this . taskFileMtimes . set ( taskId , mtimeMs )
356380 if ( ! deepEqual ( previous , item ) ) {
357381 this . cache . set ( taskId , item )
358382 }
359383 }
360384 } catch {
361- // history_item.json missing or corrupt — not live
385+ // File may be temporarily absent during a peer's atomic
386+ // rename window in safeWriteJson. The advisory lock is
387+ // held for the entire write, so its presence means a
388+ // write is in progress — keep the task live.
389+ try {
390+ const lockPath = ( await this . getTaskFilePath ( taskId ) ) + ".lock"
391+ await fs . access ( lockPath )
392+ liveIds . add ( taskId )
393+ } catch {
394+ // No lock file — file is genuinely absent
395+ }
362396 }
363397 }
364398
@@ -403,7 +437,7 @@ export class TaskHistoryStore {
403437 * Reconcile delegation state while the store lock is already held.
404438 *
405439 * Callers that do not hold the lock must use `reconcileDelegationState()`.
406- * Migration uses this core method so its cache/file/index updates and the
440+ * Migration uses this core method so its cache/file updates and the
407441 * follow-up repair remain one serialized operation without re-entering the
408442 * non-reentrant lock.
409443 */
@@ -828,6 +862,10 @@ export class TaskHistoryStore {
828862 ) as Partial < HistoryItem >
829863 }
830864
865+ private buildDelta ( id : string , cached : HistoryItem , incoming : Partial < HistoryItem > ) : Partial < HistoryItem > {
866+ return { id, ...this . computeDelta ( cached , incoming ) }
867+ }
868+
831869 /**
832870 * Write a HistoryItem to its per-task `history_item.json` file.
833871 *
@@ -836,12 +874,22 @@ export class TaskHistoryStore {
836874 * process are preserved. Without a delta the full item is written
837875 * as-is (used by administrative repair paths that are authoritative).
838876 */
839- private async writeTaskFile ( item : HistoryItem , delta ?: Partial < HistoryItem > ) : Promise < void > {
877+ private async writeTaskFile ( item : HistoryItem , delta ?: Partial < HistoryItem > ) : Promise < HistoryItem > {
840878 const filePath = await this . getTaskFilePath ( item . id )
841879 if ( delta ) {
842- await safeWriteJson ( filePath , item , { merge : mergeWithDisk ( delta ) } )
880+ let written : HistoryItem = item
881+ const mergeFn = mergeWithDisk ( delta )
882+ await safeWriteJson ( filePath , item , {
883+ merge : ( existing , incoming ) => {
884+ const result = mergeFn ( existing , incoming )
885+ written = result as HistoryItem
886+ return result
887+ } ,
888+ } )
889+ return written
843890 } else {
844891 await safeWriteJson ( filePath , item )
892+ return item
845893 }
846894 }
847895
@@ -963,10 +1011,11 @@ export class TaskHistoryStore {
9631011 }
9641012
9651013 /**
966- * Atomically update two related HistoryItems within a single lock acquisition.
967- * Both updaters run synchronously (no I/O, no lock re-entry). Both writes are
968- * committed before the lock releases — no concurrent writer can observe an
969- * intermediate state.
1014+ * Update two related HistoryItems within a single in-process lock acquisition.
1015+ * Both updaters run synchronously (no I/O, no lock re-entry). Both writes
1016+ * complete before the lock releases, so no in-process reader can observe an
1017+ * intermediate state. Cross-process atomicity is NOT guaranteed — each
1018+ * writeTaskFile call acquires and releases its own advisory file lock.
9701019 *
9711020 * @throws If either task ID is not present in the cache.
9721021 */
@@ -1013,18 +1062,15 @@ export class TaskHistoryStore {
10131062 const mergedFirst = { ...first , ...updatedFirst }
10141063 const mergedSecond = { ...second , ...updatedSecond }
10151064
1016- await this . writeTaskFile ( mergedFirst , {
1017- id : firstId ,
1018- ...this . computeDelta ( first , updatedFirst ) ,
1019- } as HistoryItem )
1020- await this . writeTaskFile ( mergedSecond , {
1021- id : secondId ,
1022- ...this . computeDelta ( second , updatedSecond ) ,
1023- } as HistoryItem )
1065+ const writtenFirst = await this . writeTaskFile ( mergedFirst , this . buildDelta ( firstId , first , updatedFirst ) )
1066+ const writtenSecond = await this . writeTaskFile (
1067+ mergedSecond ,
1068+ this . buildDelta ( secondId , second , updatedSecond ) ,
1069+ )
10241070
10251071 // Both disk writes succeeded — now update the cache atomically.
1026- this . cache . set ( firstId , mergedFirst )
1027- this . cache . set ( secondId , mergedSecond )
1072+ this . cache . set ( firstId , writtenFirst )
1073+ this . cache . set ( secondId , writtenSecond )
10281074
10291075 const all = this . getAll ( )
10301076 if ( this . onWrite ) {
0 commit comments