-
Notifications
You must be signed in to change notification settings - Fork 235
feat(task): track task nesting depth with cycle-safe backfill #1263
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -170,6 +170,14 @@ export class Task extends EventEmitter<TaskEvents> implements TaskLike { | |
| readonly rootTaskId?: string | ||
| readonly parentTaskId?: string | ||
| childTaskId?: string | ||
| /** Nesting level; root = 0, child = parent.depth + 1. */ | ||
| readonly depth: number | ||
| /** | ||
| * True when `depth` was derived authoritatively (persisted value, live parent, | ||
| * or a genuine root) and is safe to persist on save. False for legacy children | ||
| * resumed without their live parent — the provider backfills those before first save. | ||
| */ | ||
| readonly depthAuthoritative: boolean | ||
| pendingNewTaskToolCallId?: string | ||
|
|
||
| readonly instanceId: string | ||
|
|
@@ -505,6 +513,28 @@ export class Task extends EventEmitter<TaskEvents> implements TaskLike { | |
| this.parentTaskId = historyItem ? historyItem.parentTaskId : parentTask?.taskId | ||
| this.childTaskId = undefined | ||
|
|
||
| // Nesting depth (root = 0). A persisted value is authoritative; otherwise derive it | ||
| // from the live parent task (parent.depth + 1) or default to root (0). | ||
| const persistedDepth = historyItem?.depth | ||
| if (typeof persistedDepth === "number" && Number.isInteger(persistedDepth) && persistedDepth >= 0) { | ||
| this.depth = persistedDepth | ||
| this.depthAuthoritative = true | ||
| } else if (parentTask) { | ||
| // Live parent available: its depth is authoritative, so the child's is too. | ||
| this.depth = parentTask.depth + 1 | ||
| this.depthAuthoritative = true | ||
| } else if (!this.parentTaskId) { | ||
| // No persisted depth and no parent reference at all: this task is a root. | ||
| this.depth = 0 | ||
| this.depthAuthoritative = true | ||
| } else { | ||
| // Legacy child resumed without its live parent (e.g. reopened from history): | ||
| // the depth cannot be derived here, so mark it non-authoritative and let | ||
| // ClineProvider.createTaskWithHistoryItem() backfill it before first save. | ||
| this.depth = 0 | ||
| this.depthAuthoritative = false | ||
| } | ||
|
Comment on lines
+516
to
+536
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🎯 Functional Correctness | 🟠 Major | 🏗️ Heavy lift Apply the requested
Destructure and validate 🤖 Prompt for AI Agents |
||
|
|
||
| this.metadata = { | ||
| task: historyItem ? historyItem.task : task, | ||
| images: historyItem ? [] : images, | ||
|
|
@@ -1121,6 +1151,9 @@ export class Task extends EventEmitter<TaskEvents> implements TaskLike { | |
| mode: this._taskMode || defaultModeSlug, // Use the task's own mode, not the current provider mode. | ||
| apiConfigName: this._taskApiConfigName, // Use the task's own provider profile, not the current provider profile. | ||
| initialStatus: this.initialStatus, | ||
| // Only persist depth when it was derived authoritatively; a legacy child resumed | ||
| // without its live parent carries a placeholder 0 that must not be written back. | ||
| depth: this.depthAuthoritative ? this.depth : undefined, | ||
| }) | ||
|
|
||
| // Emit token/tool usage updates using debounced function | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,85 @@ | ||
| // npx vitest core/task/__tests__/taskDepth.spec.ts | ||
|
|
||
| import { computeTaskDepth, MAX_DEPTH_WALK } from "../taskDepth" | ||
|
|
||
| describe("computeTaskDepth", () => { | ||
| it("returns a valid persisted depth as-is", () => { | ||
| expect(computeTaskDepth("a", 3, () => undefined)).toBe(3) | ||
| }) | ||
|
|
||
| it("rejects non-integer / negative persisted depths and falls through to the walk", () => { | ||
| // ownDepth invalid -> walk; no parent -> root at depth 0 | ||
| expect(computeTaskDepth("a", -1, (id) => ({ parentTaskId: undefined }))).toBe(0) | ||
| expect(computeTaskDepth("a", 1.5, (id) => ({ parentTaskId: undefined }))).toBe(0) | ||
| }) | ||
|
|
||
| it("derives depth from a live root with no persisted value", () => { | ||
| // c -> b -> a(root). No depths persisted anywhere. | ||
| const lookup = (id: string) => { | ||
| switch (id) { | ||
| case "c": | ||
| return { parentTaskId: "b" } | ||
| case "b": | ||
| return { parentTaskId: "a" } | ||
| case "a": | ||
| return { parentTaskId: undefined } | ||
| } | ||
| return undefined | ||
| } | ||
| expect(computeTaskDepth("c", undefined, lookup)).toBe(2) | ||
| }) | ||
|
|
||
| it("derives depth from the nearest ancestor with a persisted value", () => { | ||
| // c -> b(depth 5) -> a. Only b has a persisted depth. | ||
| const lookup = (id: string) => { | ||
| switch (id) { | ||
| case "c": | ||
| return { parentTaskId: "b" } | ||
| case "b": | ||
| return { parentTaskId: "a", depth: 5 } | ||
| case "a": | ||
| return { parentTaskId: undefined, depth: 4 } | ||
| } | ||
| return undefined | ||
| } | ||
| expect(computeTaskDepth("c", undefined, lookup)).toBe(6) | ||
| }) | ||
|
|
||
| it("returns undefined for a cycle in the parent chain", () => { | ||
| const lookup = (id: string) => { | ||
| switch (id) { | ||
| case "a": | ||
| return { parentTaskId: "b" } | ||
| case "b": | ||
| return { parentTaskId: "c" } | ||
| case "c": | ||
| return { parentTaskId: "a" } // cycle | ||
| } | ||
| return undefined | ||
| } | ||
| expect(computeTaskDepth("a", undefined, lookup)).toBeUndefined() | ||
| }) | ||
|
|
||
| it("returns undefined when the chain dangles (parent not loadable)", () => { | ||
| const lookup = (id: string) => { | ||
| if (id === "c") return { parentTaskId: "missing" } | ||
| return undefined // "missing" cannot be loaded | ||
| } | ||
| expect(computeTaskDepth("c", undefined, lookup)).toBeUndefined() | ||
| }) | ||
|
|
||
| it("returns undefined for a chain longer than MAX_DEPTH_WALK hops", () => { | ||
| // Build a linear chain of length > MAX_DEPTH_WALK with no persisted depths. | ||
| const nodes: Record<string, { parentTaskId?: string }> = {} | ||
| for (let i = 0; i < MAX_DEPTH_WALK + 5; i++) { | ||
| nodes[`t${i}`] = { parentTaskId: i === 0 ? undefined : `t${i - 1}` } | ||
| } | ||
| const lookup = (id: string) => nodes[id] | ||
| expect(computeTaskDepth(`t${MAX_DEPTH_WALK + 4}`, undefined, lookup)).toBeUndefined() | ||
| }) | ||
|
|
||
| it("handles a self-referencing parent", () => { | ||
| const lookup = (id: string) => ({ parentTaskId: id }) | ||
| expect(computeTaskDepth("a", undefined, lookup)).toBeUndefined() | ||
| }) | ||
| }) |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,69 @@ | ||
| /** | ||
| * Pure helpers for task nesting depth. | ||
| * | ||
| * Depth is the number of delegation hops from the root task (root = 0). | ||
| * It is derived from the `parentTaskId` chain so that legacy tasks without a | ||
| * persisted `depth` field can be backfilled on load, and so that a corrupted | ||
| * or circular parent chain cannot produce an invalid depth. | ||
| */ | ||
|
|
||
| /** Maximum ancestor hops to follow before giving up (guards against cycles). */ | ||
| export const MAX_DEPTH_WALK = 32 | ||
|
|
||
| export type DepthLookup = (taskId: string) => { parentTaskId?: string; depth?: number } | undefined | ||
|
|
||
| function isValidDepth(value: unknown): value is number { | ||
| return typeof value === "number" && Number.isInteger(value) && value >= 0 | ||
| } | ||
|
|
||
| /** | ||
| * Compute the nesting depth for a task. | ||
| * | ||
| * - A persisted `depth` on the task itself is authoritative. | ||
| * - Otherwise, walk up the `parentTaskId` chain: | ||
| * - if an ancestor has a valid persisted depth, the task sits that many hops below it; | ||
| * - if we reach a root (no parent) without a persisted depth, the task's depth equals | ||
| * the number of hops taken to get there (a root is at depth 0); | ||
| * - on a cycle, a dangling reference, or an overly long chain, return `undefined` | ||
| * so callers fall back to treating the task as a root without persisting a bogus value. | ||
| */ | ||
| export function computeTaskDepth( | ||
| taskId: string, | ||
| ownDepth: number | undefined, | ||
| lookup: DepthLookup, | ||
| ): number | undefined { | ||
| if (isValidDepth(ownDepth)) { | ||
| return ownDepth | ||
| } | ||
|
|
||
| const seen = new Set<string>([taskId]) | ||
| let currentId: string | undefined = taskId | ||
| let hopsFromTask = 0 | ||
|
|
||
| while (hopsFromTask < MAX_DEPTH_WALK) { | ||
| const node: { parentTaskId?: string; depth?: number } | undefined = currentId ? lookup(currentId) : undefined | ||
| if (!node) { | ||
| // Parent chain references a task we cannot load — stop. | ||
| return undefined | ||
| } | ||
| if (isValidDepth(node.depth)) { | ||
| // Nearest ancestor with an authoritative depth: the original task sits | ||
| // `hopsFromTask` levels below it. | ||
| return node.depth + hopsFromTask | ||
| } | ||
| const parentId: string | undefined = node.parentTaskId | ||
| if (parentId === undefined) { | ||
| // Reached a root without a persisted depth (root = 0). | ||
| return hopsFromTask | ||
| } | ||
| if (seen.has(parentId)) { | ||
| // Cycle detected — refuse to persist a derived depth. | ||
| return undefined | ||
| } | ||
| seen.add(parentId) | ||
| currentId = parentId | ||
| hopsFromTask += 1 | ||
| } | ||
|
|
||
| return undefined | ||
|
Comment on lines
+43
to
+68
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win Allow exactly A chain with exactly 32 hops returns 🤖 Prompt for AI Agents |
||
| } | ||
Uh oh!
There was an error while loading. Please reload this page.