Skip to content

Commit c45f039

Browse files
committed
fix(task-persistence): enforce status transition guard at upsertCore write boundary
1 parent 433cb1c commit c45f039

2 files changed

Lines changed: 173 additions & 24 deletions

File tree

src/core/task-persistence/TaskHistoryStore.ts

Lines changed: 48 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@ import { safeWriteJson } from "../../utils/safeWriteJson"
99
import { getStorageBasePath } from "../../utils/storage"
1010

1111
/** Valid status values for a task's HistoryItem. */
12-
export type HistoryItemStatus = "active" | "delegated" | "completed"
12+
export type HistoryItemStatus = NonNullable<HistoryItem["status"]>
1313

1414
const VALID_TRANSITIONS: Record<HistoryItemStatus, HistoryItemStatus[]> = {
1515
active: ["delegated", "completed"],
@@ -188,10 +188,24 @@ export class TaskHistoryStore {
188188

189189
/**
190190
* Core upsert logic — must only be called from within `withLock`.
191+
*
192+
* Enforces state-machine transition rules when `item.status` changes.
193+
* Pass `skipTransitionCheck: true` only for administrative repairs (reconciliation,
194+
* migration) that need to write corrected state outside the normal task lifecycle.
191195
*/
192-
private async upsertCore(item: HistoryItem): Promise<HistoryItem[]> {
196+
private async upsertCore(
197+
item: HistoryItem,
198+
options: { skipTransitionCheck?: boolean } = {},
199+
): Promise<HistoryItem[]> {
193200
const existing = this.cache.get(item.id)
194201

202+
// Enforce transition validity at the write boundary so that any caller
203+
// (including fire-and-forget saves) cannot silently stomp a terminal status.
204+
// Skip when there is no existing record — first insert has no prior state to transition from.
205+
if (!options.skipTransitionCheck && existing && item.status !== undefined && item.status !== existing.status) {
206+
assertValidTransition(existing.status, item.status)
207+
}
208+
195209
// Merge: preserve existing metadata unless explicitly overwritten
196210
const merged = existing ? { ...existing, ...item } : item
197211

@@ -324,10 +338,13 @@ export class TaskHistoryStore {
324338
*
325339
* Called once from `initialize()` after `reconcile()`. Runs inside `withLock` to
326340
* prevent interleaving with watcher-triggered reconcile() calls. Iterates until
327-
* convergence so that chained delegations (A→B→C) are fully resolved in one startup.
341+
* convergence so that one-level chained delegations visible at startup are resolved.
328342
*
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.
343+
* Must NOT be called from within `withLock` — `withLock` is non-reentrant (promise
344+
* chain); calling `upsert` (which acquires the lock) from inside would deadlock.
345+
* `upsertCore` is called directly here instead, bypassing transition validation via
346+
* `skipTransitionCheck: true` because these writes are administrative repairs, not
347+
* runtime state-machine transitions.
331348
*
332349
* Cases repaired per pass:
333350
* - Parent `delegated` with no `awaitingChildId` → parent → `active` (invalid state)
@@ -343,15 +360,18 @@ export class TaskHistoryStore {
343360
repairsInThisPass = 0
344361
// Rebuild the lookup map each pass so repairs from the previous pass
345362
// are visible when evaluating chained delegations.
346-
const byId = new Map(this.getAll().map((i) => [i.id, i]))
363+
const byId = new Map(Array.from(this.cache.values()).map((i) => [i.id, i]))
347364

348365
for (const [, item] of byId) {
349366
if (item.status !== "delegated") {
350367
continue
351368
}
352369

353370
if (!item.awaitingChildId) {
354-
await this.upsertCore({ ...item, status: "active", delegatedToId: undefined })
371+
await this.upsertCore(
372+
{ ...item, status: "active", awaitingChildId: undefined, delegatedToId: undefined },
373+
{ skipTransitionCheck: true },
374+
)
355375
console.warn(
356376
`[TaskHistoryStore] Reconciled invalid delegation: task ${item.id} → active (no awaitingChildId)`,
357377
)
@@ -362,26 +382,32 @@ export class TaskHistoryStore {
362382
const child = byId.get(item.awaitingChildId)
363383

364384
if (!child) {
365-
await this.upsertCore({
366-
...item,
367-
status: "active",
368-
awaitingChildId: undefined,
369-
delegatedToId: undefined,
370-
})
385+
await this.upsertCore(
386+
{
387+
...item,
388+
status: "active",
389+
awaitingChildId: undefined,
390+
delegatedToId: undefined,
391+
},
392+
{ skipTransitionCheck: true },
393+
)
371394
console.warn(
372395
`[TaskHistoryStore] Reconciled orphaned delegation: task ${item.id} → active (child ${item.awaitingChildId} not found)`,
373396
)
374397
repairsInThisPass++
375398
} 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-
})
399+
await this.upsertCore(
400+
{
401+
...item,
402+
status: "active",
403+
awaitingChildId: undefined,
404+
delegatedToId: undefined,
405+
completedByChildId: child.id,
406+
completionResultSummary:
407+
child.completionResultSummary ?? "Task completed (recovered after interruption)",
408+
},
409+
{ skipTransitionCheck: true },
410+
)
385411
console.warn(
386412
`[TaskHistoryStore] Reconciled interrupted handoff: task ${item.id} → active (child ${item.awaitingChildId} already completed)`,
387413
)

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

Lines changed: 125 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -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

Comments
 (0)