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
66 changes: 66 additions & 0 deletions src/core/task-persistence/TaskHistoryStore.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,13 @@
return (existing, incoming) => mergeHistoryDelta(existing, incoming as HistoryItem, delta)
}

/** Thrown inside the `setTitleIfUnset` merge callback to abort the write without touching the file. */
class TitleWriteSkipped extends Error {
constructor(readonly reason: "missing" | "titled") {
super(`title write skipped: ${reason}`)

Check warning on line 32 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:32: Survived StringLiteral mutant (replacement: ``). See the job summary for the complete list and resolution guidance.
}
}

/**
* Durable intent for the one repair that spans an active delegated child and
* its parent. Task files remain authoritative; this file only records the
Expand Down Expand Up @@ -996,6 +1003,65 @@

// ────────────────────────────── Atomic read-modify-write ──────────────────────────────

/**
* Set `title` only while the item has none, checking the title on disk inside the
* per-file lock. The cache can be stale when another store (for example a second
* provider on the same storage path) saved a manual title, and a delta merge would
* otherwise overwrite it.
*
* @returns "conflict" when a title already exists or the record was deleted on disk (the cache is refreshed).
* @throws If the task ID is not present in the cache.
*/
public setTitleIfUnset(taskId: string, title: string): Promise<"applied" | "conflict"> {
return this.withLock(async () => {
const current = this.cache.get(taskId)
if (!current) {
throw new Error(`[TaskHistoryStore] setTitleIfUnset: task ${taskId} not found in cache`)
}
if (current.title) {
return "conflict"
}

const filePath = await this.getTaskFilePath(taskId)
let written: HistoryItem = { ...current, title }
try {
await safeWriteJson(filePath, written, {
merge: (existing, incoming) => {
// Throwing aborts the write, so a deleted record is never recreated from the cache
// and an existing title is never rewritten.
if (!existing || typeof existing !== "object" || !("id" in existing)) {

Check warning on line 1032 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:1032: Survived ConditionalExpression mutant (replacement: false). See the job summary for the complete list and resolution guidance.
throw new TitleWriteSkipped("missing")
}
if ((existing as HistoryItem).title) {
throw new TitleWriteSkipped("titled")

Check warning on line 1036 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:1036: Survived StringLiteral mutant (replacement: ""). See the job summary for the complete list and resolution guidance.
}
written = mergeHistoryDelta(existing, incoming as HistoryItem, { title })
return written
},
})
} catch (error) {
if (!(error instanceof TitleWriteSkipped)) {

Check warning on line 1043 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:1043: 2 mutation test gaps; example: NoCoverage BlockStatement mutant (replacement: {}). See the job summary for the complete list and resolution guidance.
throw error
}
if (error.reason === "missing") {
this.cache.delete(taskId)
} else {
const diskItem = await this.readTaskFile(taskId)
if (diskItem) {

Check warning on line 1050 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:1050: Survived ConditionalExpression mutant (replacement: true). See the job summary for the complete list and resolution guidance.
this.cache.set(taskId, diskItem)
}
}
return "conflict"
}

this.cache.set(taskId, written)
if (this.onWrite) {

Check warning on line 1058 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:1058: 2 mutation test gaps; example: NoCoverage BlockStatement mutant (replacement: {}). See the job summary for the complete list and resolution guidance.
await this.onWrite(this.getAll())
}
return "applied"
})
}

