Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
352df16
fix(task): guard saveClineMessages against abandoned tasks (fixes #1021)
easonliang28 Aug 27, 2026
ce34d48
test(task): document double assertion in abandoned-guard test
easonliang28 Aug 27, 2026
b67e573
chore(ci): empty commit — re-trigger CI and the CodeRabbit current-he…
easonliang28 Aug 30, 2026
8cce67a
test(task): reach the history-update path in the abandoned-guard tests
easonliang28 Sep 2, 2026
ea65469
chore(ci): sync .coderabbit.yaml from upstream main
easonliang28 Sep 2, 2026
d48f46d
Merge remote-tracking branch 'upstream/main' into fix/abandoned-subta…
easonliang28 Sep 4, 2026
561417d
Merge upstream/main into fix/abandoned-subtask-save-race-1021
easonliang28 Sep 27, 2026
a3db831
test: assert the persisted message payload in abandoned-save tests
easonliang28 Sep 27, 2026
a1c7101
test: build the abandoned-save provider double with a single structur…
easonliang28 Sep 27, 2026
4145fe9
Merge remote-tracking branch 'upstream/main' into fix/abandoned-subta…
Oct 2, 2026
94b2e12
Merge commit 'refs/shapes/upstream-main' into fix/abandoned-subtask-s…
Oct 2, 2026
76e06d3
Merge branch 'main' into fix/abandoned-subtask-save-race-1021
easonLiangWorldedtech Oct 5, 2026
99ab6cc
Merge upstream main into fix/abandoned-subtask-save-race-1021
easonliang28 Oct 13, 2026
10300ce
Return success from the abandoned-save guard: the write did succeed
easonliang28 Oct 13, 2026
4671c9a
chore(task): drop Stryker suppression comments per maintainer directive
easonliang28 Oct 14, 2026
7c3beb1
Merge remote-tracking branch 'upstream/main' into fix/abandoned-subta…
easonLiangWorldedtech Oct 22, 2026
d5e5a54
style: apply repo Prettier to the merge resolution
easonLiangWorldedtech Oct 22, 2026
34dce6e
chore: keep the out-of-scope Stryker directive removals out of this fix
easonLiangWorldedtech Oct 22, 2026
737fdd2
test(task): drive the abandoned-save tests through requestAbort
easonLiangWorldedtech Oct 22, 2026
757015b
fix(task): refresh an existing history row without reattaching the ab…
easonLiangWorldedtech Oct 27, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 26 additions & 0 deletions src/core/task/Task.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1812,6 +1812,32 @@
this.debouncedEmitTokenUsage(tokenUsage, this.toolUsage)

const provider = this.providerRef.deref()

// Fire-and-forget saveClineMessages() calls can reach this point after
// abandonSubtask's atomicUpdatePair() cleared parentTaskId/rootTaskId. Writing

Check warning on line 1817 in src/core/task/Task.ts

View workflow job for this annotation

GitHub Actions / mutation-diff

Mutation test advisory

src/core/task/Task.ts:1817: Survived OptionalChaining mutant (replacement: provider.taskHistoryStore). See the job summary for the complete list and resolution guidance.
// this live Task's stale link values would silently reattach the severed

Check warning on line 1818 in src/core/task/Task.ts

View workflow job for this annotation

GitHub Actions / mutation-diff

Mutation test advisory

src/core/task/Task.ts:1818: Survived ConditionalExpression mutant (replacement: false). See the job summary for the complete list and resolution guidance.
// parent-child link. Keep the upsert (so a plain cancel still records its
// metadata) but omit the link keys when the store already has this task's row:
// upsertCore merges over the existing record, so omitted keys keep the
// severed/null values while tokens, cost, ts and title are refreshed.
if (this.abandoned) {
Comment thread
easonLiangWorldedtech marked this conversation as resolved.
const existing = provider?.taskHistoryStore.get(this.taskId)
if (!existing) {
// The message write succeeded, and the boolean reflects the message write
// only (see method docs): returning false here would make callers retry an
// already-persisted save (persistQueuedFeedbackAndAcknowledge) or skip a

Check warning on line 1828 in src/core/task/Task.ts

View workflow job for this annotation

GitHub Actions / mutation-diff

Mutation test advisory

src/core/task/Task.ts:1828: Survived OptionalChaining mutant (replacement: provider.updateTaskHistory). See the job summary for the complete list and resolution guidance.
// durable-state-gated update whose on-disk record is in fact current
// (finalizePartialToolAsk). No stored row means there is no link to
// reattach, and creating one here would write a task the store never had.
return true
}
const { parentTaskId: _parentTaskId, rootTaskId: _rootTaskId, ...detachedHistoryItem } = historyItem
await provider?.updateTaskHistory(
existing.status ? { ...detachedHistoryItem, status: existing.status } : detachedHistoryItem,
)
return true
}

const existingStatus = provider?.taskHistoryStore.get(this.taskId)?.status
await provider?.updateTaskHistory(existingStatus ? { ...historyItem, status: existingStatus } : historyItem)
} catch (error) {
Expand Down
224 changes: 224 additions & 0 deletions src/core/task/__tests__/Task.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,9 @@ import { processUserContentMentions } from "../../mentions/processUserContentMen
import { MultiSearchReplaceDiffStrategy } from "../../diff/strategies/multi-search-replace"
import type { ApiMessage } from "../../task-persistence"
import type { RequestPolicySnapshot } from "../../prompts/tools/effective-tool-policy"
import * as taskMetadataModule from "../../task-persistence/taskMetadata"
import * as taskMessagesModule from "../../task-persistence/taskMessages"
import { getApiMetrics } from "../../../shared/getApiMetrics"
import { asyncStreamFrom } from "../../../test-utils/stream"
import { McpHub } from "../../../services/mcp/McpHub"
import { McpServerManager } from "../../../services/mcp/McpServerManager"
Expand Down Expand Up @@ -8252,3 +8255,224 @@ describe("pushToolResultToUserContent", () => {
expect(task.userMessageContent[2]).toEqual(toolResult)
})
})

