mirror of
https://github.com/RooVetGit/Roo-Code.git
synced 2026-08-28 05:27:24 +00:00
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:
parent
8922418600
commit
d5b45ff368
9 changed files with 608 additions and 8 deletions
126
packages/types/src/__tests__/task-context.spec.ts
Normal file
126
packages/types/src/__tests__/task-context.spec.ts
Normal 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([])
|
||||
})
|
||||
})
|
||||
|
|
@ -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"
|
||||
|
|
|
|||
227
packages/types/src/task-context.ts
Normal file
227
packages/types/src/task-context.ts
Normal 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]))
|
||||
}
|
||||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
130
src/__tests__/task-context-builder.spec.ts
Normal file
130
src/__tests__/task-context-builder.spec.ts
Normal 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)
|
||||
})
|
||||
})
|
||||
|
|
@ -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
|
||||
|
|
|
|||
63
src/core/task/TaskContextBuilder.ts
Normal file
63
src/core/task/TaskContextBuilder.ts
Normal 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,
|
||||
}
|
||||
}
|
||||
|
|
@ -80,9 +80,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 } from "@roo-code/types"
|
||||
import type { ClineMessage, TodoItem, TaskPermissions } from "@roo-code/types"
|
||||
import { readApiMessages, saveApiMessages, saveTaskMessages, TaskHistoryStore } from "../task-persistence"
|
||||
import { readTaskMessages } from "../task-persistence/taskMessages"
|
||||
import { getNonce } from "./getNonce"
|
||||
|
|
@ -2785,8 +2786,10 @@ export class ClineProvider
|
|||
message: string
|
||||
initialTodos: TodoItem[]
|
||||
mode: string
|
||||
/** Optional permission boundaries for the child task (Phase 3a) */
|
||||
permissions?: TaskPermissions
|
||||
}): Promise<Task> {
|
||||
const { parentTaskId, message, initialTodos, mode } = params
|
||||
const { parentTaskId, message, initialTodos, mode, permissions } = params
|
||||
|
||||
// Metadata-driven delegation is always enabled
|
||||
|
||||
|
|
@ -2862,24 +2865,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]))
|
||||
|
|
@ -2899,10 +2913,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 {
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue