Skip to content
Open
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
3 changes: 3 additions & 0 deletions apps/vscode-e2e/src/suite/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,9 @@ export async function run() {
openRouterApiKey: aimockUrl && !isRecord ? "mock-key" : process.env.OPENROUTER_API_KEY!,
openRouterModelId: "openai/gpt-4.1",
...(aimockUrl && { openRouterBaseUrl: `${aimockUrl}/v1` }),
// Title requests embed the task prompt, so they match prompt-substring fixtures and
// consume their sequenceIndex slots ahead of the task's own requests.
taskTitleGenerationEnabled: false,
})

await vscode.commands.executeCommand("zoo-code.SidebarProvider.focus")
Expand Down
2 changes: 2 additions & 0 deletions apps/vscode-e2e/src/visual/sceneController.ts
Original file line number Diff line number Diff line change
Expand Up @@ -108,6 +108,8 @@ export async function run(): Promise<void> {
openRouterModelId: "openai/gpt-4.1",
openRouterBaseUrl: `${aimockUrl}/v1`,
telemetrySetting: "disabled",
// A generated title lands asynchronously and would race the task header screenshot.
taskTitleGenerationEnabled: false,
})
await log(`aimock=${aimockUrl}`)
let completionHandler: ((event: { message: ClineMessage }) => void) | undefined
Expand Down
5 changes: 5 additions & 0 deletions packages/types/src/global-settings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,9 @@

export const DEFAULT_DESTRUCTIVE_COMMAND_GUARD_ENABLED = false

/** Whether new root tasks get a short generated title in the background. */
export const DEFAULT_TASK_TITLE_GENERATION_ENABLED = true

Check warning on line 58 in packages/types/src/global-settings.ts

View workflow job for this annotation

GitHub Actions / mutation-diff

Mutation test advisory

packages/types/src/global-settings.ts:58: Survived BooleanLiteral mutant (replacement: false). See the job summary for the complete list and resolution guidance.

/**
* Whether commands that are not explicitly auto-approved are automatically
* denied (with a structured reason sent to the model) instead of prompting the
Expand Down Expand Up @@ -273,6 +276,8 @@
customSupportPrompts: customSupportPromptsSchema.optional(),
enhancementApiConfigId: z.string().optional(),
includeTaskHistoryInEnhance: z.boolean().optional(),
taskTitleGenerationEnabled: z.boolean().optional(),
taskTitleApiConfigId: z.string().optional(),
historyPreviewCollapsed: z.boolean().optional(),
reasoningBlockCollapsed: z.boolean().optional(),
/**
Expand Down
1 change: 1 addition & 0 deletions packages/types/src/history.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ export const historyItemSchema = z.object({
ts: z.number(),
task: z.string(),
title: z.string().optional(), // User-assigned display name; `task` keeps the original prompt
autoTitle: z.string().optional(), // Generated display name; `title` (manual) takes precedence
tokensIn: z.number(),
tokensOut: z.number(),
cacheWrites: z.number().optional(),
Expand Down
2 changes: 2 additions & 0 deletions packages/types/src/vscode-extension-host.ts
Original file line number Diff line number Diff line change
Expand Up @@ -340,6 +340,8 @@ export type ExtensionState = Pick<
| "imageGenerationProvider"
| "openRouterImageGenerationSelectedModel"
| "includeTaskHistoryInEnhance"
| "taskTitleGenerationEnabled"
| "taskTitleApiConfigId"
| "reasoningBlockCollapsed"
| "chatFontSize"
| "timeFormat"
Expand Down
137 changes: 127 additions & 10 deletions scripts/check-task-store-concurrency.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,10 @@ type OperationId =
| "stale-save-a"
| "abandon-b"
| "reject-a"
| "rename-a"
| "auto-title-a"
| "auto-title-b"
| "delete-a"
type RecordMap = Partial<Record<TaskId, HistoryItem>>

interface PreparedWrite {
Expand All @@ -34,7 +38,7 @@ interface PreparedWrite {
}

interface OperationState {
phase: "idle" | "read" | "prepared" | "revalidated" | "done" | "rejected" | "failed"
phase: "idle" | "read" | "prepared" | "revalidated" | "done" | "rejected" | "skipped" | "failed"
snapshot?: RecordMap
writes?: PreparedWrite[]
writeIndex: number
Expand Down Expand Up @@ -64,6 +68,13 @@ interface OperationSpec {
externalSnapshot?: boolean
allowRefreshAfterRead?: boolean
publishCacheAtEnd?: boolean
/**
* "auto-title" mirrors TaskHistoryStore.setAutoTitleIfAbsent: under the file lock the write is
* skipped when the persisted record is gone or already carries an autoTitle, and a commit copies
* only autoTitle into the host cache.
* "delete" removes the persisted record.
*/
kind?: "auto-title" | "delete"
isEnabled?(snapshot: RecordMap): boolean
buildWrites(snapshot: RecordMap): HistoryItem[]
}
Expand Down Expand Up @@ -113,6 +124,12 @@ const semanticLandmarks = {
state.commits.filter((entry) => entry.operationId === "complete-a").length === 1 &&
state.caches.A["child-a"]?.status === "completed" &&
state.caches.A.parent?.status === "delegated",
"auto-title-skipped-after-delete": (state: ModelState) =>
Object.values(state.operations).some((operation) => operation.phase === "skipped") &&
state.operations["delete-a"]?.phase === "done" &&
state.disk["child-a"] === undefined,
"manual-and-auto-title-coexist": (state: ModelState) =>
Boolean(state.disk["child-a"]?.title) && Boolean(state.disk["child-a"]?.autoTitle),
} satisfies Record<string, (state: ModelState) => boolean>