describe("saveClineMessages abandoned guard (#1021)", () => {
beforeEach(() => {
if (!TelemetryService.hasInstance()) {
TelemetryService.createInstance([])
}
})

// The history item carries the stale link: a fire-and-forget save that
// reaches updateTaskHistory() after abandonSubtask's atomicUpdatePair()
// cleared parentTaskId/rootTaskId would silently reattach the severed
// parent-child link.
const staleHistoryItem: HistoryItem = {
id: "orphan-subtask",
number: 7,
ts: Date.now(),
task: "orphan subtask",
tokensIn: 0,
tokensOut: 0,
totalCost: 0,
rootTaskId: "stale-root",
parentTaskId: "stale-parent",
}

// Task receives a full ClineProvider at runtime; these focused unit tests only
// exercise these methods, so the partial double is cast. taskHistoryStore must
// be stubbed: without it provider?.taskHistoryStore.get() throws before the
// guard is evaluated and the catch would mask whether execution reached
// updateTaskHistory().
//
// The double does not structurally overlap MockedClineProvider, so a direct
// literal `as` fails (TS2352). Object.assign over an empty-object single cast
// keeps one assertion: the target carries the declared type and the source's
// property types stay inferred.
function makeMockProvider() {
const mockProvider = Object.assign({} as MockedClineProvider, {
context: {
globalStorageUri: { fsPath: "/test/storage" },
globalState: {
get: vi.fn().mockImplementation(() => undefined),
update: vi.fn().mockResolvedValue(undefined),
keys: vi.fn().mockReturnValue([]),
},
},
getState: vi.fn().mockResolvedValue({
apiConfiguration: { apiProvider: providerIdentifiers.anthropic, apiKey: "test-key" },
mcpEnabled: false,
}),
getMcpHub: vi.fn().mockReturnValue(undefined),
postMessageToWebview: vi.fn().mockResolvedValue(undefined),
updateTaskHistory: vi.fn().mockResolvedValue(undefined),
taskHistoryStore: { get: vi.fn(() => undefined) },
})
return mockProvider
}

function createTask(provider: MockedClineProvider) {
return new Task({
provider,
apiConfiguration: { apiProvider: providerIdentifiers.anthropic, apiKey: "test-key" },
task: "orphan subtask",
startTask: false,
})
}

it("persists messages but does not update task history when the task was abandoned", async () => {
const saveSpy = vi.spyOn(taskMessagesModule, "saveTaskMessages").mockResolvedValue([])
const metaSpy = vi
.spyOn(taskMetadataModule, "taskMetadata")
.mockResolvedValue({ historyItem: staleHistoryItem, tokenUsage: getApiMetrics([]) })

try {
const mockProvider = makeMockProvider()
const task = createTask(mockProvider)

// Seed a message so the payload assertion is meaningful: clineMessages
// starts empty, and an empty-payload save would pass a call-count-only
// assertion even if the save dropped the messages field.
task.clineMessages = [{ ts: 1, type: "say", say: "text", text: "seeded message" }]

// abandonSubtask severs the link, then aborts the subtask with
// isAbandoned=true; an in-flight fire-and-forget save lands here.
task.requestAbort(true)

const saved = await getTaskTestAccess(task).saveClineMessages()

Comment thread
easonLiangWorldedtech marked this conversation as resolved.
// The boolean reflects the message write only: the write succeeded, so the
// save reports success even though the history update is skipped.
expect(saved).toBe(true)
expect(saveSpy).toHaveBeenCalledTimes(1) // messages are still persisted
Comment thread
coderabbitai[bot] marked this conversation as resolved.
expect(saveSpy).toHaveBeenCalledWith(
expect.objectContaining({
taskId: task.taskId,
messages: expect.arrayContaining([expect.objectContaining({ text: "seeded message" })]),
}),
)
expect(mockProvider.updateTaskHistory).not.toHaveBeenCalled() // history link is not reattached
} finally {
saveSpy.mockRestore()
metaSpy.mockRestore()
}
})

it("refreshes an existing history row without reattaching the severed link when abandoned", async () => {
const saveSpy = vi.spyOn(taskMessagesModule, "saveTaskMessages").mockResolvedValue([])
const metaSpy = vi.spyOn(taskMetadataModule, "taskMetadata").mockResolvedValue({
historyItem: { ...staleHistoryItem, tokensIn: 11, tokensOut: 7, totalCost: 0.02 },
tokenUsage: getApiMetrics([]),
})

try {
const mockProvider = makeMockProvider()
// The row already exists with the severed link; a plain cancel's final save
// must refresh metadata but keep the link severed (upsertCore merges, so the
// link keys must be omitted from the update).
;(mockProvider.taskHistoryStore.get as ReturnType<typeof vi.fn>).mockReturnValue({
id: "orphan-subtask",
status: "completed",
})
const task = createTask(mockProvider)
task.clineMessages = [{ ts: 1, type: "say", say: "text", text: "seeded message" }]
task.requestAbort(true)

const saved = await getTaskTestAccess(task).saveClineMessages()

expect(saved).toBe(true)
expect(mockProvider.updateTaskHistory).toHaveBeenCalledTimes(1)
const update = (mockProvider.updateTaskHistory as ReturnType<typeof vi.fn>).mock.calls[0][0]
// Metadata is refreshed...
expect(update).toEqual(
expect.objectContaining({
id: "orphan-subtask",
tokensIn: 11,
tokensOut: 7,
totalCost: 0.02,
status: "completed",
}),
)
// ...but the update carries no link keys: the merge must leave the stored
// severed link untouched.
expect(update).not.toHaveProperty("parentTaskId")
expect(update).not.toHaveProperty("rootTaskId")
} finally {
saveSpy.mockRestore()
metaSpy.mockRestore()
}
})

it("updates task history for a non-abandoned task (guard does not block the normal path)", async () => {
const saveSpy = vi.spyOn(taskMessagesModule, "saveTaskMessages").mockResolvedValue([])
const metaSpy = vi
.spyOn(taskMetadataModule, "taskMetadata")
.mockResolvedValue({ historyItem: staleHistoryItem, tokenUsage: getApiMetrics([]) })

try {
const mockProvider = makeMockProvider()
const task = createTask(mockProvider)

const saved = await getTaskTestAccess(task).saveClineMessages()

expect(saved).toBe(true)
expect(saveSpy).toHaveBeenCalledTimes(1)
// Control case: the guard must not block the normal path, so execution
// genuinely reached updateTaskHistory(). No pre-existing store entry,
// so the item is written as-is.
expect(mockProvider.updateTaskHistory).toHaveBeenCalledTimes(1)
expect(mockProvider.updateTaskHistory).toHaveBeenCalledWith(staleHistoryItem)
} finally {
saveSpy.mockRestore()
metaSpy.mockRestore()
}
})

it("skips the history update when the task is abandoned while the save is in flight", async () => {
// Fire-and-forget race: the save starts while the task is still active,
// abandonSubtask severs the link mid-save, and the guard must catch it
// when the awaited saveTaskMessages() finally resolves.
let resolveSave!: (value: import("@roo-code/types").ClineMessage[]) => void
const saveSpy = vi
.spyOn(taskMessagesModule, "saveTaskMessages")
.mockImplementation(
() => new Promise<import("@roo-code/types").ClineMessage[]>((resolve) => (resolveSave = resolve)),
)
const metaSpy = vi
.spyOn(taskMetadataModule, "taskMetadata")
.mockResolvedValue({ historyItem: staleHistoryItem, tokenUsage: getApiMetrics([]) })

try {
const mockProvider = makeMockProvider()
const task = createTask(mockProvider)

// Seed a message so the payload assertion is meaningful: clineMessages
// starts empty, and an empty-payload save would pass a call-count-only
// assertion even if the save dropped the messages field.
task.clineMessages = [{ ts: 1, type: "say", say: "text", text: "seeded message" }]

const savePromise = getTaskTestAccess(task).saveClineMessages()

// The save is in flight (awaiting saveTaskMessages) when the task is
// abandoned; only after it resumes does the save hit the guard.
task.requestAbort(true)
resolveSave([])

const saved = await savePromise

// Write-success contract: the guard skips the history update, not the save.
expect(saved).toBe(true)
expect(saveSpy).toHaveBeenCalledTimes(1) // messages are still persisted
expect(saveSpy).toHaveBeenCalledWith(
expect.objectContaining({
messages: expect.arrayContaining([expect.objectContaining({ text: "seeded message" })]),
}),
)
expect(mockProvider.updateTaskHistory).not.toHaveBeenCalled() // history link is not reattached
} finally {
resolveSave?.([]) // settle the deferred save if an assertion failed above
saveSpy.mockRestore()
metaSpy.mockRestore()
}
})
})
Loading