@@ -8,6 +8,28 @@ import { GlobalFileNames } from "../../shared/globalFileNames"
88import { safeWriteJson } from "../../utils/safeWriteJson"
99import { getStorageBasePath } from "../../utils/storage"
1010
11+ /** Valid status values for a task's HistoryItem. */
12+ export type HistoryItemStatus = "active" | "delegated" | "completed"
13+
14+ const VALID_TRANSITIONS : Record < HistoryItemStatus , HistoryItemStatus [ ] > = {
15+ active : [ "delegated" , "completed" ] ,
16+ delegated : [ "active" ] ,
17+ completed : [ ] ,
18+ }
19+
20+ /**
21+ * Asserts that a task status transition is valid, throwing if not.
22+ *
23+ * @throws {Error } When the transition is not allowed by the state machine.
24+ */
25+ export function assertValidTransition ( from : HistoryItemStatus | undefined , to : HistoryItemStatus ) : void {
26+ const fromStatus : HistoryItemStatus = from ?? "active"
27+ const validTargets = VALID_TRANSITIONS [ fromStatus ]
28+ if ( ! validTargets . includes ( to ) ) {
29+ throw new Error ( `Invalid task status transition: ${ fromStatus } → ${ to } ` )
30+ }
31+ }
32+
1133/**
1234 * Index file format for fast startup reads.
1335 */
@@ -88,10 +110,13 @@ export class TaskHistoryStore {
88110 // 2. Reconcile cache against actual task directories on disk
89111 await this . reconcile ( )
90112
91- // 3. Start fs.watch for cross-instance reactivity
113+ // 3. Repair delegation inconsistencies left by a previous crash
114+ await this . reconcileDelegationState ( )
115+
116+ // 4. Start fs.watch for cross-instance reactivity
92117 this . startWatcher ( )
93118
94- // 4 . Start periodic reconciliation as a defensive fallback
119+ // 5 . Start periodic reconciliation as a defensive fallback
95120 this . startPeriodicReconciliation ( )
96121 } finally {
97122 // Mark initialization as complete so callers awaiting `initialized` can proceed
@@ -158,14 +183,13 @@ export class TaskHistoryStore {
158183 * updates the in-memory Map, and schedules a debounced index write.
159184 */
160185 async upsert ( item : HistoryItem ) : Promise < HistoryItem [ ] > {
161- return this . withLock ( ( ) => this . _upsertUnlocked ( item ) )
186+ return this . withLock ( ( ) => this . upsertCore ( item ) )
162187 }
163188
164189 /**
165- * Upsert body executed without acquiring the lock.
166- * Must only be called from within a `withLock` callback.
190+ * Core upsert logic — must only be called from within `withLock`.
167191 */
168- private async _upsertUnlocked ( item : HistoryItem ) : Promise < HistoryItem [ ] > {
192+ private async upsertCore ( item : HistoryItem ) : Promise < HistoryItem [ ] > {
169193 const existing = this . cache . get ( item . id )
170194
171195 // Merge: preserve existing metadata unless explicitly overwritten
@@ -295,6 +319,80 @@ export class TaskHistoryStore {
295319 } )
296320 }
297321
322+ /**
323+ * Repair delegation inconsistencies left by a crash mid-transition.
324+ *
325+ * Called once from `initialize()` after `reconcile()`. Runs inside `withLock` to
326+ * prevent interleaving with watcher-triggered reconcile() calls. Iterates until
327+ * convergence so that chained delegations (A→B→C) are fully resolved in one startup.
328+ *
329+ * Must NOT be called from within `withLock` — each upsertCore call holds the lock
330+ * for the duration of a single write, and the outer lock wraps the whole pass.
331+ *
332+ * Cases repaired per pass:
333+ * - Parent `delegated` with no `awaitingChildId` → parent → `active` (invalid state)
334+ * - Parent `delegated`, child not found → parent → `active` (orphaned delegation)
335+ * - Parent `delegated`, child `completed` → parent → `active` (interrupted handoff)
336+ *
337+ * A parent awaiting an `active` child is left as-is — the child is resumable.
338+ */
339+ private async reconcileDelegationState ( ) : Promise < void > {
340+ return this . withLock ( async ( ) => {
341+ let repairsInThisPass : number
342+ do {
343+ repairsInThisPass = 0
344+ // Rebuild the lookup map each pass so repairs from the previous pass
345+ // are visible when evaluating chained delegations.
346+ const byId = new Map ( this . getAll ( ) . map ( ( i ) => [ i . id , i ] ) )
347+
348+ for ( const [ , item ] of byId ) {
349+ if ( item . status !== "delegated" ) {
350+ continue
351+ }
352+
353+ if ( ! item . awaitingChildId ) {
354+ await this . upsertCore ( { ...item , status : "active" , delegatedToId : undefined } )
355+ console . warn (
356+ `[TaskHistoryStore] Reconciled invalid delegation: task ${ item . id } → active (no awaitingChildId)` ,
357+ )
358+ repairsInThisPass ++
359+ continue
360+ }
361+
362+ const child = byId . get ( item . awaitingChildId )
363+
364+ if ( ! child ) {
365+ await this . upsertCore ( {
366+ ...item ,
367+ status : "active" ,
368+ awaitingChildId : undefined ,
369+ delegatedToId : undefined ,
370+ } )
371+ console . warn (
372+ `[TaskHistoryStore] Reconciled orphaned delegation: task ${ item . id } → active (child ${ item . awaitingChildId } not found)` ,
373+ )
374+ repairsInThisPass ++
375+ } else if ( child . status === "completed" ) {
376+ await this . upsertCore ( {
377+ ...item ,
378+ status : "active" ,
379+ awaitingChildId : undefined ,
380+ delegatedToId : undefined ,
381+ completedByChildId : child . id ,
382+ completionResultSummary :
383+ child . completionResultSummary ?? "Task completed (recovered after interruption)" ,
384+ } )
385+ console . warn (
386+ `[TaskHistoryStore] Reconciled interrupted handoff: task ${ item . id } → active (child ${ item . awaitingChildId } already completed)` ,
387+ )
388+ repairsInThisPass ++
389+ }
390+ // child.status === "active" or "delegated" → leave as-is this pass
391+ }
392+ } while ( repairsInThisPass > 0 )
393+ } )
394+ }
395+
298396 // ────────────────────────────── Cache invalidation ──────────────────────────────
299397
300398 /**
@@ -561,7 +659,7 @@ export class TaskHistoryStore {
561659 `[TaskHistoryStore] atomicReadAndUpdate: updater changed task id from ${ taskId } to ${ updated . id } ` ,
562660 )
563661 }
564- return this . _upsertUnlocked ( updated )
662+ return this . upsertCore ( updated )
565663 } )
566664 }
567665
0 commit comments