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
106 changes: 106 additions & 0 deletions src/core/task-persistence/TaskHistoryStore.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1186,6 +1186,112 @@
})
}

/**
* Disk-authoritative conditional write for a generated task title.
*
* The decision reads the persisted record inside the per-file advisory
* lock's merge callback, so a manual rename committed by another store
* (another extension host) after this store's cache was last refreshed is
* preserved instead of overwritten: every provider owns a separate
* TaskHistoryStore with a separate in-process lock and cache, so a
* cache-only existing-title check is not sufficient. An existing disk
* title — a manual rename or an earlier generated title — always wins,
* and the record's other disk fields are preserved when the generated
* title is applied.
*
* Deletion by another host is authoritative: when no persisted record
* exists, or the persisted record is malformed or carries a different task
* ID, nothing is written, the stale cache entry is dropped, and the result
* has no item. A signal aborted while the call waits on the store lock or
* the per-file lock skips the write, so a cancelled task's title is never
* persisted.
*
* @returns The authoritative record (absent when the write was rejected)
* and whether the generated title was applied.
* @throws If the task ID is not present in the cache, so the caller can retry.
*/
public async setGeneratedTitleIfUnset(
taskId: string,
title: string,
options: { signal?: AbortSignal } = {},
): Promise<{ item: HistoryItem | undefined; applied: boolean }> {
return this.withLock(async () => {
const cached = this.cache.get(taskId)
if (!cached) {
throw new Error(`[TaskHistoryStore] setGeneratedTitleIfUnset: task ${taskId} not found in cache`)
}
const trimmed = title.trim()
if (!trimmed || options.signal?.aborted) {
return { item: cached, applied: false }
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Resolved
}

const filePath = await this.getTaskFilePath(taskId)
const incoming: HistoryItem = { ...cached, title: trimmed }
// Seed the decision from the cache, then let the locked disk record
// override it inside the merge callback. When the per-file lock
// cannot hand back the disk record (a safeWriteJson double that
// never invokes `merge`), the cache decision stands.
let applied = !cached.title
let authoritative: HistoryItem = cached.title ? cached : incoming
let rejectedDiskRecord = false
let cancelled = false
try {
await safeWriteJson(filePath, incoming, {
merge: (existing) => {
// Cancellation can land while waiting on the per-file lock;
// a throwing merge writes nothing.
if (options.signal?.aborted) {
cancelled = true
throw new Error(`[TaskHistoryStore] setGeneratedTitleIfUnset: task ${taskId} cancelled`)

Check warning on line 1245 in src/core/task-persistence/TaskHistoryStore.ts

View workflow job for this annotation

GitHub Actions / mutation-diff

Mutation test advisory

src/core/task-persistence/TaskHistoryStore.ts:1245: Survived StringLiteral mutant (replacement: ``). See the job summary for the complete list and resolution guidance.
}
Comment thread
daewoongoh marked this conversation as resolved.
Resolved
// Writing the cached item back would recreate a task another
// host deleted or overwrite a record that is not this task's,
// so a missing, malformed, or mismatched record fails closed.
// A throwing merge writes nothing.
const parsed = historyItemSchema.safeParse(existing)
if (!parsed.success || parsed.data.id !== taskId) {
rejectedDiskRecord = true
throw new Error(
`[TaskHistoryStore] setGeneratedTitleIfUnset: task ${taskId} has no valid disk record`,

Check warning on line 1255 in src/core/task-persistence/TaskHistoryStore.ts

View workflow job for this annotation

GitHub Actions / mutation-diff

Mutation test advisory

src/core/task-persistence/TaskHistoryStore.ts:1255: Survived StringLiteral mutant (replacement: ``). See the job summary for the complete list and resolution guidance.
)
}
const disk = existing as HistoryItem
if (disk.title) {
// A manual rename or an earlier generated title
// from any host is already persisted; keep it.
applied = false
authoritative = disk
return disk
}
// Apply only the title onto the locked disk record so
// fields written by another host are preserved.
applied = true
authoritative = { ...disk, title: trimmed }
Comment thread
daewoongoh marked this conversation as resolved.
Resolved
return authoritative
},
})
} catch (error) {
if (cancelled) {
return { item: cached, applied: false }
}
if (!rejectedDiskRecord) {
throw error
}
this.cache.delete(taskId)
this.taskFileMtimes.delete(taskId)

Check warning on line 1281 in src/core/task-persistence/TaskHistoryStore.ts

View workflow job for this annotation

GitHub Actions / mutation-diff

Mutation test advisory

src/core/task-persistence/TaskHistoryStore.ts:1281: Survived CallExpression mutant (replacement: ;). See the job summary for the complete list and resolution guidance.
if (this.onWrite) {

Check warning on line 1282 in src/core/task-persistence/TaskHistoryStore.ts

View workflow job for this annotation

GitHub Actions / mutation-diff

Mutation test advisory

src/core/task-persistence/TaskHistoryStore.ts:1282: Survived ConditionalExpression mutant (replacement: true). See the job summary for the complete list and resolution guidance.
await this.onWrite(this.getAll())
}
return { item: undefined, applied: false }
}
this.cache.set(taskId, authoritative)
if (this.onWrite) {

Check warning on line 1288 in src/core/task-persistence/TaskHistoryStore.ts

View workflow job for this annotation

GitHub Actions / mutation-diff

Mutation test advisory

src/core/task-persistence/TaskHistoryStore.ts:1288: 2 mutation test gaps; example: Survived BlockStatement mutant (replacement: {}). See the job summary for the complete list and resolution guidance.
await this.onWrite(this.getAll())
}
return { item: authoritative, applied }
})
}

