Skip to content
This repository was archived by the owner on May 15, 2026. It is now read-only.

Commit 761bef4

Browse files
committed
fix: serialize taskHistory writes and fix delegation status overwrite race (#11335)
Add a promise-chain mutex (withTaskHistoryLock) to serialize all read-modify-write operations on taskHistory, preventing concurrent interleaving from silently dropping entries. Reorder reopenParentFromDelegation to close the child instance before marking it completed, so the abort path's stale 'active' status write no longer overwrites the 'completed' state. Covered by new tests: RPD-04/05/06, UTH-02/04, and a full mutex concurrency suite.
1 parent 87cee09 commit 761bef4

3 files changed

Lines changed: 472 additions & 42 deletions

File tree

src/__tests__/history-resume-delegation.spec.ts

Lines changed: 244 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -387,6 +387,7 @@ describe("History resume delegation - parent metadata transitions", () => {
387387

388388
it("reopenParentFromDelegation emits events in correct order: TaskDelegationCompleted → TaskDelegationResumed", async () => {
389389
const emitSpy = vi.fn()
390+
const updateTaskHistory = vi.fn().mockResolvedValue([])
390391

391392
const provider = {
392393
contextProxy: { globalStorageUri: { fsPath: "/tmp" } },
@@ -411,7 +412,7 @@ describe("History resume delegation - parent metadata transitions", () => {
411412
overwriteClineMessages: vi.fn().mockResolvedValue(undefined),
412413
overwriteApiConversationHistory: vi.fn().mockResolvedValue(undefined),
413414
}),
414-
updateTaskHistory: vi.fn().mockResolvedValue([]),
415+
updateTaskHistory,
415416
} as unknown as ClineProvider
416417

417418
vi.mocked(readTaskMessages).mockResolvedValue([])
@@ -433,6 +434,92 @@ describe("History resume delegation - parent metadata transitions", () => {
433434
const resumedIdx = emitSpy.mock.calls.findIndex((c) => c[0] === RooCodeEventName.TaskDelegationResumed)
434435
expect(completedIdx).toBeGreaterThanOrEqual(0)
435436
expect(resumedIdx).toBeGreaterThan(completedIdx)
437+
438+
// RPD-05: verify parent metadata persistence happens before TaskDelegationCompleted emit
439+
const parentUpdateCallIdx = updateTaskHistory.mock.calls.findIndex((call) => {
440+
const item = call[0] as { id?: string; status?: string } | undefined
441+
return item?.id === "p3" && item.status === "active"
442+
})
443+
expect(parentUpdateCallIdx).toBeGreaterThanOrEqual(0)
444+
445+
const parentUpdateCallOrder = updateTaskHistory.mock.invocationCallOrder[parentUpdateCallIdx]
446+
const completedEmitCallOrder = emitSpy.mock.invocationCallOrder[completedIdx]
447+
expect(parentUpdateCallOrder).toBeLessThan(completedEmitCallOrder)
448+
})
449+
450+
it("reopenParentFromDelegation continues when overwrite operations fail and still resumes/emits (RPD-06)", async () => {
451+
const emitSpy = vi.fn()
452+
const parentInstance = {
453+
resumeAfterDelegation: vi.fn().mockResolvedValue(undefined),
454+
overwriteClineMessages: vi.fn().mockRejectedValue(new Error("ui overwrite failed")),
455+
overwriteApiConversationHistory: vi.fn().mockRejectedValue(new Error("api overwrite failed")),
456+
}
457+
458+
const provider = {
459+
contextProxy: { globalStorageUri: { fsPath: "/tmp" } },
460+
getTaskWithId: vi.fn().mockImplementation(async (id: string) => {
461+
if (id === "parent-rpd06") {
462+
return {
463+
historyItem: {
464+
id: "parent-rpd06",
465+
status: "delegated",
466+
awaitingChildId: "child-rpd06",
467+
childIds: ["child-rpd06"],
468+
ts: 800,
469+
task: "Parent RPD-06",
470+
tokensIn: 0,
471+
tokensOut: 0,
472+
totalCost: 0,
473+
},
474+
}
475+
}
476+
477+
return {
478+
historyItem: {
479+
id: "child-rpd06",
480+
status: "active",
481+
ts: 801,
482+
task: "Child RPD-06",
483+
tokensIn: 0,
484+
tokensOut: 0,
485+
totalCost: 0,
486+
},
487+
}
488+
}),
489+
emit: emitSpy,
490+
getCurrentTask: vi.fn(() => ({ taskId: "child-rpd06" })),
491+
removeClineFromStack: vi.fn().mockResolvedValue(undefined),
492+
createTaskWithHistoryItem: vi.fn().mockResolvedValue(parentInstance),
493+
updateTaskHistory: vi.fn().mockResolvedValue([]),
494+
} as unknown as ClineProvider
495+
496+
vi.mocked(readTaskMessages).mockResolvedValue([])
497+
vi.mocked(readApiMessages).mockResolvedValue([])
498+
499+
await expect(
500+
(ClineProvider.prototype as any).reopenParentFromDelegation.call(provider, {
501+
parentTaskId: "parent-rpd06",
502+
childTaskId: "child-rpd06",
503+
completionResultSummary: "Subtask finished despite overwrite failures",
504+
}),
505+
).resolves.toBeUndefined()
506+
507+
expect(parentInstance.overwriteClineMessages).toHaveBeenCalledTimes(1)
508+
expect(parentInstance.overwriteApiConversationHistory).toHaveBeenCalledTimes(1)
509+
expect(parentInstance.resumeAfterDelegation).toHaveBeenCalledTimes(1)
510+
511+
expect(emitSpy).toHaveBeenCalledWith(
512+
RooCodeEventName.TaskDelegationCompleted,
513+
"parent-rpd06",
514+
"child-rpd06",
515+
"Subtask finished despite overwrite failures",
516+
)
517+
expect(emitSpy).toHaveBeenCalledWith(RooCodeEventName.TaskDelegationResumed, "parent-rpd06", "child-rpd06")
518+
519+
const completedIdx = emitSpy.mock.calls.findIndex((c) => c[0] === RooCodeEventName.TaskDelegationCompleted)
520+
const resumedIdx = emitSpy.mock.calls.findIndex((c) => c[0] === RooCodeEventName.TaskDelegationResumed)
521+
expect(completedIdx).toBeGreaterThanOrEqual(0)
522+
expect(resumedIdx).toBeGreaterThan(completedIdx)
436523
})
437524

438525
it("reopenParentFromDelegation does NOT emit TaskPaused or TaskUnpaused (new flow only)", async () => {
@@ -480,6 +567,162 @@ describe("History resume delegation - parent metadata transitions", () => {
480567
expect(eventNames).not.toContain(RooCodeEventName.TaskSpawned)
481568
})
482569

570+
it("reopenParentFromDelegation skips child close when current task differs and still reopens parent (RPD-02)", async () => {
571+
const parentInstance = {
572+
resumeAfterDelegation: vi.fn().mockResolvedValue(undefined),
573+
overwriteClineMessages: vi.fn().mockResolvedValue(undefined),
574+
overwriteApiConversationHistory: vi.fn().mockResolvedValue(undefined),
575+
}
576+
577+
const updateTaskHistory = vi.fn().mockResolvedValue([])
578+
const removeClineFromStack = vi.fn().mockResolvedValue(undefined)
579+
const createTaskWithHistoryItem = vi.fn().mockResolvedValue(parentInstance)
580+
581+
const provider = {
582+
contextProxy: { globalStorageUri: { fsPath: "/tmp" } },
583+
getTaskWithId: vi.fn().mockImplementation(async (id: string) => {
584+
if (id === "parent-rpd02") {
585+
return {
586+
historyItem: {
587+
id: "parent-rpd02",
588+
status: "delegated",
589+
awaitingChildId: "child-rpd02",
590+
childIds: ["child-rpd02"],
591+
ts: 600,
592+
task: "Parent RPD-02",
593+
tokensIn: 0,
594+
tokensOut: 0,
595+
totalCost: 0,
596+
},
597+
}
598+
}
599+
return {
600+
historyItem: {
601+
id: "child-rpd02",
602+
status: "active",
603+
ts: 601,
604+
task: "Child RPD-02",
605+
tokensIn: 0,
606+
tokensOut: 0,
607+
totalCost: 0,
608+
},
609+
}
610+
}),
611+
emit: vi.fn(),
612+
getCurrentTask: vi.fn(() => ({ taskId: "different-open-task" })),
613+
removeClineFromStack,
614+
createTaskWithHistoryItem,
615+
updateTaskHistory,
616+
} as unknown as ClineProvider
617+
618+
vi.mocked(readTaskMessages).mockResolvedValue([])
619+
vi.mocked(readApiMessages).mockResolvedValue([])
620+
621+
await (ClineProvider.prototype as any).reopenParentFromDelegation.call(provider, {
622+
parentTaskId: "parent-rpd02",
623+
childTaskId: "child-rpd02",
624+
completionResultSummary: "Child done without being current",
625+
})
626+
627+
expect(removeClineFromStack).not.toHaveBeenCalled()
628+
expect(updateTaskHistory).toHaveBeenCalledWith(
629+
expect.objectContaining({
630+
id: "child-rpd02",
631+
status: "completed",
632+
}),
633+
)
634+
expect(createTaskWithHistoryItem).toHaveBeenCalledWith(
635+
expect.objectContaining({
636+
id: "parent-rpd02",
637+
status: "active",
638+
completedByChildId: "child-rpd02",
639+
}),
640+
{ startTask: false },
641+
)
642+
expect(parentInstance.resumeAfterDelegation).toHaveBeenCalledTimes(1)
643+
})
644+
645+
it("reopenParentFromDelegation logs child status persistence failure and continues reopen flow (RPD-04)", async () => {
646+
const logSpy = vi.fn()
647+
const emitSpy = vi.fn()
648+
const parentInstance = {
649+
resumeAfterDelegation: vi.fn().mockResolvedValue(undefined),
650+
overwriteClineMessages: vi.fn().mockResolvedValue(undefined),
651+
overwriteApiConversationHistory: vi.fn().mockResolvedValue(undefined),
652+
}
653+
654+
const updateTaskHistory = vi.fn().mockImplementation(async (historyItem: { id?: string }) => {
655+
if (historyItem.id === "child-rpd04") {
656+
throw new Error("child status persist failed")
657+
}
658+
return []
659+
})
660+
661+
const provider = {
662+
contextProxy: { globalStorageUri: { fsPath: "/tmp" } },
663+
getTaskWithId: vi.fn().mockImplementation(async (id: string) => {
664+
if (id === "parent-rpd04") {
665+
return {
666+
historyItem: {
667+
id: "parent-rpd04",
668+
status: "delegated",
669+
awaitingChildId: "child-rpd04",
670+
childIds: ["child-rpd04"],
671+
ts: 700,
672+
task: "Parent RPD-04",
673+
tokensIn: 0,
674+
tokensOut: 0,
675+
totalCost: 0,
676+
},
677+
}
678+
}
679+
return {
680+
historyItem: {
681+
id: "child-rpd04",
682+
status: "active",
683+
ts: 701,
684+
task: "Child RPD-04",
685+
tokensIn: 0,
686+
tokensOut: 0,
687+
totalCost: 0,
688+
},
689+
}
690+
}),
691+
emit: emitSpy,
692+
log: logSpy,
693+
getCurrentTask: vi.fn(() => ({ taskId: "child-rpd04" })),
694+
removeClineFromStack: vi.fn().mockResolvedValue(undefined),
695+
createTaskWithHistoryItem: vi.fn().mockResolvedValue(parentInstance),
696+
updateTaskHistory,
697+
} as unknown as ClineProvider
698+
699+
vi.mocked(readTaskMessages).mockResolvedValue([])
700+
vi.mocked(readApiMessages).mockResolvedValue([])
701+
702+
await expect(
703+
(ClineProvider.prototype as any).reopenParentFromDelegation.call(provider, {
704+
parentTaskId: "parent-rpd04",
705+
childTaskId: "child-rpd04",
706+
completionResultSummary: "Child completion with persistence failure",
707+
}),
708+
).resolves.toBeUndefined()
709+
710+
expect(logSpy).toHaveBeenCalledWith(
711+
expect.stringContaining(
712+
"[reopenParentFromDelegation] Failed to persist child completed status for child-rpd04:",
713+
),
714+
)
715+
expect(updateTaskHistory).toHaveBeenCalledWith(
716+
expect.objectContaining({
717+
id: "parent-rpd04",
718+
status: "active",
719+
completedByChildId: "child-rpd04",
720+
}),
721+
)
722+
expect(parentInstance.resumeAfterDelegation).toHaveBeenCalledTimes(1)
723+
expect(emitSpy).toHaveBeenCalledWith(RooCodeEventName.TaskDelegationResumed, "parent-rpd04", "child-rpd04")
724+
})
725+
483726
it("handles empty history gracefully when injecting synthetic messages", async () => {
484727
const provider = {
485728
contextProxy: { globalStorageUri: { fsPath: "/tmp" } },

0 commit comments

Comments
 (0)