function item(id: TaskId, overrides: Partial<HistoryItem> = {}): HistoryItem {
Expand Down Expand Up @@ -248,6 +265,32 @@ const operationSpecs: Record<OperationId, OperationSpec> = {
return [abandoned.child, abandoned.parent]
},
},
"rename-a": {
id: "rename-a",
host: "A",
buildWrites: (snapshot) => [{ ...getRequired(snapshot, "child-a"), title: "manual" }],
},
"auto-title-a": {
id: "auto-title-a",
host: "A",
kind: "auto-title",
isEnabled: (snapshot) => snapshot["child-a"] !== undefined,
buildWrites: (snapshot) => [{ ...getRequired(snapshot, "child-a"), autoTitle: "generated-a" }],
},
"auto-title-b": {
id: "auto-title-b",
host: "B",
kind: "auto-title",
isEnabled: (snapshot) => snapshot["child-a"] !== undefined,
buildWrites: (snapshot) => [{ ...getRequired(snapshot, "child-a"), autoTitle: "generated-b" }],
},
"delete-a": {
id: "delete-a",
host: "A",
kind: "delete",
isEnabled: (snapshot) => snapshot["child-a"] !== undefined,
buildWrites: (snapshot) => [getRequired(snapshot, "child-a")],
},
"reject-a": {
id: "reject-a",
host: "A",
Expand Down Expand Up @@ -316,6 +359,13 @@ function nextSteps(state: ModelState, scenario: Scenario): TraceStep[] {
const target = next.operations[spec.id]!
const targetWrite = target.writes![target.writeIndex]!
next.locks[targetWrite.taskId] = spec.id
const persisted = next.disk[targetWrite.taskId]
if (spec.kind === "auto-title" && (!persisted || persisted.autoTitle)) {
target.phase = "skipped"
delete next.locks[targetWrite.taskId]
delete next.hostMutexes[spec.host]
return
}
try {
target.candidate = mergeHistoryDelta(
next.disk[targetWrite.taskId],
Expand All @@ -342,15 +392,29 @@ function nextSteps(state: ModelState, scenario: Scenario): TraceStep[] {
throw new Error(`${spec.id} committed without owning ${targetWrite.taskId}`)
}
const previous = next.disk[targetWrite.taskId]
next.disk[targetWrite.taskId] = target.candidate
if (!spec.publishCacheAtEnd) next.caches[spec.host][targetWrite.taskId] = target.candidate
next.commits.push({
operationId: spec.id,
taskId: targetWrite.taskId,
previous,
delta: targetWrite.delta,
next: target.candidate,
})
if (spec.kind === "delete") {
delete next.disk[targetWrite.taskId]
delete next.caches[spec.host][targetWrite.taskId]
} else {
next.disk[targetWrite.taskId] = target.candidate
const cached = next.caches[spec.host][targetWrite.taskId]
if (spec.kind === "auto-title" && cached) {
// setAutoTitleIfAbsent copies only autoTitle so the cache stays this host's save baseline.
next.caches[spec.host][targetWrite.taskId] = {
...cached,
autoTitle: target.candidate.autoTitle,
}
} else if (!spec.publishCacheAtEnd) {
next.caches[spec.host][targetWrite.taskId] = target.candidate
}
next.commits.push({
operationId: spec.id,
taskId: targetWrite.taskId,
previous,
delta: targetWrite.delta,
next: target.candidate,
})
}
delete next.locks[targetWrite.taskId]
target.candidate = undefined
target.writeIndex++
Expand Down Expand Up @@ -681,6 +745,59 @@ const scenarios: Scenario[] = [
},
check: () => [],
},
{
name: "auto title vs manual rename",
operations: [operationSpecs["rename-a"], operationSpecs["auto-title-b"]],
check: (state) => {
if (state.operations["rename-a"]?.phase !== "done" || state.operations["auto-title-b"]?.phase !== "done") {
return []
}
const record = state.disk["child-a"]
return record?.title === "manual" && record.autoTitle === "generated-b"
? []
: ["auto title and manual title did not both survive"]
},
},
{
name: "auto title vs stale save",
operations: [operationSpecs["stale-save-a"], operationSpecs["auto-title-b"]],
check: (state) => {
if (
state.operations["stale-save-a"]?.phase !== "done" ||
state.operations["auto-title-b"]?.phase !== "done"
) {
return []
}
const record = state.disk["child-a"]
return record?.autoTitle === "generated-b" && record.tokensOut === 1
? []
: ["stale save erased the auto title or lost its own update"]
},
},
{
name: "auto title vs delete",
operations: [operationSpecs["delete-a"], operationSpecs["auto-title-b"]],
check: (state) => {
const autoTitle = state.operations["auto-title-b"]?.phase
if (state.operations["delete-a"]?.phase !== "done" || (autoTitle !== "done" && autoTitle !== "skipped")) {
return []
}
return state.disk["child-a"] === undefined ? [] : ["auto title resurrected a deleted record"]
},
},
{
name: "auto title never overwritten",
operations: [operationSpecs["auto-title-a"], operationSpecs["auto-title-b"]],
check: (state) => {
const phases = [state.operations["auto-title-a"]?.phase, state.operations["auto-title-b"]?.phase]
if (!phases.every((phase) => phase === "done" || phase === "skipped")) return []
const titleCommits = state.commits.filter((entry) => entry.taskId === "child-a" && entry.delta.autoTitle)
const first = titleCommits[0]
return titleCommits.length === 1 && state.disk["child-a"]?.autoTitle === first?.next.autoTitle
? []
: ["a second auto title overwrote the first"]
},
},
]

let totalStates = 0
Expand Down
1 change: 1 addition & 0 deletions src/__tests__/ClineProvider.delegation.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1205,6 +1205,7 @@ describe("ClineProvider.delegateParentAndOpenChild()", () => {
// The real deletion path: the store swallows a per-file history
// lock failure, so deleteMany resolves and cleanup continues.
deleteTaskWithId: ClineProvider.prototype.deleteTaskWithId,
taskTitleService: { cancel: vi.fn() },
createTaskWithHistoryItem,
log: vi.fn(),
postStateToWebview: vi.fn().mockResolvedValue(undefined),
Expand Down
96 changes: 96 additions & 0 deletions src/core/task-persistence/TaskHistoryStore.ts
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,8 @@ interface DelegationRepairIntent {
/**
* Options for TaskHistoryStore constructor.
*/
type AutoTitleDiskRead = { kind: "found"; item: HistoryItem } | { kind: "missing" } | { kind: "unreadable" }

export interface TaskHistoryStoreOptions {
/**
* Optional callback invoked inside the write lock after each mutation
Expand Down Expand Up @@ -1024,6 +1026,100 @@ export class TaskHistoryStore {
})
}

/**
* Record a generated title unless the task already has one. The persisted record is re-checked
* under the per-file lock, so a task deleted by another host is never resurrected and a title
* written by another host is never overwritten. Only a confirmed missing file evicts the cache:
* a read or parse failure leaves it intact so a later save still merges against a known record.
*/
public setAutoTitleIfAbsent(taskId: string, autoTitle: string): Promise<{ applied: boolean; item?: HistoryItem }> {
return this.withLock(async () => {
const title = autoTitle.trim()
const disk = await this.readTaskFileForAutoTitle(taskId)
if (disk.kind !== "found") {
return this.settleSkippedAutoTitle(taskId, disk)
}
if (disk.item.autoTitle || !title) {
return this.settleSkippedAutoTitle(taskId, disk)
}

const skipped = Symbol("auto-title-skipped")
let persisted: HistoryItem | undefined
try {
const filePath = await this.getTaskFilePath(taskId)
await safeWriteJson(
filePath,
{ ...disk.item, autoTitle: title },
{
merge: (existing) => {
const current = existing as Partial<HistoryItem> | null
if (!current || current.id !== taskId || current.autoTitle) {
throw Object.assign(new Error("auto title skipped"), { [skipped]: true })
}
persisted = { ...(current as HistoryItem), autoTitle: title }
return persisted
},
},
)
} catch (error) {
if (!(error instanceof Error) || !(skipped in error)) {
throw error
}
// safeWriteJson reports both a missing and an unparsable file as `null`, so re-read to tell them apart.
return this.settleSkippedAutoTitle(taskId, await this.readTaskFileForAutoTitle(taskId))
}

if (!persisted) {
return { applied: false }
}
this.cacheAutoTitle(persisted)
if (this.onWrite) {
await this.onWrite(this.getAll())
}
return { applied: true, item: persisted }
})
}

/**
* Copy only `autoTitle` from the disk record onto the cached one. The cache is the baseline that
* later saves diff against, so taking the peer's other fields here would let a stale snapshot
* from this host write its old values back over them. Callers still return the full disk record
* for display.
*/
private cacheAutoTitle(disk: HistoryItem): void {
const cached = this.cache.get(disk.id)
this.cache.set(disk.id, cached ? { ...cached, autoTitle: disk.autoTitle } : disk)
}

private settleSkippedAutoTitle(taskId: string, disk: AutoTitleDiskRead): { applied: false; item?: HistoryItem } {
switch (disk.kind) {
case "found":
this.cacheAutoTitle(disk.item)
return { applied: false, item: disk.item }
case "missing":
this.cache.delete(taskId)
return { applied: false }
case "unreadable":
return { applied: false, item: this.cache.get(taskId) }
}
}

private async readTaskFileForAutoTitle(taskId: string): Promise<AutoTitleDiskRead> {
let raw: string
try {
raw = await readFileWithMissingRetry(await this.getTaskFilePath(taskId))
} catch (error) {
return getErrorCode(error) === "ENOENT" ? { kind: "missing" } : { kind: "unreadable" }
}
try {
const item: unknown = JSON.parse(raw)
const id = typeof item === "object" && item !== null ? (item as { id?: unknown }).id : undefined
return id === taskId ? { kind: "found", item: item as HistoryItem } : { kind: "unreadable" }
} catch {
return { kind: "unreadable" }
}
}

/**
* Update two related HistoryItems within a single in-process lock acquisition.
* Both updaters run synchronously (no I/O, no lock re-entry). Both writes
Expand Down
Loading
Loading