Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
86 commits
Select commit Hold shift + click to select a range
a0878e4
split unit U1 of PR 1833 (issue 1375)
Oct 5, 2026
404b8df
fix(file-safety): close the pre-merge findings on the publish primiti…
Oct 5, 2026
c1f7fc7
fix(file-safety): propagate a non-ENOENT lstat failure in resolvePubl…
Oct 5, 2026
644581d
fix(file-safety): keep the rollback pair typed and the mock stand-ins…
Oct 5, 2026
c0b563b
rebuild unit u2 on the fixed chain
Oct 5, 2026
6ee9aa3
chore(lint): prune the safeWriteJson suppression this unit earns
Oct 5, 2026
c03b9f4
rebuild unit u3 on the fixed chain
Oct 5, 2026
6409f44
fix(task): declare the observation registry on Task in this unit
Oct 5, 2026
da5acb3
rebuild unit u4 on the fixed chain
Oct 5, 2026
c685a51
rebuild unit u5 on the fixed chain
Oct 5, 2026
7476cc0
rebuild unit U8 on U5 (corrected merge order)
Oct 5, 2026
c8e69f2
rebuild unit u6 in the corrected order
Oct 5, 2026
646bb37
fix(apply-patch): a full hunk read is a complete observation
Oct 5, 2026
87887e1
rebuild unit u7 on the corrected u6
Oct 5, 2026
cfac653
rebuild unit U7 on the corrected U6
Oct 5, 2026
261f6fa
fix(task): keep main's abort re-check in ask
Oct 5, 2026
ebcb518
rebuild unit U9 on the corrected U7
Oct 5, 2026
a59746f
test(file-safety): check the paths the cleanup assertions actually name
Oct 5, 2026
1a31785
fix(file-safety): inherit unit 1 committed guard and unit 3 test wording
Oct 5, 2026
5365369
fix(file-safety): keep the publish error message in RollbackFailureError
Oct 5, 2026
d4d81ae
test(file-safety): assert the exact staging directory removed after a…
Oct 5, 2026
5cf3b3e
test: re-trigger required checks - the queued runs were cancelled by …
Oct 5, 2026
1639167
fix(file-safety): give the Windows DACL dump a per-write name
Oct 5, 2026
40b2126
test(tools): assert the completeness argument the tool passes, not th…
Oct 6, 2026
9a5c6bb
fix(file-safety): durable-copy publish and confined writes for the ta…
Oct 7, 2026
c78807f
fix(file-safety): do not read a failed target lstat as a missing target
Oct 7, 2026
d3de289
fix(file-safety): compare staging and target identity with bigint stats
Oct 7, 2026
1cd6a66
test(file-safety): pin the bigint options in the staging-identity tests
Oct 7, 2026
d5f9e25
fix(file-safety): report a Windows replacement whose DACL was not pre…
Oct 7, 2026
96f4840
fix(task-history): keep the item when a deletion did not happen
Oct 7, 2026
61d28b8
test(task-history): give the provider spec the fs exports the delete …
Oct 7, 2026
a2748f1
docs(file-safety): describe confineTo where it actually runs
Oct 7, 2026
bb8ef2b
fix(file-safety): keep DACL warning delivery from failing the save
Oct 7, 2026
f4ee7a0
fix(utils): check confinement before taking the advisory lock
Oct 7, 2026
09a6026
fix(integrations): the preview observation must not authorize an appr…
Oct 7, 2026
161ca86
fix(file-safety): handle async warning sinks and confine before mkdir
Oct 7, 2026
4042595
fix(tools): ApplyDiffTool must observe the version its diff was built on
Oct 7, 2026
9b74ee5
test(tools): type the apply_diff observation doubles for check-types
Oct 7, 2026
8e9b591
fix(integrations): never adopt an autosaved match for an unauthorized…
Oct 7, 2026
44e3673
fix(tools): never refresh a model observation the tool read on a diff…
Oct 7, 2026
a1ccf47
fix(webview): clean up the tasks a partial batch delete actually removed
Oct 7, 2026
e09b7db
test(webview): reach the private artifact cleanup through bracket not…
Oct 7, 2026
43fef0c
chore(ci): re-run the unit lane
Oct 7, 2026
ac90265
fix(webview): keep artifact cleanup reachable from partial receivers
Oct 7, 2026
6664b45
fix(webview): clean partial-delete artifacts before posting state
Oct 7, 2026
e0f655b
refactor(task): stop re-adding the isPaused field upstream deleted
Oct 8, 2026
ccb9ece
fix(file-safety): inherit unit 1 committed guard
Oct 5, 2026
5af828f
fix(file-safety): keep the publish error message in RollbackFailureError
Oct 5, 2026
d30b54a
test(file-safety): assert the exact staging directory removed after a…
Oct 5, 2026
f99bfd4
test: re-run mocked e2e and the ubuntu lane - no source change since …
Oct 5, 2026
c01245a
fix(file-safety): give the Windows DACL dump a per-write name
Oct 5, 2026
1a2320a
fix(tools): carry U1's hunk-read completeness rule into this unit
Oct 6, 2026
3887bcf
fix(tools): bring U7 onto the current file-safety core
Oct 7, 2026
d15f73b
chore: trigger a fresh review pass at this head
Oct 7, 2026
634cbe1
fix(integrations): preview observation must not authorize a save; asy…
Oct 7, 2026
316dc7f
fix(tools): ApplyDiffTool must observe the version its diff was built on
Oct 7, 2026
231b7cf
fix(safety): confine before the lock, keep the post-commit backup
Oct 8, 2026
67a4225
fix(safety): confine before mkdir, report the retained backup, stop p…
Oct 8, 2026
33802ef
fix(tools): honor an approval for a write outside the task root
Oct 8, 2026
5e645b1
fix(integrations): a denied preview must not stay authorized for the …
Oct 8, 2026
fe197a6
fix(u9): one lock key whether the parent exists, and no cleanup a wai…
Oct 8, 2026
f9360e9
fix(u7-port): no containment decision without a canonical workspace, …
Oct 8, 2026
901e54f
fix(diff-view): put the save's post-publish cleanup inside the same t…
Oct 8, 2026
0d5eb30
fix(diff-view): let only the teardown that started a pass finalize th…
Oct 9, 2026
6e5b61c
fix(diff-view): restore preview tabs for the pass that owns a rejecte…
Oct 9, 2026
f10bc21
fix(tools): pass the outside-workspace approval flag in its own position
Oct 9, 2026
d26d6c3
fix(guardedWrite): one unresolvable workspace folder must not veto ev…
Oct 9, 2026
a3a88be
test(task-history): make the failed-batch assertions say what they mean
Oct 9, 2026
411eea9
fix(guardedWrite): bind an approved outside-workspace write to the id…
Oct 9, 2026
3198543
fix(diff-view): keep the teardown guard held through the finalization…
Oct 9, 2026
1b2ac89
fix(write-to-file): resolve the approved identity inside the tool's e…
Oct 9, 2026
e0b458d
test(tools): keep the outside-workspace flag reset on the failure path
Oct 9, 2026
cd7de9d
test(observations): cover forget() and the per-task registry ownership
Oct 9, 2026
2dc326f
fix(tools): stop the apply_patch hunk read from refreshing a stale ob…
Oct 9, 2026
2a92bd9
fix(safe-write-json): release the backup a post-commit directory fsyn…
Oct 9, 2026
3b15a63
fix(diff-view): finalize the session when a cancellation lands during…
Oct 9, 2026
2226de8
fix(task-history): a failed delete is reported by the stage that fail…
Oct 9, 2026
ea2614d
fix(test): clear the merge artifacts on the U7 branch
Oct 9, 2026
caba972
refactor(safety,apply-patch): close six review threads on duplicated …
Oct 10, 2026
cad7265
style: format the seventeen files the formatting gate flags on this PR
Oct 10, 2026
aa19b7c
test(task-history): cover both answers the post-lock-failure check ca…
Oct 10, 2026
a38b594
fix(u9): align the unit with main's lint re-enable and format gate
easonLiangWorldedtech Oct 14, 2026
486e074
fix(integrations): drop the Stryker directives from the guarded save …
easonLiangWorldedtech Oct 14, 2026
1d54a75
fix(guarded-write): bind the publish to the authorized canonical target
easonLiangWorldedtech Oct 15, 2026
3d8681d
test(tools): pass toolCallId in guarded-write spec callbacks
easonLiangWorldedtech Oct 15, 2026
3a5ebbb
fix(guarded-write): refuse a dangling leaf link before publishing
easonLiangWorldedtech Oct 16, 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
205 changes: 176 additions & 29 deletions src/core/task-persistence/TaskHistoryStore.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import { historyItemSchema, type HistoryItem } from "@roo-code/types"
import { GlobalFileNames } from "../../shared/globalFileNames"
import { LOCK_STALE_MS, withFileLock } from "../../utils/fileLock"
import { safeWriteJson } from "../../utils/safeWriteJson"
import { resolveLockKey } from "../../services/file-safety/safeWriteText"
import { getStorageBasePath } from "../../utils/storage"
import { assertValidTransition, settleRejectedCreateSubtaskAction, type HistoryItemStatus } from "./taskLifecycle"
import { computeHistoryDelta, DeltaRejectedError, mergeHistoryDelta } from "./taskStoreConcurrency"
Expand Down Expand Up @@ -78,6 +79,34 @@ export interface TaskHistoryStoreOptions {
onWrite?: (items: HistoryItem[]) => Promise<void>
}