/**
* Read a HistoryItem from the in-memory cache and write back an updated version,
* all within a single lock acquisition so no concurrent writer can interleave
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,51 @@ describe("TaskHistoryStore cross-instance safety", () => {
await fs.rm(tmpDir, { recursive: true, force: true }).catch(() => {})
})

describe("setTitleIfUnset", () => {
it("keeps a manual title that another instance wrote after this cache was loaded", async () => {
await storeA.initialize()
await storeB.initialize()
await storeA.upsert(makeHistoryItem({ id: "title-task", task: "Original prompt" }))
await storeB.reconcile()
expect(storeB.get("title-task")?.title).toBeUndefined()

// Instance A still caches no title when B renames the task on disk.
await storeB.atomicReadAndUpdate("title-task", (item) => ({ ...item, title: "Manual title" }))
expect(storeA.get("title-task")?.title).toBeUndefined()

await expect(storeA.setTitleIfUnset("title-task", "Auto title")).resolves.toBe("conflict")

expect(storeA.get("title-task")?.title).toBe("Manual title")
await storeB.reconcile()
expect(storeB.get("title-task")?.title).toBe("Manual title")
})

it("does not recreate a task that another instance deleted", async () => {
await storeA.initialize()
await storeB.initialize()
await storeA.upsert(makeHistoryItem({ id: "title-deleted", task: "Original prompt" }))
await storeB.reconcile()

await storeB.delete("title-deleted")
expect(storeA.get("title-deleted")).toBeDefined()

await expect(storeA.setTitleIfUnset("title-deleted", "Auto title")).resolves.toBe("conflict")

expect(storeA.get("title-deleted")).toBeUndefined()
await storeB.reconcile()
expect(storeB.get("title-deleted")).toBeUndefined()
})

it("applies the title when no instance has set one", async () => {
await storeA.initialize()
await storeA.upsert(makeHistoryItem({ id: "title-free", task: "Original prompt" }))

await expect(storeA.setTitleIfUnset("title-free", "Auto title")).resolves.toBe("applied")

expect(storeA.get("title-free")).toMatchObject({ title: "Auto title", task: "Original prompt" })
})
})

it("two instances can write different tasks without conflict", async () => {
await storeA.initialize()
await storeB.initialize()
Expand Down
33 changes: 31 additions & 2 deletions src/core/task/Task.ts
Original file line number Diff line number Diff line change
Expand Up @@ -145,6 +145,7 @@ import { prepareApiConversationMessage } from "./apiConversationHistory"
import { shouldAddUserMessageToHistory } from "./messageCounting"
import { type TaskExecutionContext } from "./providerHandoff"
import { initialRunState, type Ask, type RunEvent, type RunState } from "./run-state/runState"
import { generateSessionTitle } from "./session-title"
import { applyRunEvent, type RunRejection } from "./run-state/runStateShadow"
import {
divergenceKey,
Expand Down Expand Up @@ -286,6 +287,9 @@ export class PendingActionSettlementError extends Error {
}
}

// Literal, not the vscode enum: specs that mock "vscode" without ExtensionMode would throw on the read.
const EXTENSION_MODE_TEST = 3

export class Task extends EventEmitter<TaskEvents> implements TaskLike {
readonly taskId: string
readonly rootTaskId?: string
Expand Down Expand Up @@ -3110,6 +3114,31 @@ export class Task extends EventEmitter<TaskEvents> implements TaskLike {

await this.say("text", task, images)

// Fire-and-forget session title generation: the run must never wait
// on it and no title failure may surface in the chat. Only a root
// task's first message triggers this — resumes go through
// resumeTaskFromHistory, subtask continuations re-enter the task
// loop directly, and both must keep their existing names. `say`
// above persisted the history item, so the conditional write will
// find it.
// The extra provider request would break E2E and visual tests that assert
// exact request sequences and rendered task names, so Test mode skips it.
if (!this.parentTask && task) {
const provider = this.providerRef.deref()
if (provider && provider.context.extensionMode !== EXTENSION_MODE_TEST) {
void generateSessionTitle({
Comment thread
daewoongoh marked this conversation as resolved.
Resolved
provider,
taskId: this.taskId,
userMessage: task,
apiConfiguration: this.apiConfiguration,
}).catch((error) => {
Comment thread
daewoongoh marked this conversation as resolved.
Resolved
// generateSessionTitle never rejects by contract; this
// arm is defense-in-depth for future regressions.
console.error(`[Task#startTask] session title generation failed: ${error}`)
})
}
}

// Check for too many MCP tools and warn the user
const { enabledToolCount, enabledServerCount } = await this.getEnabledMcpToolsCount()
if (enabledToolCount > MAX_MCP_TOOLS_THRESHOLD) {
Expand Down Expand Up @@ -4419,7 +4448,7 @@ export class Task extends EventEmitter<TaskEvents> implements TaskLike {
nativeToolCallParserScope,
)

// Stryker disable next-line ConditionalExpression, EqualityOperator: equivalent mutants, streamingKey is always defined here, so both conditions reduce to the partialToolUse check
// Stryker disable next-line ConditionalExpression,EqualityOperator: equivalent mutants, streamingKey is always defined here, so both conditions reduce to the partialToolUse check
if (partialToolUse && streamingKey !== undefined) {
// Reuse the compound key built above: it is exactly the key the start event
// registered under, so the delta lookup needs no second encoding.
Expand Down Expand Up @@ -4855,7 +4884,7 @@ export class Task extends EventEmitter<TaskEvents> implements TaskLike {
// name (provider quirk), the compound-key lookup above misses and both the
// parser entry and the dedup tracking would stay stale. Resolve the tracked
// entry by id and target the real compound key for finalization and cleanup.
// Stryker disable next-line ConditionalExpression, LogicalOperator, EqualityOperator: equivalent mutants, the both-miss case is unreachable via the raw-chunk pipeline
// Stryker disable next-line ConditionalExpression,LogicalOperator,EqualityOperator: equivalent mutants, the both-miss case is unreachable via the raw-chunk pipeline
if (finalToolUse === null && toolUseIndex === undefined) {
const resolved = NativeToolCallParser.getStreamingToolCallById(
event.id,
Expand Down
Loading
Loading