// ────────────────────────────── Private: Write lock ──────────────────────────────

/**
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -421,4 +421,208 @@ describe("TaskHistoryStore real cross-host locking", () => {
await fs.rm(storagePath, { recursive: true, force: true })
}
})

it("preserves a manual rename committed by another store over a stale-cache generated title", async () => {
const storagePath = await fs.mkdtemp(path.join(os.tmpdir(), "task-history-generated-title-"))
const storeA = new TaskHistoryStore(storagePath)
const storeB = new TaskHistoryStore(storagePath)
const filePath = path.join(storagePath, "tasks", "shared-task", "history_item.json")

try {
await storeA.initialize()
await storeA.upsert(item("shared-task"))
// storeB hydrates its cache before the rename, so its cached copy
// never sees the manual title.
await storeB.initialize()

// The manual rename commits to disk through store A before store
// B attempts the generated-title write.
await storeA.atomicReadAndUpdate("shared-task", (current) => ({ ...current, title: "Manual Title" }))

const result = await storeB.setGeneratedTitleIfUnset("shared-task", "Generated Title")

expect(result.applied).toBe(false)
expect(result.item?.title).toBe("Manual Title")
// The stale cache is refreshed with the authoritative disk record.
expect(storeB.get("shared-task")?.title).toBe("Manual Title")
expect(JSON.parse(await fs.readFile(filePath, "utf8"))).toMatchObject({ title: "Manual Title" })
} finally {
storeA.dispose()
storeB.dispose()
await fs.rm(storagePath, { recursive: true, force: true })
}
})

it("applies the generated title onto the locked disk record when no disk title exists", async () => {
const storagePath = await fs.mkdtemp(path.join(os.tmpdir(), "task-history-generated-title-apply-"))
const storeA = new TaskHistoryStore(storagePath)
const storeB = new TaskHistoryStore(storagePath)
const filePath = path.join(storagePath, "tasks", "shared-task", "history_item.json")

try {
await storeA.initialize()
await storeA.upsert(item("shared-task"))
await storeB.initialize()

// Another host persists an unrelated field; storeB's cache stays
// stale, so the apply must read the locked disk record.
await storeA.atomicReadAndUpdate("shared-task", (current) => ({ ...current, totalCost: 42 }))

const result = await storeB.setGeneratedTitleIfUnset("shared-task", "Generated Title")

expect(result.applied).toBe(true)
expect(result.item).toMatchObject({ title: "Generated Title", totalCost: 42 })
expect(JSON.parse(await fs.readFile(filePath, "utf8"))).toMatchObject({
title: "Generated Title",
totalCost: 42,
})
Comment thread
daewoongoh marked this conversation as resolved.
Resolved
} finally {
storeA.dispose()
storeB.dispose()
await fs.rm(storagePath, { recursive: true, force: true })
}
})

describe("setGeneratedTitleIfUnset title input and cancellation", () => {
async function withSingleStore(
run: (store: TaskHistoryStore, filePath: string, onWrite: ReturnType<typeof vi.fn>) => Promise<void>,
) {
const storagePath = await fs.mkdtemp(path.join(os.tmpdir(), "task-history-generated-title-input-"))
const onWrite = vi.fn(async () => {})
const store = new TaskHistoryStore(storagePath, { onWrite })
const filePath = path.join(storagePath, "tasks", "solo-task", "history_item.json")
try {
await store.initialize()
await store.upsert(item("solo-task"))
onWrite.mockClear()
await run(store, filePath, onWrite)
} finally {
store.dispose()
await fs.rm(storagePath, { recursive: true, force: true })
}
}

it("persists a padded title trimmed", async () => {
await withSingleStore(async (store, filePath) => {
const result = await store.setGeneratedTitleIfUnset("solo-task", " Padded Title ")

expect(result).toMatchObject({ applied: true, item: { title: "Padded Title" } })
expect(JSON.parse(await fs.readFile(filePath, "utf8"))).toMatchObject({ title: "Padded Title" })
})
})

it.each([
["a blank title", " ", undefined],
["an aborted signal", "Generated Title", AbortSignal.abort()],
])("does not write or notify for %s", async (_label, title, signal) => {
await withSingleStore(async (store, filePath, onWrite) => {
const before = await fs.readFile(filePath, "utf8")

const result = await store.setGeneratedTitleIfUnset("solo-task", title, { signal })

expect(result.applied).toBe(false)
expect(await fs.readFile(filePath, "utf8")).toBe(before)
expect(onWrite).not.toHaveBeenCalled()
expect(store.get("solo-task")?.title).toBeUndefined()
})
})

it("skips the write when cancelled while waiting on the per-file lock", async () => {
const actualFs = await vi.importActual<typeof import("fs/promises")>("fs/promises")
await withSingleStore(async (store, filePath, onWrite) => {
const before = await fs.readFile(filePath, "utf8")
const controller = new AbortController()
let signalReadStarted!: () => void
const readStarted = new Promise<void>((resolve) => {
signalReadStarted = resolve
})
let releaseRead!: () => void
const readCanContinue = new Promise<void>((resolve) => {
releaseRead = resolve
})
// Pause inside the per-file lock's disk read, before the merge.
vi.mocked(fs.readFile).mockImplementation(async (...args: Parameters<typeof fs.readFile>) => {
if (args[0] === filePath) {
signalReadStarted()
await readCanContinue
}
return actualFs.readFile(...args)
})

try {
const write = store.setGeneratedTitleIfUnset("solo-task", "Generated Title", {
signal: controller.signal,
})
await readStarted
controller.abort()
releaseRead()

await expect(write).resolves.toMatchObject({ applied: false })
} finally {
vi.mocked(fs.readFile).mockImplementation(actualFs.readFile)
}
expect(await fs.readFile(filePath, "utf8")).toBe(before)
expect(onWrite).not.toHaveBeenCalled()
expect(store.get("solo-task")?.title).toBeUndefined()
})
})

it("propagates an ordinary storage failure and keeps the cached task", async () => {
const actualFs = await vi.importActual<typeof import("fs/promises")>("fs/promises")
await withSingleStore(async (store, filePath, onWrite) => {
const storageError = Object.assign(new Error("permission denied"), { code: "EACCES" })
vi.mocked(fs.readFile).mockImplementation(async (...args: Parameters<typeof fs.readFile>) => {
if (args[0] === filePath) {
throw storageError
}
return actualFs.readFile(...args)
})

try {
await expect(store.setGeneratedTitleIfUnset("solo-task", "Generated Title")).rejects.toBe(
storageError,
)
} finally {
vi.mocked(fs.readFile).mockImplementation(actualFs.readFile)
}
expect(store.get("solo-task")).toMatchObject({ id: "solo-task" })
expect(onWrite).not.toHaveBeenCalled()
})
})
})

describe("setGeneratedTitleIfUnset with a rejected disk record", () => {
it.each([
["deleted by another host", async (filePath: string) => fs.rm(filePath)],
["unreadable", async (filePath: string) => fs.writeFile(filePath, "{not json")],
[
"persisted under a different task ID",
async (filePath: string) => fs.writeFile(filePath, JSON.stringify(item("other-task"))),
],
])("fails closed without writing when the disk record is %s", async (_label, mutateDisk) => {
const storagePath = await fs.mkdtemp(path.join(os.tmpdir(), "task-history-generated-title-reject-"))
const onWrite = vi.fn(async () => {})
const store = new TaskHistoryStore(storagePath, { onWrite })
const filePath = path.join(storagePath, "tasks", "shared-task", "history_item.json")

try {
await store.initialize()
await store.upsert(item("shared-task"))
// Another host changes the disk record after this store cached it.
await mutateDisk(filePath)
const before = await fs.readFile(filePath, "utf8").catch(() => undefined)
onWrite.mockClear()

const result = await store.setGeneratedTitleIfUnset("shared-task", "Generated Title")

expect(result).toEqual({ item: undefined, applied: false })
expect(await fs.readFile(filePath, "utf8").catch(() => undefined)).toBe(before)
expect(store.get("shared-task")).toBeUndefined()
expect(onWrite).toHaveBeenCalledTimes(1)
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Resolved
} finally {
store.dispose()
await fs.rm(storagePath, { recursive: true, force: true })
}
})
})
})
Loading
Loading