/**
* A deletion that did not happen.
*
* The item is kept in the cache, the mtime map and the persisted state, so the store still
* agrees with the disk and the next reconciliation does not resurrect a task the caller was
* told was gone. The caller learns the delete failed instead of seeing a success that the
* on-disk state contradicts.
*/
export class TaskHistoryDeleteError extends Error {
constructor(
readonly taskIds: string[],
readonly reason: string,
cause?: unknown,
) {
super(
`Task history for ${taskIds.join(", ")} could not be deleted (${reason}); the affected items were kept.`,
{ cause },
)
this.name = "TaskHistoryDeleteError"
}
}

function errorCode(error: unknown): string | undefined {
return typeof error === "object" && error !== null && "code" in error
? String((error as { code?: unknown }).code)
: undefined
}

export class TaskHistoryStore {
private readonly globalStoragePath: string
private readonly onWrite?: (items: HistoryItem[]) => Promise<void>
Expand Down Expand Up @@ -263,28 +292,25 @@ export class TaskHistoryStore {
}

/**
* Delete a single task's history item.
* Delete one task’s history item.
*
* Deletion is best-effort: the unlink runs under the same per-file
* advisory lock as `safeWriteJson`, so a locked read-modify-write (for
* example the settlement in `clearPendingActionIfMatching`) cannot
* interleave with it. A lock or unlink failure is swallowed because the
* file may already be deleted; the in-memory eviction and the write
* through still complete.
* The unlink runs under the same per-file advisory lock as `safeWriteJson`, so a locked
* read-modify-write (for example the settlement in `clearPendingActionIfMatching`) cannot
* interleave with it. Eviction and the write-through happen only once the file is actually
* gone - unlinked, or reported absent by the unlink itself. A lock-key resolution failure, a
* lock that could not be taken, or any other unlink failure leaves the item in place and is
* reported as a TaskHistoryDeleteError: a store that dropped the item while the file survived
* would report a deletion that the next reconciliation immediately contradicts.
*/
async delete(taskId: string): Promise<void> {
return this.withLock(async () => {
const outcome = await this.removeTaskFile(taskId)
if (!outcome.deleted) {
throw new TaskHistoryDeleteError([taskId], outcome.reason ?? "unknown error", outcome.cause)
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
this.cache.delete(taskId)
this.taskFileMtimes.delete(taskId)

// Remove per-task file (best-effort)
try {
const filePath = await this.getTaskFilePath(taskId)
await withFileLock(filePath, (absoluteFilePath) => fs.unlink(absoluteFilePath))
} catch {
// File may already be deleted
}

// Call onWrite callback inside the lock for serialized write-through
if (this.onWrite) {
await this.onWrite(this.getAll())
Expand All @@ -293,36 +319,155 @@ export class TaskHistoryStore {
}

/**
* Delete multiple tasks' history items in a batch.
* Delete multiple tasks’ history items in a batch.
*
* Every item follows the `delete` semantics and is attempted even when an
* earlier unlink fails. The single write-through runs once after the
* whole batch.
* Every item follows the `delete` semantics and is attempted even when an earlier one fails.
* The single write-through runs once, after the batch, and only if at least one item was
* actually removed. Failed ids are reported together in one TaskHistoryDeleteError and stay
* in the store.
*/
async deleteMany(taskIds: string[]): Promise<void> {
return this.withLock(async () => {
const failures: { taskId: string; reason: string; cause?: unknown }[] = []
let deletedAny = false
for (const taskId of taskIds) {
const outcome = await this.removeTaskFile(taskId)
if (!outcome.deleted) {
failures.push({ taskId, reason: outcome.reason ?? "unknown error", cause: outcome.cause })
continue
}
this.cache.delete(taskId)
this.taskFileMtimes.delete(taskId)

// Remove per-task file (best-effort)
try {
const filePath = await this.getTaskFilePath(taskId)
await withFileLock(filePath, (absoluteFilePath) => fs.unlink(absoluteFilePath))
} catch {
// File may already be deleted
}
deletedAny = true
}

// Call onWrite callback inside the lock for serialized write-through
if (this.onWrite) {
if (this.onWrite && deletedAny) {
await this.onWrite(this.getAll())
}

if (failures.length > 0) {
throw new TaskHistoryDeleteError(
failures.map((f) => f.taskId),
failures.map((f) => f.taskId + ": " + f.reason).join("; "),
failures[0].cause,
)
}
})
}

/**
* Remove the per-task file under the shared lock and report whether the file is gone.
*
* Only the unlink's own ENOENT counts as gone: the file was there to delete and is not there
* now. Every other failure means the file may still exist and the item must stay in the store
* - a lock-key that cannot be resolved, a lock that could not be taken (proper-lockfile
* reports ENOENT for a missing key), a lock timeout, or any non-ENOENT unlink error. Those
* three stages reach the caller with a reason naming the stage, because "ENOENT" on its own
* does not say which of them happened, and a store that evicts an item whose file survived
* reports a deletion the next reconciliation contradicts. The one stage where an ENOENT may
* still close the deletion is a lock that could not be taken at all: there the verdict comes
* from an lstat of the named path, not from the lock's errno, so a file that reports itself
* absent is a completed deletion while a file that reports itself present is not.
*/
private async removeTaskFile(taskId: string): Promise<{ deleted: boolean; reason?: string; cause?: unknown }> {
const filePath = await this.getTaskFilePath(taskId)
let lockKey: string
try {
// Lock the resolved publish target, not the path as spelled: proper-lockfile keys the
// lock by the path it is given, so an alias and its referent would take two locks for
// one file. The unlink still removes the named path.
lockKey = await this.lockKeyFor(filePath)
} catch (resolutionError: unknown) {
// The key could not be computed, so the lock was never attempted and the unlink never
// ran. A dangling alias whose referent sits under a missing parent fails here with
// ENOENT while the alias itself is still on disk: that is not evidence of a deletion.
return {
deleted: false,
reason: `lock key could not be resolved (${errorCode(resolutionError) ?? "unknown error"})`,
cause: resolutionError,
}
}

// The unlink's outcome is captured inside the locked operation rather than inferred from
// what withFileLock throws: that helper rethrows the operation's error unchanged, so a
// lock failure and an unlink failure arrive at the same place and cannot be told apart by
// their error code alone.
let unlinkOutcome: { outcome: "removed" | "absent" | "failed"; error?: unknown } | undefined
try {
await withFileLock(lockKey, async () => {
try {
await fs.unlink(filePath)
unlinkOutcome = { outcome: "removed" }
} catch (unlinkError: unknown) {
unlinkOutcome = {
outcome: errorCode(unlinkError) === "ENOENT" ? "absent" : "failed",
error: unlinkError,
}
}
})
} catch (lockError: unknown) {
// The lock stage failed, so the unlink never ran. Its ENOENT cannot settle anything on
// its own: proper-lockfile creates <key>.lock with mkdir, which reports ENOENT both when
// the directory that would hold the file is gone (nothing to delete) and when the key is
// an alias whose referent is missing (the named file may still be right there). Ask the
// named path itself rather than reading the lock's errno as a report about the file.
return await this._verdictAfterLockFailure(filePath, lockError)
}

if (unlinkOutcome === undefined) {
// The lock was taken and released without the unlink running. Nothing was observed,
// so nothing may be reported as deleted.
return { deleted: false, reason: "the unlink did not run", cause: undefined }
}
if (unlinkOutcome.outcome === "removed" || unlinkOutcome.outcome === "absent") {
return { deleted: true }
}
return {
deleted: false,
reason: errorCode(unlinkOutcome.error) ?? "unknown error",
cause: unlinkOutcome.error,
}
}

/**
* What to report when the lock could not be taken at all. The file counts as gone only when
* the named path says so directly: lstat does not follow a symlink, so a dangling alias still
* reports itself present and the item stays in the store. Any other answer keeps the item and
* reports the lock failure, which is the only thing actually known.
*/
private async _verdictAfterLockFailure(
filePath: string,
lockError: unknown,
): Promise<{ deleted: boolean; reason?: string; cause?: unknown }> {
const lockReason = `the per-file lock could not be taken (${errorCode(lockError) ?? "unknown error"})`
try {
await fs.lstat(filePath)
} catch (statError: unknown) {
if (errorCode(statError) === "ENOENT") {
// The named path is absent: there was nothing to delete, whatever the lock did.
return { deleted: true }
}
return {
deleted: false,
reason: `${lockReason}; the file could not be checked (${errorCode(statError) ?? "unknown error"})`,
cause: statError,
}
}
return { deleted: false, reason: lockReason, cause: lockError }
}

// ────────────────────────────── Reconciliation ──────────────────────────────

/**
* The lock key a writer would use for a task file. resolvePublishTarget refuses
* a dangling link, so the delete paths and the liveness probe walk the chain
* themselves to find the lock held at the referent.
*/
private async lockKeyFor(taskFilePath: string): Promise<string> {
return resolveLockKey(taskFilePath)
}

/**
* Scan task directories and fix any drift between disk and cache.
*
Expand Down Expand Up @@ -375,7 +520,9 @@ export class TaskHistoryStore {
// held for the entire write, so its presence means a
// write is in progress — keep the task live.
try {
const lockPath = (await this.getTaskFilePath(taskId)) + ".lock"
// Probe the same key the writer locks: safeWriteJson locks the resolved
// publish target, so an alias and its referent share one lock file.
const lockPath = (await this.lockKeyFor(await this.getTaskFilePath(taskId))) + ".lock"
const lockStat = await fs.stat(lockPath)
if (Date.now() - lockStat.mtimeMs < LOCK_STALE_MS) {
liveIds.add(taskId)
Expand Down
Loading
Loading