Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
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
9 changes: 9 additions & 0 deletions src/core/task/Task.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1130,6 +1130,15 @@ export class Task extends EventEmitter<TaskEvents> implements TaskLike {
// - Final state is emitted when updates stop (trailing: true)
this.debouncedEmitTokenUsage(tokenUsage, this.toolUsage)

// Guard: don't update the history item for abandoned tasks. Fire-and-forget
// saveClineMessages() calls can reach updateTaskHistory() after
// abandonSubtask's atomicUpdatePair() has already cleared
// parentTaskId/rootTaskId; writing this live Task's stale values would
// silently reattach the severed parent-child link.
if (this.abandoned) {
return false
}

const provider = this.providerRef.deref()
const existingStatus = provider?.taskHistoryStore.get(this.taskId)?.status
await provider?.updateTaskHistory(existingStatus ? { ...historyItem, status: existingStatus } : historyItem)
Expand Down
74 changes: 74 additions & 0 deletions src/core/task/__tests__/Task.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import {
type GlobalState,
type ProviderSettings,
type ModelInfo,
type HistoryItem,
type TaskLike,
} from "@roo-code/types"
import { TelemetryService } from "@roo-code/telemetry"
Expand All @@ -27,6 +28,9 @@ import { ContextProxy } from "../../config/ContextProxy"
import { processUserContentMentions } from "../../mentions/processUserContentMentions"
import { MultiSearchReplaceDiffStrategy } from "../../diff/strategies/multi-search-replace"
import type { ApiMessage } from "../../task-persistence"
import * as taskMetadataModule from "../../task-persistence/taskMetadata"
import * as taskMessagesModule from "../../task-persistence/taskMessages"
import { getApiMetrics } from "../../../shared/getApiMetrics"

type TaskTestAccess = {
getSystemPrompt: () => Promise<string>
Expand Down Expand Up @@ -4297,3 +4301,73 @@ describe("pushToolResultToUserContent", () => {
expect(task.userMessageContent[2]).toEqual(toolResult)
})
})

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

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

try {
const mockProvider = {
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),
} as unknown as MockedClineProvider

const task = new Task({
provider: mockProvider,
apiConfiguration: { apiProvider: providerIdentifiers.anthropic, apiKey: "test-key" },
task: "orphan subtask",
startTask: false,
})

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

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

expect(saved).toBe(false)
expect(saveSpy).toHaveBeenCalledTimes(1) // messages are still persisted
expect(mockProvider.updateTaskHistory).not.toHaveBeenCalled() // history link is not reattached
} finally {
saveSpy.mockRestore()
metaSpy.mockRestore()
}
})
})
Loading