feat: add TaskContext and TaskPermissions for Phase 3a task isolation

Introduces the foundation for isolated task execution (Phase 3a of #12330):

- TaskContext: immutable snapshot of mode, API config, and workspace for
  each task, replacing runtime reads from shared ClineProvider state
- TaskPermissions: fine-grained permission boundaries (file patterns,
  command restrictions, read-only mode, tool allowlists) that the
  orchestrator can attach when spawning subtasks
- TaskContextBuilder: factory functions to build TaskContext from provider
  state and to derive child contexts with merged permissions
- Task constructor now accepts optional taskContext, using it for mode
  and API config initialization instead of provider.getState()
- delegateParentAndOpenChild builds and passes a TaskContext to child tasks
- Permission merging follows most-restrictive-wins semantics

This is a pure refactor with no behavioral change -- tasks still execute
sequentially, but they now carry their own isolated context. Enforcement
of permission boundaries is deferred to Phase 3b/3d.

Ref: #12330
This commit is contained in:
Roo Code 2026-05-12 05:00:07 +00:00
parent 3fd128832c
commit 16de6be4ea
9 changed files with 608 additions and 8 deletions

View file

@ -0,0 +1,126 @@
import { describe, it, expect } from "vitest"
import {
taskPermissionsSchema,
taskContextSchema,
mergePermissions,
type TaskPermissions,
type TaskContext,
} from "../task-context.js"
describe("TaskPermissions schema", () => {
it("accepts empty object", () => {
const result = taskPermissionsSchema.parse({})
expect(result).toEqual({})
})
it("accepts full permissions object", () => {
const permissions: TaskPermissions = {
fileReadPatterns: ["docs/**", "src/**"],
fileWritePatterns: ["docs/**"],
allowedCommands: ["npm test"],
blockedCommands: ["rm -rf"],
readOnly: true,
allowedTools: ["read_file", "list_files"],
}
const result = taskPermissionsSchema.parse(permissions)
expect(result).toEqual(permissions)
})
it("accepts partial permissions", () => {
const result = taskPermissionsSchema.parse({ readOnly: true })
expect(result).toEqual({ readOnly: true })
})
it("rejects invalid types", () => {
expect(() => taskPermissionsSchema.parse({ readOnly: "yes" })).toThrow()
expect(() => taskPermissionsSchema.parse({ fileReadPatterns: "docs/**" })).toThrow()
})
})
describe("TaskContext schema", () => {
it("accepts minimal context", () => {
const context: TaskContext = { mode: "code" }
const result = taskContextSchema.parse(context)
expect(result.mode).toBe("code")
})
it("accepts full context", () => {
const context: TaskContext = {
mode: "architect",
apiConfigName: "gpt-4",
permissions: {
readOnly: true,
fileReadPatterns: ["docs/**"],
},
inheritSkills: true,
skillOverrides: ["custom-skill"],
workspacePath: "/workspace/project",
parentTaskId: "parent-123",
rootTaskId: "root-456",
}
const result = taskContextSchema.parse(context)
expect(result).toEqual(context)
})
it("rejects missing mode", () => {
expect(() => taskContextSchema.parse({})).toThrow()
})
})
describe("mergePermissions", () => {
it("returns undefined when both are undefined", () => {
expect(mergePermissions(undefined, undefined)).toBeUndefined()
})
it("returns child when parent is undefined", () => {
const child: TaskPermissions = { readOnly: true }
expect(mergePermissions(undefined, child)).toEqual(child)
})
it("returns parent when child is undefined", () => {
const parent: TaskPermissions = { readOnly: true }
expect(mergePermissions(parent, undefined)).toEqual(parent)
})
it("merges readOnly with OR logic", () => {
expect(mergePermissions({ readOnly: true }, { readOnly: false })).toMatchObject({ readOnly: true })
expect(mergePermissions({ readOnly: false }, { readOnly: true })).toMatchObject({ readOnly: true })
expect(mergePermissions({ readOnly: false }, { readOnly: false })).toMatchObject({})
})
it("intersects fileWritePatterns", () => {
const parent: TaskPermissions = { fileWritePatterns: ["docs/**", "src/**", "package.json"] }
const child: TaskPermissions = { fileWritePatterns: ["docs/**", "package.json"] }
const result = mergePermissions(parent, child)
expect(result?.fileWritePatterns).toEqual(["docs/**", "package.json"])
})
it("intersects allowedTools", () => {
const parent: TaskPermissions = { allowedTools: ["read_file", "list_files", "search_files"] }
const child: TaskPermissions = { allowedTools: ["read_file", "search_files", "write_to_file"] }
const result = mergePermissions(parent, child)
expect(result?.allowedTools).toEqual(["read_file", "search_files"])
})
it("unions blockedCommands", () => {
const parent: TaskPermissions = { blockedCommands: ["rm -rf"] }
const child: TaskPermissions = { blockedCommands: ["git push", "rm -rf"] }
const result = mergePermissions(parent, child)
expect(result?.blockedCommands).toEqual(["rm -rf", "git push"])
})
it("returns defined array when only one side specifies it", () => {
const parent: TaskPermissions = { fileReadPatterns: ["docs/**"] }
const child: TaskPermissions = {}
const result = mergePermissions(parent, child)
expect(result?.fileReadPatterns).toEqual(["docs/**"])
})
it("returns empty array when intersection is empty", () => {
const parent: TaskPermissions = { allowedTools: ["read_file"] }
const child: TaskPermissions = { allowedTools: ["write_to_file"] }
const result = mergePermissions(parent, child)
expect(result?.allowedTools).toEqual([])
})
})

View file

@ -20,6 +20,7 @@ export * from "./mode.js"
export * from "./model.js"
export * from "./provider-settings.js"
export * from "./task.js"
export * from "./task-context.js"
export * from "./todo.js"
export * from "./skills.js"
export * from "./terminal.js"

View file

@ -0,0 +1,227 @@
import { z } from "zod"
/**
* TaskPermissions defines fine-grained permission boundaries for a subtask.
*
* These permissions allow the orchestrator (or parent task) to restrict what
* a child task can do, making parallel execution safer by preventing
* unintended side effects across task boundaries.
*
* ## Design Notes
*
* Phase 3a introduces the types and plumbing. Enforcement is deferred to
* Phase 3b (read-only parallelism) and Phase 3d (write parallelism).
*
* The permission model is intentionally additive: if no permissions are
* specified, the task inherits full capabilities from its mode. Permissions
* can only *restrict*, never *expand* beyond what the mode allows.
*/
export const taskPermissionsSchema = z.object({
/**
* Glob patterns restricting which files the task may read.
* If empty or undefined, the task can read any file (subject to mode restrictions).
* Examples: ["docs/**", "src/utils/**"]
*/
fileReadPatterns: z.array(z.string()).optional(),
/**
* Glob patterns restricting which files the task may write/edit.
* If empty or undefined, the task can write any file (subject to mode restrictions).
* Examples: ["docs/**", "package.json"]
*/
fileWritePatterns: z.array(z.string()).optional(),
/**
* Allowlist of shell commands the task may execute.
* If empty or undefined, the task can execute any command (subject to mode restrictions).
* Matched as prefixes against the command string.
* Examples: ["npm test", "npx vitest", "git status"]
*/
allowedCommands: z.array(z.string()).optional(),
/**
* Blocklist of shell commands the task may NOT execute.
* Takes precedence over allowedCommands.
* Examples: ["rm -rf", "git push"]
*/
blockedCommands: z.array(z.string()).optional(),
/**
* Whether the task is restricted to read-only operations.
* When true, the task cannot use write tools (write_to_file, apply_diff,
* execute_command, etc.). This is the primary mechanism for Phase 3b
* read-only parallelism.
*/
readOnly: z.boolean().optional(),
/**
* Explicit list of tool names the task is allowed to use.
* If empty or undefined, all tools available to the mode are allowed.
* Examples: ["read_file", "list_files", "search_files"]
*/
allowedTools: z.array(z.string()).optional(),
})
export type TaskPermissions = z.infer<typeof taskPermissionsSchema>
/**
* TaskContext encapsulates all per-task configuration that a Task needs
* to operate independently of the ClineProvider's shared mutable state.
*
* ## Purpose
*
* Today, Task reads mode, API config, and other settings from the provider
* via `provider.getState()` at construction time and during execution.
* This couples Task execution to the provider's current state, which
* prevents multiple tasks from running concurrently (since they'd all
* read/write the same shared state).
*
* TaskContext captures a snapshot of everything a Task needs at creation
* time, so the Task can operate with its own isolated configuration.
*
* ## Lifecycle
*
* 1. Built by the parent (orchestrator or provider) when creating a subtask
* 2. Passed to the Task constructor as an immutable snapshot
* 3. The Task uses this context instead of reaching back to the provider
* for mode/config/permissions during execution
*
* ## Phase 3a Scope
*
* In Phase 3a, TaskContext is optional -- tasks that don't receive one
* fall back to the existing provider.getState() behavior. This ensures
* full backward compatibility while enabling incremental adoption.
*/
export const taskContextSchema = z.object({
/**
* The mode slug for this task (e.g., "code", "architect", "ask").
* Snapshot at task creation time -- does not change if the provider's
* mode changes later.
*/
mode: z.string(),
/**
* The API configuration profile name for this task.
* Allows subtasks to use different models (including local ones)
* from the parent task.
*/
apiConfigName: z.string().optional(),
/**
* Permission boundaries for this task.
* If undefined, the task inherits full capabilities from its mode.
*/
permissions: taskPermissionsSchema.optional(),
/**
* Whether this task should inherit skills from the parent.
* Defaults to true if not specified.
*/
inheritSkills: z.boolean().optional(),
/**
* Additional skill overrides for this task.
* These are merged with (or replace) inherited skills depending
* on the inheritSkills setting.
*/
skillOverrides: z.array(z.string()).optional(),
/**
* The workspace path for this task.
* Allows subtasks to operate in different workspace roots.
*/
workspacePath: z.string().optional(),
/**
* ID of the parent task that created this context.
* Used for lineage tracking and result aggregation.
*/
parentTaskId: z.string().optional(),
/**
* ID of the root task in the delegation chain.
* Used for hierarchical task management.
*/
rootTaskId: z.string().optional(),
})
export type TaskContext = z.infer<typeof taskContextSchema>
/**
* Merge two TaskPermissions objects, producing the most restrictive
* combination. This is used when a parent task's permissions should
* further constrain a child task's permissions.
*
* Rules:
* - readOnly: true if either is true
* - allowedTools: intersection if both specified, otherwise the one that's specified
* - fileReadPatterns / fileWritePatterns: intersection if both specified
* - allowedCommands: intersection if both specified
* - blockedCommands: union (all blocked commands from both)
*/
export function mergePermissions(
parent: TaskPermissions | undefined,
child: TaskPermissions | undefined,
): TaskPermissions | undefined {
if (!parent && !child) {
return undefined
}
if (!parent) {
return child
}
if (!child) {
return parent
}
return {
readOnly: parent.readOnly || child.readOnly || undefined,
fileReadPatterns: intersectArrays(parent.fileReadPatterns, child.fileReadPatterns),
fileWritePatterns: intersectArrays(parent.fileWritePatterns, child.fileWritePatterns),
allowedCommands: intersectArrays(parent.allowedCommands, child.allowedCommands),
blockedCommands: unionArrays(parent.blockedCommands, child.blockedCommands),
allowedTools: intersectArrays(parent.allowedTools, child.allowedTools),
}
}
/** Return intersection of two optional arrays, or the defined one if only one exists. */
function intersectArrays(a: string[] | undefined, b: string[] | undefined): string[] | undefined {
if (!a && !b) {
return undefined
}
if (!a) {
return b
}
if (!b) {
return a
}
const setB = new Set(b)
const result = a.filter((item) => setB.has(item))
return result.length > 0 ? result : []
}
/** Return union of two optional arrays. */
function unionArrays(a: string[] | undefined, b: string[] | undefined): string[] | undefined {
if (!a && !b) {
return undefined
}
if (!a) {
return b
}
if (!b) {
return a
}
return Array.from(new Set([...a, ...b]))
}

View file

@ -5,6 +5,7 @@ import type { RooCodeSettings } from "./global-settings.js"
import type { ClineMessage, QueuedMessage, TokenUsage } from "./message.js"
import type { ToolUsage, ToolName } from "./tool.js"
import type { TodoItem } from "./todo.js"
import type { TaskContext } from "./task-context.js"
/**
* TaskProviderLike
@ -94,6 +95,12 @@ export interface CreateTaskOptions {
/** Whether to start the task loop immediately (default: true).
* When false, the caller must invoke `task.start()` manually. */
startTask?: boolean
/**
* Optional isolated task context containing mode, API config, and permissions.
* When provided, the task uses this context instead of reading from the provider.
* Phase 3a foundation for concurrent task execution.
*/
taskContext?: TaskContext
}
export enum TaskStatus {

View file

@ -47,6 +47,7 @@ describe("ClineProvider.delegateParentAndOpenChild()", () => {
getTaskWithId,
updateTaskHistory,
handleModeSwitch,
getState: vi.fn().mockResolvedValue({ mode: "code", currentApiConfigName: "default" }),
log: vi.fn(),
} as unknown as ClineProvider
@ -68,6 +69,7 @@ describe("ClineProvider.delegateParentAndOpenChild()", () => {
initialTodos: [],
initialStatus: "active",
startTask: false,
taskContext: expect.objectContaining({ mode: "code" }),
})
// Metadata persistence - parent gets "delegated" status (child status is set at creation via initialStatus)
@ -129,6 +131,7 @@ describe("ClineProvider.delegateParentAndOpenChild()", () => {
getTaskWithId,
updateTaskHistory,
handleModeSwitch,
getState: vi.fn().mockResolvedValue({ mode: "code", currentApiConfigName: "default" }),
log: vi.fn(),
} as unknown as ClineProvider

View file

@ -0,0 +1,130 @@
import { describe, it, expect, vi } from "vitest"
import { buildTaskContext, buildChildTaskContext } from "../core/task/TaskContextBuilder"
import { defaultModeSlug } from "../shared/modes"
import type { TaskContext } from "@roo-code/types"
describe("buildTaskContext", () => {
it("snapshots mode and API config from provider state", async () => {
const provider = {
getState: vi.fn().mockResolvedValue({
mode: "architect",
currentApiConfigName: "gpt-4-profile",
}),
} as any
const ctx = await buildTaskContext(provider)
expect(ctx.mode).toBe("architect")
expect(ctx.apiConfigName).toBe("gpt-4-profile")
expect(ctx.inheritSkills).toBe(true)
})
it("applies overrides over provider state", async () => {
const provider = {
getState: vi.fn().mockResolvedValue({
mode: "code",
currentApiConfigName: "default",
}),
} as any
const ctx = await buildTaskContext(provider, {
mode: "ask",
apiConfigName: "local-model",
permissions: { readOnly: true },
parentTaskId: "parent-1",
})
expect(ctx.mode).toBe("ask")
expect(ctx.apiConfigName).toBe("local-model")
expect(ctx.permissions?.readOnly).toBe(true)
expect(ctx.parentTaskId).toBe("parent-1")
})
it("falls back to defaults when provider state is empty", async () => {
const provider = {
getState: vi.fn().mockResolvedValue(null),
} as any
const ctx = await buildTaskContext(provider)
expect(ctx.mode).toBe(defaultModeSlug)
expect(ctx.apiConfigName).toBe("default")
})
})
describe("buildChildTaskContext", () => {
it("inherits parent context when no overrides", () => {
const parent: TaskContext = {
mode: "orchestrator",
apiConfigName: "gpt-4",
permissions: { fileWritePatterns: ["docs/**"] },
inheritSkills: true,
workspacePath: "/workspace",
rootTaskId: "root-1",
}
const child = buildChildTaskContext(parent, { parentTaskId: "parent-1" })
expect(child.mode).toBe("orchestrator")
expect(child.apiConfigName).toBe("gpt-4")
expect(child.permissions?.fileWritePatterns).toEqual(["docs/**"])
expect(child.workspacePath).toBe("/workspace")
expect(child.rootTaskId).toBe("root-1")
expect(child.parentTaskId).toBe("parent-1")
})
it("overrides mode and API config for child", () => {
const parent: TaskContext = {
mode: "orchestrator",
apiConfigName: "gpt-4",
rootTaskId: "root-1",
}
const child = buildChildTaskContext(parent, {
mode: "code",
apiConfigName: "local-llama",
parentTaskId: "parent-1",
})
expect(child.mode).toBe("code")
expect(child.apiConfigName).toBe("local-llama")
expect(child.rootTaskId).toBe("root-1")
})
it("merges permissions using most-restrictive rule", () => {
const parent: TaskContext = {
mode: "orchestrator",
permissions: {
fileWritePatterns: ["docs/**", "src/**"],
allowedTools: ["read_file", "write_to_file", "list_files"],
},
}
const child = buildChildTaskContext(parent, {
mode: "code",
permissions: {
fileWritePatterns: ["docs/**"],
allowedTools: ["read_file", "list_files"],
},
parentTaskId: "parent-1",
})
// Intersection of file write patterns
expect(child.permissions?.fileWritePatterns).toEqual(["docs/**"])
// Intersection of allowed tools
expect(child.permissions?.allowedTools).toEqual(["read_file", "list_files"])
})
it("inherits parent permissions when child specifies none", () => {
const parent: TaskContext = {
mode: "orchestrator",
permissions: { readOnly: true },
}
const child = buildChildTaskContext(parent, {
mode: "ask",
parentTaskId: "parent-1",
})
expect(child.permissions?.readOnly).toBe(true)
})
})

View file

@ -51,6 +51,7 @@ import {
MIN_CHECKPOINT_TIMEOUT_SECONDS,
MAX_MCP_TOOLS_THRESHOLD,
countEnabledMcpTools,
type TaskContext,
} from "@roo-code/types"
// api
@ -153,6 +154,15 @@ export interface TaskOptions extends CreateTaskOptions {
workspacePath?: string
/** Initial status for the task's history item (e.g., "active" for child tasks) */
initialStatus?: "active" | "delegated" | "completed"
/**
* Optional isolated task context containing mode, API config, and permissions.
* When provided, the task uses this context instead of reading from the provider.
* This is the foundation for Phase 3a task isolation -- tasks that carry their
* own context can eventually run concurrently without shared state conflicts.
*
* If not provided, the task falls back to the existing provider.getState() behavior.
*/
taskContext?: TaskContext
}
export class Task extends EventEmitter<TaskEvents> implements TaskLike {
@ -172,6 +182,15 @@ export class Task extends EventEmitter<TaskEvents> implements TaskLike {
readonly taskNumber: number
readonly workspacePath: string
/**
* Isolated task context carrying mode, API config, and permission boundaries.
* When set, the task uses this context instead of reading shared provider state.
* This is the foundation for concurrent task execution in later phases.
*
* @see TaskContext in @roo-code/types
*/
readonly taskContext?: TaskContext
/**
* The mode associated with this task. Persisted across sessions
* to maintain user context when reopening tasks from history.
@ -430,6 +449,7 @@ export class Task extends EventEmitter<TaskEvents> implements TaskLike {
initialTodos,
workspacePath,
initialStatus,
taskContext,
}: TaskOptions) {
super()
@ -491,6 +511,7 @@ export class Task extends EventEmitter<TaskEvents> implements TaskLike {
this.parentTask = parentTask
this.taskNumber = taskNumber
this.initialStatus = initialStatus
this.taskContext = taskContext
this.assistantMessageParser = undefined
@ -544,6 +565,14 @@ export class Task extends EventEmitter<TaskEvents> implements TaskLike {
this._taskApiConfigName = historyItem.apiConfigName
this.taskModeReady = Promise.resolve()
this.taskApiConfigReady = Promise.resolve()
} else if (taskContext) {
// Phase 3a: Use isolated TaskContext instead of reading from provider state.
// This allows the task to carry its own mode and API config snapshot,
// independent of the provider's shared mutable state.
this._taskMode = taskContext.mode || defaultModeSlug
this._taskApiConfigName = taskContext.apiConfigName ?? "default"
this.taskModeReady = Promise.resolve()
this.taskApiConfigReady = Promise.resolve()
} else {
this._taskMode = undefined
this._taskApiConfigName = undefined

View file

@ -0,0 +1,63 @@
import { type TaskContext, type TaskPermissions, mergePermissions } from "@roo-code/types"
import { defaultModeSlug } from "../../shared/modes"
import type { ClineProvider } from "../webview/ClineProvider"
/**
* Build a TaskContext from the current provider state.
*
* This factory snapshots the provider's current mode and API config
* into an immutable TaskContext that a child task can carry independently.
* This is the key enabler for Phase 3a: tasks no longer need to reach
* back into the provider for their mode/config during execution.
*
* @param provider - The ClineProvider to snapshot state from
* @param overrides - Optional overrides (e.g., mode from new_task tool)
* @returns A TaskContext snapshot
*/
export async function buildTaskContext(
provider: ClineProvider,
overrides?: Partial<TaskContext>,
): Promise<TaskContext> {
const state = await provider.getState()
const context: TaskContext = {
mode: overrides?.mode ?? state?.mode ?? defaultModeSlug,
apiConfigName: overrides?.apiConfigName ?? state?.currentApiConfigName ?? "default",
permissions: overrides?.permissions,
inheritSkills: overrides?.inheritSkills ?? true,
skillOverrides: overrides?.skillOverrides,
workspacePath: overrides?.workspacePath,
parentTaskId: overrides?.parentTaskId,
rootTaskId: overrides?.rootTaskId,
}
return context
}
/**
* Build a TaskContext for a child task, inheriting from a parent context
* and applying any child-specific overrides.
*
* Permission merging follows the "most restrictive" principle:
* the child's effective permissions are the intersection of the parent's
* permissions and any child-specific permissions.
*
* @param parentContext - The parent task's context
* @param childOverrides - Child-specific overrides
* @returns A new TaskContext for the child task
*/
export function buildChildTaskContext(parentContext: TaskContext, childOverrides: Partial<TaskContext>): TaskContext {
const mergedPermissions = mergePermissions(parentContext.permissions, childOverrides.permissions)
return {
mode: childOverrides.mode ?? parentContext.mode,
apiConfigName: childOverrides.apiConfigName ?? parentContext.apiConfigName,
permissions: mergedPermissions,
inheritSkills: childOverrides.inheritSkills ?? parentContext.inheritSkills,
skillOverrides: childOverrides.skillOverrides ?? parentContext.skillOverrides,
workspacePath: childOverrides.workspacePath ?? parentContext.workspacePath,
parentTaskId: childOverrides.parentTaskId,
rootTaskId: childOverrides.rootTaskId ?? parentContext.rootTaskId,
}
}

View file

@ -83,9 +83,10 @@ import { ContextProxy } from "../config/ContextProxy"
import { ProviderSettingsManager } from "../config/ProviderSettingsManager"
import { CustomModesManager } from "../config/CustomModesManager"
import { Task } from "../task/Task"
import { buildTaskContext } from "../task/TaskContextBuilder"
import { webviewMessageHandler } from "./webviewMessageHandler"
import type { ClineMessage, TodoItem, SubtaskQueueItem } from "@roo-code/types"
import type { ClineMessage, TodoItem, SubtaskQueueItem, TaskPermissions } from "@roo-code/types"
import { readApiMessages, saveApiMessages, saveTaskMessages, TaskHistoryStore } from "../task-persistence"
import { readTaskMessages } from "../task-persistence/taskMessages"
import { getNonce } from "./getNonce"
@ -3018,8 +3019,10 @@ export class ClineProvider
initialTodos: TodoItem[]
mode: string
subtaskQueue?: SubtaskQueueItem[]
/** Optional permission boundaries for the child task (Phase 3a) */
permissions?: TaskPermissions
}): Promise<Task> {
const { parentTaskId, message, initialTodos, mode, subtaskQueue } = params
const { parentTaskId, message, initialTodos, mode, subtaskQueue, permissions } = params
// Metadata-driven delegation is always enabled
@ -3095,24 +3098,35 @@ export class ClineProvider
)
}
// 4) Create child as sole active (parent reference preserved for lineage)
// 4) Build an isolated TaskContext for the child (Phase 3a).
// This snapshots mode, API config, and permission boundaries so the child
// task carries its own context instead of reading shared provider state.
const childTaskContext = await buildTaskContext(this, {
mode,
permissions,
parentTaskId,
rootTaskId: parent.rootTaskId ?? parent.taskId,
})
// 5) Create child as sole active (parent reference preserved for lineage)
// Pass initialStatus: "active" to ensure the child task's historyItem is created
// with status from the start, avoiding race conditions where the task might
// call attempt_completion before status is persisted separately.
//
// Pass startTask: false to prevent the child from beginning its task loop
// (and writing to globalState via saveClineMessages → updateTaskHistory)
// before we persist the parent's delegation metadata in step 5.
// Without this, the child's fire-and-forget startTask() races with step 5,
// before we persist the parent's delegation metadata in step 6.
// Without this, the child's fire-and-forget startTask() races with step 6,
// and the last writer to globalState overwrites the other's changes—
// causing the parent's delegation fields to be lost.
const child = await this.createTask(message, undefined, parent as any, {
initialTodos,
initialStatus: "active",
startTask: false,
taskContext: childTaskContext,
})
// 5) Persist parent delegation metadata BEFORE the child starts writing.
// 6) Persist parent delegation metadata BEFORE the child starts writing.
try {
const { historyItem } = await this.getTaskWithId(parentTaskId)
const childIds = Array.from(new Set([...(historyItem.childIds ?? []), child.taskId]))
@ -3135,10 +3149,10 @@ export class ClineProvider
)
}
// 6) Start the child task now that parent metadata is safely persisted.
// 7) Start the child task now that parent metadata is safely persisted.
child.start()
// 7) Emit TaskDelegated (provider-level)
// 8) Emit TaskDelegated (provider-level)
try {
this.emit(RooCodeEventName.TaskDelegated, parentTaskId, child.taskId)
} catch {