Skip to content

Commit 3d0aaba

Browse files
committed
fix(task-history): harden cross-process merge and reconciliation
1 parent a066d55 commit 3d0aaba

2 files changed

Lines changed: 116 additions & 29 deletions

File tree

src/core/task-persistence/TaskHistoryStore.ts

Lines changed: 75 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -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) {

src/core/task-persistence/__tests__/TaskHistoryStore.crossInstance.spec.ts

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -239,6 +239,47 @@ describe("TaskHistoryStore cross-instance safety", () => {
239239
expect(storeA.get("shared-task")!.totalCost).toBe(9.99)
240240
})
241241

242+
/**
243+
* Regression: a stale host whose cache says "active" tries to write
244+
* status: "delegated" after a peer already wrote "completed" to disk.
245+
* The merge must reject the entire delta (including companion fields)
246+
* to prevent an internally-inconsistent record.
247+
*/
248+
it("merge rejects an invalid status transition against disk and drops the entire delta", async () => {
249+
await storeA.initialize()
250+
251+
const base = makeHistoryItem({ id: "guarded-task", status: "active", totalCost: 0.01, ts: 1000 })
252+
await storeA.upsert(base)
253+
254+
// Peer writes terminal "completed" directly to disk.
255+
const filePath = path.join(tmpDir, "tasks", "guarded-task", GlobalFileNames.historyItem)
256+
const onDisk = JSON.parse(await fs.readFile(filePath, "utf8"))
257+
onDisk.status = "completed"
258+
onDisk.completionResultSummary = "done by peer"
259+
await fs.writeFile(filePath, JSON.stringify(onDisk), "utf8")
260+
261+
// Host A's cache still has "active". It tries to delegate (active → delegated
262+
// passes the cache check, but completed → delegated is invalid on disk).
263+
const staleItem = storeA.get("guarded-task")!
264+
await storeA.upsert({
265+
...staleItem,
266+
status: "delegated",
267+
awaitingChildId: "child-99",
268+
delegatedToId: "child-99",
269+
})
270+
271+
const final = JSON.parse(await fs.readFile(filePath, "utf8")) as HistoryItem
272+
// Terminal status must survive.
273+
expect(final.status).toBe("completed")
274+
expect(final.completionResultSummary).toBe("done by peer")
275+
// Companion fields from the rejected delta must NOT be applied.
276+
expect(final.awaitingChildId).toBeUndefined()
277+
expect(final.delegatedToId).toBeUndefined()
278+
279+
// Cache must reflect the disk state, not the stale delta.
280+
expect(storeA.get("guarded-task")!.status).toBe("completed")
281+
})
282+
242283
/**
243284
* When both hosts change the same field, the last writer wins.
244285
* This is expected — true conflict resolution requires application

0 commit comments

Comments
 (0)