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
31 changes: 31 additions & 0 deletions packages/types/src/global-settings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,25 @@ export const DEFAULT_DIFF_FUZZY_THRESHOLD = 1.0

export const DEFAULT_DESTRUCTIVE_COMMAND_GUARD_ENABLED = false

/**
* Default maximum task nesting depth (root = 0).
*
* A value of `0` disables delegation entirely — every `new_task` call is executed
* inline in the current conversation instead of opening a child tab. The range is
* clamped to 0–5 by the settings UI and validated here.
*/
export const DEFAULT_MAX_NESTING_DEPTH = 2

/**
* Default for auto-flattening when the nesting limit is reached.
*
* When `true`, a `new_task` call that would exceed `maxNestingDepth` is executed inline
* in the current conversation (same Task instance, phase marker) rather than opening a
* child tab. When `false`, such a call is rejected with an error result so the model
* continues working directly.
*/
export const DEFAULT_AUTO_FLATTEN_ON_LIMIT = true

/**
* Terminal output preview size options for persisted command output.
*
Expand Down Expand Up @@ -151,6 +170,18 @@ export const globalSettingsSchema = z.object({
autoCondenseContext: z.boolean().optional(),
autoCondenseContextPercent: z.number().optional(),

/**
* Maximum task nesting depth (root = 0). Range 0–5; `0` disables delegation entirely.
* @default 2
*/
maxNestingDepth: z.number().int().min(0).max(5).optional(),
/**
* When the nesting limit is reached, execute the subtask inline in the current
* conversation instead of opening a child tab. When `false`, such calls are rejected.
* @default true
*/
autoFlattenOnLimit: z.boolean().optional(),

/**
* Whether to include current time in the environment details
* @default true
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 @@ -24,6 +24,7 @@ export const historyItemSchema = z.object({
delegatedToId: z.string().optional(), // Last child this parent delegated to
childIds: z.array(z.string()).optional(), // All children spawned by this task
awaitingChildId: z.string().optional(), // Child currently awaited (set when delegated)
depth: z.number().int().min(0).optional(), // Nesting level; root = 0, child = parent.depth + 1
completedByChildId: z.string().optional(), // Child that completed and resumed this parent
completionResultSummary: z.string().optional(), // Summary from completed child
})
Expand Down
4 changes: 4 additions & 0 deletions packages/types/src/task.ts
Original file line number Diff line number Diff line change
Expand Up @@ -94,6 +94,8 @@ export interface CreateTaskOptions {
/** Whether to start the task loop immediately (default: true).
* When false, the caller must invoke `task.start()` manually. */
startTask?: boolean
/** Nesting level for newly created tasks; root = 0, child = parent.depth + 1. */
depth?: number
}

