@@ -13,6 +13,17 @@ import { getStorageBasePath } from "../../utils/storage"
1313/** Valid status values for a task's HistoryItem. */
1414export type HistoryItemStatus = NonNullable < HistoryItem [ "status" ] >
1515
16+ export class DeltaRejectedError extends Error {
17+ constructor (
18+ public readonly taskId : string ,
19+ public readonly diskStatus : HistoryItemStatus ,
20+ public readonly attemptedStatus : HistoryItemStatus ,
21+ ) {
22+ super ( `Delta rejected for task ${ taskId } : disk status ${ diskStatus } rejects transition to ${ attemptedStatus } ` )
23+ this . name = "DeltaRejectedError"
24+ }
25+ }
26+
1627const VALID_TRANSITIONS : Record < HistoryItemStatus , HistoryItemStatus [ ] > = {
1728 active : [ "delegated" , "completed" , "interrupted" ] ,
1829 delegated : [ "active" ] ,
@@ -48,10 +59,7 @@ function mergeWithDisk(delta: Partial<HistoryItem>): (existing: unknown, incomin
4859 if ( delta . status !== diskStatus ) {
4960 const validTargets = VALID_TRANSITIONS [ diskStatus ]
5061 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
62+ throw new DeltaRejectedError ( disk . id , diskStatus , delta . status as HistoryItemStatus )
5563 }
5664 }
5765 }
@@ -258,20 +266,35 @@ export class TaskHistoryStore {
258266 if ( item . status !== normalizedExisting ) {
259267 try {
260268 assertValidTransition ( existing . status , item . status )
261- } catch {
269+ } catch ( cacheError ) {
262270 // Cache may be stale from a peer write. Re-read disk
263271 // under the store lock before rejecting the transition.
264272 const diskItem = await this . readTaskFile ( item . id )
265- assertValidTransition ( diskItem ?. status , item . status )
273+ if ( ! diskItem ) {
274+ throw cacheError
275+ }
276+ assertValidTransition ( diskItem . status , item . status )
266277 }
267278 }
268279 }
269280
270281 // Merge: preserve existing metadata unless explicitly overwritten
271282 const merged = existing ? { ...existing , ...item } : item
272283
273- const delta = existing ? this . buildDelta ( item . id , existing , item ) : undefined
274- const written = await this . writeTaskFile ( merged , delta )
284+ const delta = existing ? this . buildDelta ( item . id , existing , item ) : { ...item }
285+ let written : HistoryItem
286+ try {
287+ written = await this . writeTaskFile ( merged , delta )
288+ } catch ( error ) {
289+ if ( error instanceof DeltaRejectedError ) {
290+ const diskItem = await this . readTaskFile ( item . id )
291+ if ( diskItem ) {
292+ this . cache . set ( item . id , diskItem )
293+ }
294+ throw error
295+ }
296+ throw error
297+ }
275298
276299 // Update in-memory cache with what was actually persisted
277300 this . cache . set ( written . id , written )
@@ -388,8 +411,10 @@ export class TaskHistoryStore {
388411 // write is in progress — keep the task live.
389412 try {
390413 const lockPath = ( await this . getTaskFilePath ( taskId ) ) + ".lock"
391- await fs . access ( lockPath )
392- liveIds . add ( taskId )
414+ const lockStat = await fs . stat ( lockPath )
415+ if ( Date . now ( ) - lockStat . mtimeMs < 31_000 ) {
416+ liveIds . add ( taskId )
417+ }
393418 } catch {
394419 // No lock file — file is genuinely absent
395420 }
@@ -1063,12 +1088,17 @@ export class TaskHistoryStore {
10631088 const mergedSecond = { ...second , ...updatedSecond }
10641089
10651090 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- )
1091+ let writtenSecond : HistoryItem
1092+ try {
1093+ writtenSecond = await this . writeTaskFile ( mergedSecond , this . buildDelta ( secondId , second , updatedSecond ) )
1094+ } catch ( error ) {
1095+ // First record is committed on disk. Update cache so it
1096+ // reflects disk state before propagating the error.
1097+ this . cache . set ( firstId , writtenFirst )
1098+ throw error
1099+ }
10701100
1071- // Both disk writes succeeded — now update the cache atomically .
1101+ // Both disk writes succeeded — now update the cache.
10721102 this . cache . set ( firstId , writtenFirst )
10731103 this . cache . set ( secondId , writtenSecond )
10741104
0 commit comments