export enum TaskStatus {
Expand All @@ -116,6 +118,8 @@ export interface TaskLike {
readonly rootTaskId?: string
readonly parentTaskId?: string
readonly childTaskId?: string
/** Nesting level; root = 0, child = parent.depth + 1. */
readonly depth: number
readonly metadata: TaskMetadata
readonly taskStatus: TaskStatus
readonly taskAsk: ClineMessage | undefined
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 @@ -341,6 +341,8 @@ export type ExtensionState = Pick<

writeDelayMs: number
diffFuzzyThreshold: number
maxNestingDepth?: number // Maximum task nesting depth (root = 0); default 2, range 0–5
autoFlattenOnLimit?: boolean // Execute subtasks inline when the nesting limit is reached; default true

enableCheckpoints: boolean
checkpointTimeout: number // Timeout for checkpoint initialization in seconds (default: 15)
Expand Down
3 changes: 3 additions & 0 deletions src/__tests__/single-open-invariant.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -169,6 +169,7 @@ describe("Single-open-task invariant", () => {
getCurrentTask: vi.fn(() => undefined), // ensure not rehydrating
taskHistoryStore: { get: vi.fn(() => undefined) },
markDelegatedChildInterrupted: vi.fn().mockResolvedValue(undefined),
backfillTaskDepth: vi.fn().mockResolvedValue(undefined),
get evictCurrentTask() {
return privateClineProvider.evictCurrentTask.bind(this)
},
Expand Down Expand Up @@ -268,6 +269,7 @@ describe("Single-open-task invariant", () => {
taskScheduler: { schedule: schedulespy },
taskEventListeners: new WeakMap(),
performPreparationTasks: vi.fn().mockResolvedValue(undefined),
backfillTaskDepth: vi.fn().mockResolvedValue(undefined),
context: { extension: { packageJSON: {} }, globalStorageUri: { fsPath: "/tmp" } },
contextProxy: {
extensionUri: {},
Expand Down Expand Up @@ -340,6 +342,7 @@ describe("Single-open-task invariant", () => {
taskScheduler: { schedule: schedulespy },
taskEventListeners: new WeakMap(),
performPreparationTasks: vi.fn().mockResolvedValue(undefined),
backfillTaskDepth: vi.fn().mockResolvedValue(undefined),
context: { extension: { packageJSON: {} }, globalStorageUri: { fsPath: "/tmp" } },
contextProxy: {
extensionUri: {},
Expand Down
4 changes: 4 additions & 0 deletions src/core/task-persistence/taskMetadata.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,8 @@ export type TaskMetadataOptions = {
apiConfigName?: string
/** Initial status for the task (e.g., "active" for child tasks) */
initialStatus?: "active" | "delegated" | "completed" | "interrupted"
/** Nesting level; root = 0, child = parent.depth + 1. Persisted when known. */
depth?: number
}

export async function taskMetadata({
Expand All @@ -38,6 +40,7 @@ export async function taskMetadata({
mode,
apiConfigName,
initialStatus,
depth,
}: TaskMetadataOptions) {
const taskDir = await getTaskDirectoryPath(globalStoragePath, id)

Expand Down Expand Up @@ -112,6 +115,7 @@ export async function taskMetadata({
mode,
...(typeof apiConfigName === "string" && apiConfigName.length > 0 ? { apiConfigName } : {}),
...(initialStatus && { status: initialStatus }),
...(typeof depth === "number" && Number.isInteger(depth) && depth >= 0 ? { depth } : {}),
}

return { historyItem, tokenUsage }
Expand Down
33 changes: 33 additions & 0 deletions src/core/task/Task.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
}

this.metadata = {
task: historyItem ? historyItem.task : task,
images: historyItem ? [] : images,
Expand Down Expand Up @@ -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
Expand Down
92 changes: 92 additions & 0 deletions src/core/task/__tests__/Task.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -547,6 +547,98 @@ describe("Cline", () => {
new Task({ provider: mockProvider, apiConfiguration: mockApiConfig })
}).toThrow("Either historyItem or task/images must be provided")
})

describe("nesting depth", () => {
it("assigns depth 0 and authoritative to a new root task", () => {
const task = new Task({
provider: mockProvider,
apiConfiguration: mockApiConfig,
task: "root task",
startTask: false,
})

expect(task.depth).toBe(0)
expect(task.depthAuthoritative).toBe(true)
})

it("derives child depth from the live parent (parent.depth + 1)", () => {
const parent = new Task({
provider: mockProvider,
apiConfiguration: mockApiConfig,
task: "parent task",
startTask: false,
})

const child = new Task({
provider: mockProvider,
apiConfiguration: mockApiConfig,
task: "child task",
parentTask: parent,
startTask: false,
})

expect(parent.depth).toBe(0)
expect(child.depth).toBe(1)
expect(child.depthAuthoritative).toBe(true)
expect(child.parentTaskId).toBe(parent.taskId)
})

it("prefers a persisted depth over the live parent", () => {
const parent = new Task({
provider: mockProvider,
apiConfiguration: mockApiConfig,
task: "parent task",
startTask: false,
})

const child = new Task({
provider: mockProvider,
apiConfiguration: mockApiConfig,
task: "child task",
parentTask: parent,
historyItem: {
id: "persisted-child",
ts: Date.now(),
task: "child task",
number: 1,
tokensIn: 0,
tokensOut: 0,
cacheWrites: 0,
cacheReads: 0,
totalCost: 0,
parentTaskId: parent.taskId,
depth: 7,
},
startTask: false,
})

expect(child.depth).toBe(7)
expect(child.depthAuthoritative).toBe(true)
})

it("marks a legacy child resumed without its live parent as non-authoritative", () => {
const task = new Task({
provider: mockProvider,
apiConfiguration: mockApiConfig,
historyItem: {
id: "legacy-child",
ts: Date.now(),
task: "legacy child",
number: 1,
tokensIn: 0,
tokensOut: 0,
cacheWrites: 0,
cacheReads: 0,
totalCost: 0,
parentTaskId: "missing-parent",
},
startTask: false,
})

expect(task.depth).toBe(0)
expect(task.depthAuthoritative).toBe(false)
})
})
})

describe("task-local configuration isolation", () => {
Expand Down
85 changes: 85 additions & 0 deletions src/core/task/__tests__/taskDepth.spec.ts
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()
})
})
Loading
Loading