From 164c4e0fc273456c177100d2432fccf16bec260b Mon Sep 17 00:00:00 2001 From: shuv1337 Date: Mon, 9 Nov 2026 12:40:08 -0800 Subject: [PATCH 01/12] feat(server): track delegated tasks across local projects --- .../src/mcp/OrchestratorMcpService.test.ts | 53 +++ apps/server/src/mcp/OrchestratorMcpService.ts | 135 ++++++- .../src/mcp/toolkits/project/handlers.test.ts | 7 + .../src/mcp/toolkits/project/handlers.ts | 6 +- .../src/orchestration-v2/Orchestrator.ts | 11 +- .../orchestration-v2/ThreadLaunchService.ts | 2 +- apps/server/src/peer/RemoteDelegation.test.ts | 358 +++++++++++++++++- apps/server/src/peer/RemoteDelegation.ts | 185 +++++++-- packages/contracts/src/orchestrationV2.ts | 12 +- 9 files changed, 697 insertions(+), 72 deletions(-) diff --git a/apps/server/src/mcp/OrchestratorMcpService.test.ts b/apps/server/src/mcp/OrchestratorMcpService.test.ts index ab294f157f0b2..6d3474bae10f3 100644 --- a/apps/server/src/mcp/OrchestratorMcpService.test.ts +++ b/apps/server/src/mcp/OrchestratorMcpService.test.ts @@ -3496,6 +3496,59 @@ describe("OrchestratorMcpService provider resolution", () => { }), ); + it.effect( + "rejects cross-project worktree isolation before launch when the target has no commit", + () => + Effect.gen(function* () { + const otherProjectId = ProjectId.make("project:empty"); + const dependencies = Layer.mergeAll( + NodeServices.layer, + NodeCrypto.layer, + Layer.mock(ThreadManagementService.ThreadManagementService)({ + getThreadRecords: () => Effect.succeed(parentProjection([])), + getThreadShell: () => Effect.succeed(null), + }), + Layer.mock(ProjectService.ProjectService)({ + getById: () => Effect.succeedSome({ workspaceRoot: "/empty-repo" } as never), + }), + Layer.mock(GitVcsDriver.GitVcsDriver)({ + resolveCommit: ({ cwd, revision }) => { + assert.equal(cwd, "/empty-repo"); + assert.equal(revision, "HEAD"); + return Effect.fail( + new GitCommandError({ + operation: "GitVcsDriver.resolveCommit", + command: "git", + cwd, + detail: "No commit", + }), + ); + }, + }), + Layer.mock(ThreadLaunchService.ThreadLaunchService)({}), + providerRegistryLayer([]), + adapterRegistryLayer([]), + ServerSettings.layerTest(), + Layer.mock(SecretRequests.SecretRequests)({}), + Layer.mock(RemoteDelegation.RemoteDelegation)({}), + Layer.mock(ScheduledTaskService.ScheduledTaskService)({}), + ); + const error = yield* OrchestratorMcpService.OrchestratorMcpService.pipe( + Effect.flatMap((service) => + service.delegateTask(scope, { + task: "Work elsewhere.", + target: { projectId: otherProjectId }, + isolation: "worktree", + }), + ), + Effect.flip, + Effect.provide(OrchestratorMcpService.layer.pipe(Layer.provide(dependencies))), + ); + assert.equal(error.code, "invalid_request"); + assert.include(error.message, "target project's repository"); + }), + ); + it.effect("accepts the inherited options it reports back as a delegate target", () => Effect.gen(function* () { // t3_thread_launch keeps a model selection as given, so a thread can hold an option its model lacks. diff --git a/apps/server/src/mcp/OrchestratorMcpService.ts b/apps/server/src/mcp/OrchestratorMcpService.ts index 9d96172ade57b..79daada000d1a 100644 --- a/apps/server/src/mcp/OrchestratorMcpService.ts +++ b/apps/server/src/mcp/OrchestratorMcpService.ts @@ -1788,8 +1788,8 @@ const make = Effect.gen(function* () { }); /** - * A task that runs in a linked environment, answered from its row here: - * the follower records its result once its thread there ends. + * An ordinary child thread, local or remote, answered from the parent's task row. + * The follower records its result once the child's delegated work settles. */ const readRemoteTask = Effect.fn("OrchestratorMcpService.readRemoteTask")(function* ( scope: McpThreadInvocationScope, @@ -1851,11 +1851,15 @@ const make = Effect.gen(function* () { } return { taskId: task.id, - childThreadId: null, + childThreadId: + task.remoteChild?.environmentId === scope.environmentId ? task.remoteChild.threadId : null, ...(task.remoteChild === undefined ? {} : { remoteChild: task.remoteChild, link: formatThreadLink(task.remoteChild) }), - childRunId: null, + childRunId: + task.remoteChild?.environmentId === scope.environmentId + ? (task.remoteChild.runId ?? null) + : null, childNodeId: task.id, status, workState: terminal ? "result_available" : "working", @@ -1893,8 +1897,8 @@ const make = Effect.gen(function* () { return Option.some(initial); } // One stream per event type, so transcript events never fill a buffer. - // A remote task has no child here; its completion lands on the parent. - const childThreadId = initial.childThreadId; + // Ordinary child threads report completion on the parent through their follower. + const childThreadId = initial.remoteChild === undefined ? initial.childThreadId : null; const wakeEvents = childThreadId === null ? TASK_WAKE_EVENTS.filter(({ thread }) => thread === "parent") @@ -2405,7 +2409,7 @@ const make = Effect.gen(function* () { runtimeMode: limits.runtimeMode, interactionMode: limits.interactionMode, providers: providers.map((provider) => { - // Snapshots list instances any project enables; delegate_task checks the caller's project. + // Capabilities describe this project; delegate_task checks the selected target project. const enabledForCaller = parent === undefined || resolveProjectProviderInstanceEnabled( @@ -2631,9 +2635,116 @@ const make = Effect.gen(function* () { input.target?.projectId !== undefined && input.target.projectId !== parent.thread.projectId ) { - return yield* failure( - "invalid_request", - "A local task runs in this thread's project. Omit target.projectId, or pass target.environmentId to run it in that environment's project.", + const parentRun = ThreadManagementService.latestActiveRun(parent); + if ( + parentRun?.rootNodeId == null || + parentRun.providerInstanceId !== scope.thread.providerInstanceId + ) { + return yield* failure( + "parent_not_active", + "Delegated tasks require an active run owned by this MCP provider session.", + ); + } + const projectId = input.target.projectId; + const key = yield* requestKey(input.clientRequestId); + const commandId = stableCommandId({ + scope, + requestKey: key, + operation: "cross-project-launch", + }); + const childThreadId = IdAllocator.derive.delegatedTaskThread({ commandId }); + const existing = yield* threadManagement + .getThreadShell(childThreadId) + .pipe(Effect.mapError(threadManagementFailure)); + let workspaceStrategy: OrchestrationV2ThreadLaunchWorkspaceStrategy = { type: "root" }; + if (existing === null) { + const project = yield* requireProject(projectId); + if (input.isolation === "worktree") { + const base = yield* gitDriver + .resolveCommit({ + cwd: project.workspaceRoot, + revision: "HEAD", + }) + .pipe( + Effect.mapError(() => + failure( + "invalid_request", + "Isolation worktree requires a commit in the target project's repository.", + ), + ), + ); + workspaceStrategy = { + type: "worktree", + baseRef: base.commitSha, + startFromOrigin: false, + }; + } + } + const target = + existing === null + ? yield* resolveTargetRechecking({ + parent: { thread: { ...parent.thread, projectId } }, + target: input.target, + providers: yield* loadProviders, + }) + : { modelSelection: existing.modelSelection, supportedRuntimeModes: undefined }; + const runtimeMode = yield* resolveRuntimeMode( + parent.thread.runtimeMode, + input.runtimeMode, + target, + ); + const interactionMode = yield* resolveInteractionMode( + parent.thread.interactionMode, + input.interactionMode, + ); + const launched = yield* threadLaunch + .launch({ + commandId, + threadId: childThreadId, + projectId, + title: input.title ?? parent.thread.title, + modelSelection: target.modelSelection, + ...(existing !== null || namedInstance(input.target) === undefined + ? {} + : { namedInstance: namedInstance(input.target)! }), + runtimeMode, + interactionMode, + workspaceStrategy, + initialMessage: { + text: taskPrompt(input), + attachments: [], + senderThreadId: parent.thread.id, + }, + createdBy: "agent", + creationSource: "mcp", + ...(parent.thread.linkOrigin === undefined + ? {} + : { linkOrigin: parent.thread.linkOrigin }), + delegatedFrom: { + environmentId: scope.environmentId, + threadId: parent.thread.id, + title: parent.thread.title, + }, + }) + .pipe(Effect.mapError(threadManagementFailure)); + const run = launched.projection.runs[0]; + const { taskId } = yield* remoteDelegation.record(scope, { + ...input, + clientRequestId: key, + parentRunId: parentRun.id, + parentNodeId: parentRun.rootNodeId, + modelSelection: launched.projection.thread.modelSelection, + remoteChild: { + environmentId: scope.environmentId, + threadId: launched.threadId, + label: "Local", + ...(run === undefined ? {} : { runId: run.id }), + }, + }); + return withResolvedInstance( + yield* input.mode === "wait" + ? awaitTask(scope, taskId, input.timeoutMs) + : readTask(scope, taskId, false, true), ); } const parentRun = parent.runs @@ -2845,8 +2956,8 @@ const make = Effect.gen(function* () { const scope = yield* requireThreadScope(callerScope, "task_cancel"); const current = yield* readTask(scope, input.taskId); const childThreadId = current.childThreadId; - if (childThreadId === null) { - // The task runs in a linked environment: its thread there is stopped through the link. + if (current.remoteChild !== undefined || childThreadId === null) { + // Ordinary child threads share the follower lifecycle, whether local or remote. const subagents = yield* loadTasks(scope.thread.threadId, [input.taskId]); const remoteTask = subagents.find( (task) => diff --git a/apps/server/src/mcp/toolkits/project/handlers.test.ts b/apps/server/src/mcp/toolkits/project/handlers.test.ts index 976218f76dd87..5c1b559dde771 100644 --- a/apps/server/src/mcp/toolkits/project/handlers.test.ts +++ b/apps/server/src/mcp/toolkits/project/handlers.test.ts @@ -195,6 +195,7 @@ it.effect( readonly overrideModel?: ModelSelection; readonly hostedAppUrl?: string; readonly refusedByCeiling?: true; + readonly delegatedFrom?: ThreadLaunch.ThreadLaunchInput["delegatedFrom"]; }, ) { const { targeted, overrideModel, hostedAppUrl, refusedByCeiling, ...input } = params; @@ -223,7 +224,13 @@ it.effect( const belowCeiling = yield* launchWith("full-access", "approval-required", { title: "Fix", message: "Fix the bug", + delegatedFrom: { + environmentId: EnvironmentId.make("forged"), + threadId: ThreadId.make("forged-parent"), + title: "Not the caller", + }, }); + expect(belowCeiling.launched[0]?.delegatedFrom).toBeUndefined(); expect(belowCeiling.result).toMatchObject({ projectId: belowCeiling.projectId, modelSelection: belowCeiling.modelSelection, diff --git a/apps/server/src/mcp/toolkits/project/handlers.ts b/apps/server/src/mcp/toolkits/project/handlers.ts index fadbe6166b056..af0887bf56dcb 100644 --- a/apps/server/src/mcp/toolkits/project/handlers.ts +++ b/apps/server/src/mcp/toolkits/project/handlers.ts @@ -290,8 +290,10 @@ export const layer = McpToolAccess.toLayer(ProjectToolkit, { createdBy: "agent", creationSource: "mcp", ...(linkOrigin === undefined ? {} : { linkOrigin }), - // Kept only alongside linkOrigin: thread.create drops it otherwise. - ...(input.delegatedFrom === undefined ? {} : { delegatedFrom: input.delegatedFrom }), + // Local task provenance is stamped by delegate_task, never by a caller-supplied launch. + ...(linkOrigin === undefined || input.delegatedFrom === undefined + ? {} + : { delegatedFrom: input.delegatedFrom }), }).pipe( Effect.mapError((error) => { if (error._tag === "AttachmentClaimError") { diff --git a/apps/server/src/orchestration-v2/Orchestrator.ts b/apps/server/src/orchestration-v2/Orchestrator.ts index 55c4ff15c425d..191d4f269fd32 100644 --- a/apps/server/src/orchestration-v2/Orchestrator.ts +++ b/apps/server/src/orchestration-v2/Orchestrator.ts @@ -3006,16 +3006,9 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio worktreePath: command.worktreePath, activeProviderThreadId: null, ...(command.linkOrigin === undefined ? {} : { linkOrigin: command.linkOrigin }), - // Only stamped work names a remote parent, so a client cannot claim one. - ...(command.linkOrigin === undefined || command.delegatedFrom === undefined - ? {} - : { delegatedFrom: command.delegatedFrom }), + // Client commands lose delegatedFrom in withCreationProvenance; server launches keep it. + ...(command.delegatedFrom === undefined ? {} : { delegatedFrom: command.delegatedFrom }), pullRequests: [], - ...(command.linkOrigin === undefined ? {} : { linkOrigin: command.linkOrigin }), - // Only stamped work names a remote parent, so a client cannot claim one. - ...(command.linkOrigin === undefined || command.delegatedFrom === undefined - ? {} - : { delegatedFrom: command.delegatedFrom }), lineage: { parentThreadId: null, relationshipToParent: null, diff --git a/apps/server/src/orchestration-v2/ThreadLaunchService.ts b/apps/server/src/orchestration-v2/ThreadLaunchService.ts index 28e6e057e513f..86e0a8c28dd25 100644 --- a/apps/server/src/orchestration-v2/ThreadLaunchService.ts +++ b/apps/server/src/orchestration-v2/ThreadLaunchService.ts @@ -103,7 +103,7 @@ export interface ThreadLaunchInput { readonly creationSource: OrchestrationV2CreationSource; /** The linked environment this launch is for, if one asked for it. */ readonly linkOrigin?: OrchestrationV2LinkOrigin; - /** The parent there, when that environment's agent delegated this as a task. */ + /** The parent of a cross-project or cross-environment delegated task. */ readonly delegatedFrom?: OrchestrationV2DelegatedFrom; } diff --git a/apps/server/src/peer/RemoteDelegation.test.ts b/apps/server/src/peer/RemoteDelegation.test.ts index 15d0ba0a81280..6f8ecdafb3771 100644 --- a/apps/server/src/peer/RemoteDelegation.test.ts +++ b/apps/server/src/peer/RemoteDelegation.test.ts @@ -25,6 +25,7 @@ import * as Context from "effect/Context"; import * as DateTime from "effect/DateTime"; import * as Deferred from "effect/Deferred"; import * as Effect from "effect/Effect"; +import * as Fiber from "effect/Fiber"; import * as Layer from "effect/Layer"; import * as Option from "effect/Option"; import * as Queue from "effect/Queue"; @@ -66,6 +67,12 @@ import * as PeerForwarding from "./PeerForwarding.ts"; import * as PeerLinks from "./PeerLinks.ts"; import { descriptorOf, layerLinkingEnvironment, linkTo, servePeer } from "./PeerLinks.testkit.ts"; import * as RemoteDelegation from "./RemoteDelegation.ts"; +import * as IdAllocator from "@t3tools/provider-core/server/IdAllocator"; +import * as WorktreeSetupTracker from "../project/WorktreeSetupTracker.ts"; +import * as ProjectSetupScriptRunner from "../project/ProjectSetupScriptRunner.ts"; +import * as TerminalManager from "../terminal/Manager.ts"; +import * as GitWorkflow from "../git/GitWorkflowService.ts"; +import * as TextGeneration from "../textGeneration/TextGeneration.ts"; import * as PeerLinkRequests from "./PeerLinkRequests.ts"; // The laptop's agent delegates a task to the box through a link. The box is @@ -609,6 +616,73 @@ const makeLaptop = ( Layer.provide(CommandReceiptStore.layer.pipe(Layer.provide(layerDatabase))), Layer.provideMerge(Layer.merge(layerOrchestrator, ServerSettings.layerTest())), ); + const localProjects = Layer.mock(ProjectService.ProjectService)({ + getById: (id) => + Effect.succeed( + id === boxProject + ? Option.some(boxProjects.at(-1)!) + : id === ProjectId.make("project:laptop") + ? Option.some({ + ...boxProjects.at(-1)!, + id, + title: "Laptop", + workspaceRoot: "/home/me/app", + }) + : Option.none(), + ), + }); + const localProviders = Layer.mock(ProviderRegistry.ProviderRegistry)({ + getProviders: Effect.succeed([boxPoolMember(instanceId, 0)]), + refreshInstance: () => Effect.succeed([boxPoolMember(instanceId, 0)]), + }); + const worktrees: Array<{ cwd: string; refName: string }> = []; + const localLaunch = ThreadLaunch.layer.pipe( + Layer.provide(layerThreads), + Layer.provide(CommandReceiptStore.layer.pipe(Layer.provide(layerDatabase))), + Layer.provide(IdAllocator.layer), + Layer.provide(localProjects), + Layer.provide(localProviders), + Layer.provide(ServerSettings.layerTest()), + Layer.provide(WorktreeSetupTracker.layer), + Layer.provide( + Layer.mock(ProjectCloneTracker.ProjectCloneTracker)({ get: () => Effect.succeed(null) }), + ), + Layer.provide(Layer.mock(TerminalManager.TerminalManager)({ close: () => Effect.void })), + Layer.provide( + Layer.mock(GitWorkflow.GitWorkflowService)({ + hasCommit: () => Effect.succeed(false), + createWorktree: (input) => + Effect.sync(() => { + worktrees.push(input); + return { + worktree: { + path: "/srv/app-worktrees/local-child", + refName: input.newRefName ?? input.refName, + }, + }; + }), + renameBranch: (input) => Effect.succeed({ branch: input.newBranch }), + }), + ), + Layer.provide( + Layer.mock(ProjectSetupScriptRunner.ProjectSetupScriptRunner)({ + runForThread: () => Effect.succeed({ status: "no-script" }), + }), + ), + Layer.provide( + Layer.mock(TextGeneration.TextGeneration)({ + generateThreadTitle: () => Effect.succeed({ title: "Local child" }), + generateBranchName: () => Effect.succeed({ branch: "local-child" }), + }), + ), + Layer.provide( + Layer.mock(ManagedProjectFolders.ManagedProjectFolders)({ + namedProjectsRoot: "/projects", + isScratchProject: () => Effect.succeed(false), + folderForThread: () => Effect.succeedNone, + }), + ), + ); const layerHere = McpHttpServer.layerOrchestratorToolkit.pipe( Layer.provideMerge(McpHttpServer.layerInProcessMcpServer), Layer.provideMerge( @@ -640,19 +714,28 @@ const makeLaptop = ( Layer.provide(Layer.mock(PeerLinkRequests.PeerLinkRequests)({})), Layer.provide(NodeCrypto.layer), Layer.provideMerge(layerThreads), - Layer.provide(Layer.mock(ProviderRegistry.ProviderRegistry)({})), - Layer.provide(Layer.mock(ProviderAdapterRegistry.ProviderAdapterRegistryV2)({})), + Layer.provide(localProviders), + Layer.provide(ProviderAdapterRegistry.layerFromAdapters([adapter])), Layer.provide(Layer.mock(ScheduledTaskService.ScheduledTaskService)({})), - Layer.provide(Layer.mock(ProjectService.ProjectService)({})), + Layer.provide(localProjects), Layer.provide(Layer.mock(ProjectCloneTracker.ProjectCloneTracker)({})), Layer.provide(Layer.mock(SecretRequests.SecretRequests)({})), - Layer.provide(Layer.mock(ThreadLaunch.ThreadLaunchService)({})), - Layer.provide(Layer.mock(GitVcsDriver.GitVcsDriver)({})), + Layer.provide(localLaunch), + Layer.provide( + Layer.mock(GitVcsDriver.GitVcsDriver)({ + resolveCommit: ({ cwd, revision }) => { + expect(cwd).toBe("/srv/app"); + expect(revision).toBe("HEAD"); + return Effect.succeed({ commitSha: "target-head" }); + }, + }), + ), Layer.fresh, ); const here = yield* Layer.build(layerHere); const server = Context.get(here, McpServer.McpServer); return { + worktrees, links: Context.get(linking, PeerLinks.PeerLinks), settings: Context.get(linking, ServerSettings.ServerSettingsService), orchestrator: Context.get(here, Orchestrator.OrchestratorV2), @@ -838,6 +921,267 @@ const taskResult = (laptopEnv: Effect.Success>, ta ), ); +const finishLocalChild = Effect.fn("test.finishLocalChild")(function* ( + a: Effect.Success>, + threadId: ThreadId, + text: string, +) { + const child = yield* a.orchestrator.getThreadProjection(threadId); + const run = child.runs[0]!; + const now = yield* DateTime.now; + yield* a.sink.write({ + commandId: CommandId.make(`finish:${threadId}`), + events: [ + { + id: EventId.make(`reply:${threadId}`), + type: "message.updated", + threadId, + runId: run.id, + occurredAt: now, + payload: { + ...child.messages[0]!, + id: MessageId.make(`reply:${threadId}`), + role: "assistant", + text, + }, + }, + { + id: EventId.make(`finished:${threadId}`), + type: "run.updated", + threadId, + runId: run.id, + occurredAt: now, + payload: { ...run, status: "completed", completedAt: now }, + }, + ], + }); +}); + +it.effect.each(["shared", "worktree"] as const)( + "a local cross-project task with %s isolation launches once and wakes its parent", + (isolation) => + Effect.scoped( + Effect.gen(function* () { + const offers = yield* SubscriptionRef.make< + ReadonlyArray + >([]); + const database = yield* SqlitePersistence.layerMemory.pipe(Layer.build); + const a = yield* makeLaptop(database, offers); + yield* seedParent(a); + yield* a.settings.updateSettings({ linkedEnvironmentsEnabled: false }); + const input = { + task: "Work in the other local project.", + target: { projectId: boxProject }, + isolation, + clientRequestId: "local-other-project", + }; + const first = yield* a.call("delegate_task", input); + expect(first.isError, JSON.stringify(first.content)).toBe(false); + const result = first.structuredContent as { taskId: NodeId; childThreadId: ThreadId }; + if (isolation === "worktree") { + yield* a.orchestrator + .streamStoredEventsFrom({ + threadId: result.childThreadId, + afterSequence: 0, + eventType: "run.updated", + }) + .pipe( + Stream.filter( + (stored) => + stored.event.type === "run.updated" && + stored.event.payload.status !== "preparing", + ), + Stream.runHead, + ); + expect(a.worktrees).toMatchObject([{ cwd: "/srv/app", refName: "target-head" }]); + } + const child = yield* a.orchestrator.getThreadProjection(result.childThreadId); + expect(child.thread.worktreePath).toBe( + isolation === "worktree" ? "/srv/app-worktrees/local-child" : null, + ); + expect(child.thread.projectId).toBe(boxProject); + expect(child.thread.lineage.parentThreadId).toBeNull(); + expect(child.thread.delegatedFrom).toEqual({ + environmentId: laptop.environmentId, + threadId: parentThreadId, + title: "Laptop parent", + }); + const retried = yield* a.call("delegate_task", input); + expect(retried.structuredContent).toMatchObject(result); + expect(a.worktrees).toHaveLength(isolation === "worktree" ? 1 : 0); + expect((yield* a.orchestrator.getThreadProjection(parentThreadId)).subagents).toHaveLength( + 1, + ); + expect(first.structuredContent).toMatchObject({ + link: ``, + }); + yield* finishLocalChild(a, result.childThreadId, "Local work finished."); + expect(yield* taskResult(a, result.taskId)).toMatchObject({ + status: "completed", + result: "Local work finished.", + }); + const offered = yield* SubscriptionRef.changes(offers).pipe( + Stream.filter((seen) => seen.some((offer) => offer.threadId === parentThreadId)), + Stream.runHead, + ); + expect(Option.isSome(offered)).toBe(true); + expect( + (yield* a.call("task_status", { taskId: result.taskId })).structuredContent, + ).toMatchObject({ + status: "completed", + summary: "Local work finished.", + childThreadId: result.childThreadId, + }); + const after = yield* a.orchestrator.getThreadProjection(result.childThreadId); + expect(after.runs).toHaveLength(1); + expect(after.messages.filter((message) => message.role === "user")).toHaveLength(1); + }).pipe(Effect.provide(NodeServices.layer)), + ), +); + +it.effect("follows a local cross-project task again after a restart without a peer link", () => + Effect.scoped( + Effect.gen(function* () { + const offers = yield* SubscriptionRef.make< + ReadonlyArray + >([]); + const database = yield* SqlitePersistence.layerMemory.pipe(Layer.build); + const delegated = yield* Effect.scoped( + Effect.gen(function* () { + const before = yield* makeLaptop(database, offers); + yield* seedParent(before); + const result = yield* before.call("delegate_task", { + task: "Work locally.", + target: { projectId: boxProject }, + clientRequestId: "restart-local", + }); + expect(result.isError).toBe(false); + return result.structuredContent as { taskId: NodeId; childThreadId: ThreadId }; + }), + ); + const after = yield* makeLaptop(database, offers); + yield* finishLocalChild(after, delegated.childThreadId, "Finished after restart."); + yield* after.remote.start(); + expect(yield* taskResult(after, delegated.taskId)).toMatchObject({ + status: "completed", + result: "Finished after restart.", + }); + }).pipe(Effect.provide(NodeServices.layer)), + ), +); + +it.effect("local cross-project wait returns the result without an async wake", () => + Effect.scoped( + Effect.gen(function* () { + const offers = yield* SubscriptionRef.make< + ReadonlyArray + >([]); + const a = yield* makeLaptop(yield* SqlitePersistence.layerMemory.pipe(Layer.build), offers); + yield* seedParent(a); + const waiting = yield* a + .call("delegate_task", { + task: "Work locally.", + target: { projectId: boxProject, environmentId: laptop.environmentId }, + mode: "wait", + clientRequestId: "local-wait", + }) + .pipe(Effect.forkScoped); + const task = yield* a.orchestrator + .streamStoredEventsFrom({ + threadId: parentThreadId, + afterSequence: 0, + eventType: "subagent.updated", + }) + .pipe( + Stream.filter( + (stored) => + stored.event.type === "subagent.updated" && + stored.event.payload.remoteChild !== undefined, + ), + Stream.runHead, + ); + expect(Option.isSome(task)).toBe(true); + if (Option.isNone(task) || task.value.event.type !== "subagent.updated") return; + const childThreadId = task.value.event.payload.remoteChild!.threadId; + yield* finishLocalChild(a, childThreadId, "Waited result."); + const result = yield* Fiber.join(waiting); + expect(result.isError).toBe(false); + expect(result.structuredContent).toMatchObject({ + childThreadId, + status: "completed", + summary: "Waited result.", + waitTimedOut: false, + }); + expect(yield* SubscriptionRef.get(offers)).toEqual([]); + }).pipe(Effect.provide(NodeServices.layer)), + ), +); + +it.effect("local cross-project cancel stops the thread and retries preserve its result", () => + Effect.scoped( + Effect.gen(function* () { + const offers = yield* SubscriptionRef.make< + ReadonlyArray + >([]); + const a = yield* makeLaptop(yield* SqlitePersistence.layerMemory.pipe(Layer.build), offers); + yield* seedParent(a); + const result = yield* a.call("delegate_task", { + task: "Work locally.", + target: { projectId: boxProject }, + clientRequestId: "local-cancel", + }); + expect(result.isError).toBe(false); + const { taskId, childThreadId } = result.structuredContent as { + taskId: NodeId; + childThreadId: ThreadId; + }; + const cancel = { taskId, clientRequestId: "stop-local", reason: "No longer needed" }; + expect((yield* a.call("task_cancel", cancel)).isError).toBe(false); + expect((yield* a.call("task_cancel", cancel)).isError).toBe(false); + expect((yield* a.call("task_status", { taskId })).structuredContent).toMatchObject({ + status: "cancelled", + summary: "Cancelled: No longer needed", + }); + const child = yield* a.orchestrator.getThreadProjection(childThreadId); + expect(child.runs.every((run) => run.status === "interrupted")).toBe(true); + }).pipe(Effect.provide(NodeServices.layer)), + ), +); + +it.effect( + "local delegation rejects a missing project and keeps same-project children in the run tree", + () => + Effect.scoped( + Effect.gen(function* () { + const offers = yield* SubscriptionRef.make< + ReadonlyArray + >([]); + const a = yield* makeLaptop(yield* SqlitePersistence.layerMemory.pipe(Layer.build), offers); + yield* seedParent(a); + const missing = yield* a.call("delegate_task", { + task: "Work.", + target: { projectId: "project:missing" }, + }); + expect(missing.isError).toBe(true); + expect(JSON.stringify(missing.content)).toContain("project:missing"); + expect((yield* a.orchestrator.getThreadProjection(parentThreadId)).subagents).toHaveLength( + 0, + ); + const same = yield* a.call("delegate_task", { + task: "Work here.", + target: { projectId: "project:laptop" }, + clientRequestId: "same-project", + }); + expect(same.isError, JSON.stringify(same.content)).toBe(false); + const result = same.structuredContent as { childThreadId: ThreadId }; + expect(same.structuredContent).not.toHaveProperty("remoteChild"); + const child = yield* a.orchestrator.getThreadProjection(result.childThreadId); + expect(child.thread.projectId).toBe("project:laptop"); + expect(child.thread.lineage.parentThreadId).toBe(parentThreadId); + }).pipe(Effect.provide(NodeServices.layer)), + ), +); + it.effect("a task delegated to the box wakes the laptop's parent when it ends there", () => Effect.scoped( Effect.gen(function* () { @@ -1501,9 +1845,7 @@ it.effect.each([false, true])( yield* Deferred.succeed(thread.finished, undefined); expect(yield* taskResult(a, delegated.taskId)).toMatchObject({ status: "failed", - result: ownError - ? error - : "(The thread in the linked environment ended without a reply.)", + result: ownError ? error : "(The delegated thread ended without a reply.)", }); expect(yield* Ref.get(thread.waitedFor)).toEqual([boxRunId]); }), diff --git a/apps/server/src/peer/RemoteDelegation.ts b/apps/server/src/peer/RemoteDelegation.ts index a74f2ab4ac5f5..ef355b9410645 100644 --- a/apps/server/src/peer/RemoteDelegation.ts +++ b/apps/server/src/peer/RemoteDelegation.ts @@ -1,6 +1,8 @@ import { CommandId, type EnvironmentId, + type ModelSelection, + type OrchestratorMcpThreadWaitResult, NodeId, OrchestratorMcpFailure, type OrchestratorMcpDelegateTaskInput, @@ -14,6 +16,7 @@ import { type RunId, type ThreadId, } from "@t3tools/contracts"; +import { subagentResultForRun } from "@t3tools/provider-core/server/subagentProjection"; import * as Context from "effect/Context"; import * as SecureRandom from "effect/crypto/SecureRandom"; import * as Duration from "effect/Duration"; @@ -32,6 +35,8 @@ import { resolveRuntimeMode, taskPrompt, } from "../mcp/delegatedTaskModes.ts"; +import { DispatchModeLimit } from "../orchestration-v2/DispatchModeLimit.ts"; +import { runOutcome } from "../orchestration-v2/RestartBackgroundNote.ts"; import * as ProjectionStore from "../orchestration-v2/ProjectionStore.ts"; import * as ThreadManagement from "../orchestration-v2/ThreadManagementService.ts"; import * as ServerEnvironment from "../environment/ServerEnvironment.ts"; @@ -61,10 +66,9 @@ export const FollowRetrySchedule = Context.Reference> type ThreadScope = McpInvocationContext.McpThreadInvocationScope; /** - * `delegate_task` to a linked environment. The task's child is an ordinary - * thread there, launched through the link; the parent here records the task - * like a local one, without a child thread, and a follower completes it when - * the thread there ends, so the parent wakes as it would for a local child. + * Tracks ordinary child threads outside the parent's run tree, across local + * projects or linked environments. The same durable task and follower wake + * the parent; this environment's children use local thread services. */ export class RemoteDelegation extends Context.Service< RemoteDelegation, @@ -77,6 +81,17 @@ export class RemoteDelegation extends Context.Service< }; }, ) => Effect.Effect<{ readonly taskId: NodeId }, OrchestratorMcpFailure>; + /** Records and follows an ordinary child thread, local or remote. */ + readonly record: ( + scope: ThreadScope, + input: OrchestratorMcpDelegateTaskInput & { + readonly parentRunId: RunId; + readonly parentNodeId: NodeId; + readonly modelSelection: ModelSelection; + readonly remoteChild: OrchestrationV2RemoteTaskChild; + readonly clientRequestId: string; + }, + ) => Effect.Effect<{ readonly taskId: NodeId }, OrchestratorMcpFailure>; /** * Stops the task's thread there, then completes it here as cancelled. A * task that already has its result keeps it; its thread there is still @@ -125,6 +140,17 @@ const make = Effect.gen(function* () { const hereId = yield* ServerEnvironment.ServerEnvironment.pipe( Effect.flatMap((environment) => environment.getEnvironmentId), ); + const localFailure = (error: { readonly _tag: string; readonly message: string }) => + new OrchestratorMcpFailure({ + code: + error._tag === "ThreadManagementThreadNotFoundError" + ? "thread_not_found" + : error._tag === "ThreadManagementRunNotFoundError" + ? "run_not_found" + : "orchestration_error", + message: error.message, + }); + // Followers live as long as this service, so shutting down stops them. const followers = yield* Scope.Scope; @@ -311,9 +337,6 @@ const make = Effect.gen(function* () { Effect.map(Option.getOrUndefined), Effect.orElseSucceed(() => undefined), ); - const commandId = CommandId.make( - `command:mcp:${encodeURIComponent(scope.requestNamespace)}:remote-delegate:${encodeURIComponent(key)}`, - ); const remoteChild: OrchestrationV2RemoteTaskChild = { environmentId: target.environmentId, threadId: launched.threadId, @@ -334,18 +357,35 @@ const make = Effect.gen(function* () { "That environment does not support target.pool yet. Pass target.providerInstanceId there instead.", }); } + return yield* record(scope, { + ...input, + clientRequestId: key, + parentRunId: parentRun.id, + parentNodeId, + modelSelection: launched.modelSelection, + remoteChild, + }); + }, + ); + + const record: RemoteDelegation["Service"]["record"] = Effect.fn("RemoteDelegation.record")( + function* (scope, input) { + const { remoteChild } = input; + const commandId = CommandId.make( + `command:mcp:${encodeURIComponent(scope.requestNamespace)}:remote-delegate:${encodeURIComponent(input.clientRequestId)}`, + ); const recorded = yield* threads .dispatch({ type: "delegated_task.remote.request", commandId, parentThreadId: scope.thread.threadId, - parentRunId: parentRun.id, - parentNodeId, - task, + parentRunId: input.parentRunId, + parentNodeId: input.parentNodeId, + task: taskPrompt(input), ...(input.title === undefined ? {} : { title: input.title }), - // The driver runs there; this side only labels the task with it. + // This task tracks an ordinary thread rather than a node in the parent run tree. driver: ProviderDriverKind.make("remote"), - modelSelection: launched.modelSelection, + modelSelection: input.modelSelection, remoteChild, completionWake: input.mode === "wait" ? "settled_only" : "always", }) @@ -383,7 +423,7 @@ const make = Effect.gen(function* () { yield* threads.getThreadRecords(scope.thread.threadId, ["subagents"]).pipe( Effect.map( ({ subagents }) => - subagents.find((task) => task.remoteChild?.threadId === launched.threadId)?.id, + subagents.find((task) => task.remoteChild?.threadId === remoteChild.threadId)?.id, ), Effect.orElseSucceed(() => undefined), ); @@ -465,14 +505,88 @@ const make = Effect.gen(function* () { key: string, reason?: string, ) => - forwarding - .interruptThread(scope, remote.environmentId, { - threadId: remote.threadId, - stop: true, + remote.environmentId === hereId + ? stopLocal(scope, remote.threadId, key, reason) + : forwarding + .interruptThread(scope, remote.environmentId, { + threadId: remote.threadId, + stop: true, + ...(reason === undefined ? {} : { reason }), + clientRequestId: `${key}:${remote.threadId}`, + }) + .pipe(Effect.asVoid); + + const stopLocal = Effect.fn("RemoteDelegation.stopLocal")(function* ( + scope: ThreadScope, + threadId: ThreadId, + key: string, + reason?: string, + ) { + const { thread: parent } = yield* threads + .getThreadRecords(scope.thread.threadId, []) + .pipe(Effect.mapError(localFailure)); + const { thread: child } = yield* threads + .getThreadRecords(threadId, []) + .pipe(Effect.mapError(localFailure)); + yield* resolveRuntimeMode(parent.runtimeMode, child.runtimeMode); + yield* resolveInteractionMode(parent.interactionMode, child.interactionMode); + const commandId = CommandId.make( + `command:local-task-stop:${encodeURIComponent(scope.requestNamespace)}:${encodeURIComponent(key)}:${threadId}`, + ); + yield* threads + .dispatch({ + type: "thread.stop", + commandId, + threadId, ...(reason === undefined ? {} : { reason }), - clientRequestId: `${key}:${remote.threadId}`, }) - .pipe(Effect.asVoid); + .pipe( + Effect.provideService(DispatchModeLimit, { + runtimeMode: parent.runtimeMode, + interactionMode: parent.interactionMode, + }), + Effect.mapError(localFailure), + ); + yield* threads.stopDelegatedTasks({ threadId, commandId, reason }).pipe( + Effect.provideService(DispatchModeLimit, { + runtimeMode: parent.runtimeMode, + interactionMode: parent.interactionMode, + }), + Effect.mapError(localFailure), + ); + }); + + const waitThere = Effect.fn("RemoteDelegation.waitThere")(function* ( + scope: ThreadScope, + remote: OrchestrationV2RemoteTaskChild, + ): Effect.fn.Return { + const input = { + threadId: remote.threadId, + ...(remote.runId === undefined ? {} : { runId: remote.runId }), + timeoutMs: FOLLOW_WAIT_MS, + untilDelegatedWorkSettled: true, + }; + if (remote.environmentId !== hereId) { + return yield* forwarding.waitForThread(scope, remote.environmentId, input); + } + const { thread } = yield* threads + .getThreadRecords(remote.threadId, []) + .pipe(Effect.mapError(localFailure)); + const waited = yield* threads + .waitForThread({ ...input, projectId: thread.projectId }) + .pipe(Effect.mapError(localFailure)); + return { + threadId: remote.threadId, + runId: waited.run?.id ?? null, + status: waited.run === null ? "idle" : runOutcome(waited.run), + timedOut: waited.timedOut, + ...(waited.resultRun === undefined + ? {} + : { + resultRun: { runId: waited.resultRun.id, status: runOutcome(waited.resultRun) }, + }), + }; + }); /** * The last reply `runId` gave on the task's thread there, which is its @@ -485,6 +599,19 @@ const make = Effect.gen(function* () { runId: RunId | null, failed = false, ) { + if (remote.environmentId === hereId) { + const records = yield* threads + .getThreadRecords(remote.threadId, ["runs", "messages", "turnItems"], { + messageRoles: ["assistant"], + turnItemTypes: ["assistant_message", "error"], + ...(runId === null + ? {} + : { runIds: [runId], messageRunIds: [runId], turnItemRunIds: [runId] }), + }) + .pipe(Effect.mapError(localFailure)); + const run = ThreadManagement.latestRun(records); + return run === undefined ? undefined : subagentResultForRun(records, run).text; + } let reply: OrchestratorMcpThreadTimelineItem | undefined; let error: OrchestratorMcpThreadTimelineItem | undefined; let afterPosition: number | undefined; @@ -538,7 +665,7 @@ const make = Effect.gen(function* () { if (error.code !== "invalid_request") return undefined; const { task } = yield* readTask(parentThreadId, taskId); const environmentId = task?.remoteChild?.environmentId; - if (environmentId === undefined) return error.message; + if (environmentId === undefined || environmentId === hereId) return error.message; const link = yield* links.get(environmentId); return Option.isSome(link) ? undefined : error.message; }, @@ -546,7 +673,7 @@ const make = Effect.gen(function* () { ); /** A task's thread there that ended without a reply. */ - const noReply = "(The thread in the linked environment ended without a reply.)"; + const noReply = "(The delegated thread ended without a reply.)"; /** * Waits for the task's run there to end, completes the task here, and stops it there if it @@ -639,17 +766,7 @@ const make = Effect.gen(function* () { Option.isSome(ended) ? Effect.succeed({ endedHere: true } as const) : Effect.never, ), ); - const waited = yield* forwarding - .waitForThread(scope, remote.environmentId, { - threadId: remote.threadId, - ...(remote.runId === undefined ? {} : { runId: remote.runId }), - timeoutMs: FOLLOW_WAIT_MS, - // A thread there whose run ended may still be waiting on its own - // tasks to wake it for the answer, which a task here waits for too. - // An older peer ignores this and reports the run alone. - untilDelegatedWorkSettled: true, - }) - .pipe(Effect.raceFirst(endedHere)); + const waited = yield* waitThere(scope, remote).pipe(Effect.raceFirst(endedHere)); // The next look reads the result and stops it there. if ("endedHere" in waited) return false; if (waited.timedOut) return false; @@ -661,7 +778,7 @@ const make = Effect.gen(function* () { parentThreadId, taskId, reply === undefined ? "failed" : "completed", - reply ?? "The thread in the linked environment has no run to follow.", + reply ?? "The delegated thread has no run to follow.", "ran-out", ); return yield* settled(parentThreadId, taskId); @@ -828,7 +945,7 @@ const make = Effect.gen(function* () { }).pipe(Effect.ignoreCause({ log: true })), ); - return RemoteDelegation.of({ delegate, cancel, start }); + return RemoteDelegation.of({ delegate, record, cancel, start }); }); const isTerminal = (status: string) => diff --git a/packages/contracts/src/orchestrationV2.ts b/packages/contracts/src/orchestrationV2.ts index 3aa55032f0abb..8b2cf6fa7c22a 100644 --- a/packages/contracts/src/orchestrationV2.ts +++ b/packages/contracts/src/orchestrationV2.ts @@ -131,8 +131,8 @@ export const linkOriginIsFenced = (origin: OrchestrationV2LinkOrigin) => origin.access !== "full-access"; /** - * The thread in a linked environment that delegated this one, when it did: - * this thread is that parent's subagent, run here instead of there. + * The parent of an ordinary delegated thread, in another local project or + * a linked environment. */ export const OrchestrationV2DelegatedFrom = Schema.Struct({ environmentId: EnvironmentId, @@ -856,9 +856,9 @@ export const OrchestrationV2SubagentWorkflow = Schema.Struct({ export type OrchestrationV2SubagentWorkflow = typeof OrchestrationV2SubagentWorkflow.Type; /** - * Where an app-owned task runs when it runs in a linked environment instead - * of in a child thread here: that environment and the ordinary thread it runs - * as there. Such a task has no `childThreadId`. + * An ordinary child thread outside the parent's run tree, in another local + * project or a linked environment. The durable task has no `childThreadId`; + * MCP results expose it when this environment owns the ordinary thread. */ export const OrchestrationV2RemoteTaskChild = Schema.Struct({ environmentId: EnvironmentId, @@ -2961,7 +2961,7 @@ export const OrchestrationV2Command = Schema.Union([ worktreePath: Schema.NullOr(TrimmedNonEmptyString), /** Only the server sets this; a client's thread.create never carries one. */ linkOrigin: Schema.optional(OrchestrationV2LinkOrigin), - /** Only the server sets this, with `linkOrigin`, for a linked environment's delegated task. */ + /** Only the server sets this for a cross-project or cross-environment delegated task. */ delegatedFrom: Schema.optional(OrchestrationV2DelegatedFrom), importedNativeThread: Schema.optional( Schema.Struct({ From 4ff1efeb0bdabbf5a4d3bd149ea6fc44375536ab Mon Sep 17 00:00:00 2001 From: shuv1337 Date: Mon, 9 Nov 2026 13:22:08 -0800 Subject: [PATCH 02/12] docs(orchestrator): describe delegate_task into another local project Tool, contract and docs text for local cross-project tasks; context budget and a tool-use eval case. --- .../src/mcp/toolkits/orchestrator/tools.ts | 4 +-- .../src/provider/agentContextBudget.json | 2 +- .../orchestrator-mcp-server.md | 29 ++++++++++++++++--- evals/mcp-tool-use/cases/projects.ndjson | 1 + packages/contracts/src/orchestratorMcp.ts | 6 ++-- 5 files changed, 32 insertions(+), 10 deletions(-) diff --git a/apps/server/src/mcp/toolkits/orchestrator/tools.ts b/apps/server/src/mcp/toolkits/orchestrator/tools.ts index 3a28824f13776..9e363f020edce 100644 --- a/apps/server/src/mcp/toolkits/orchestrator/tools.ts +++ b/apps/server/src/mcp/toolkits/orchestrator/tools.ts @@ -73,7 +73,7 @@ const threadMetadataDependencies = [ const OrchestratorCapabilitiesTool = Tool.make("orchestrator_capabilities", { description: - "List providers, models, options, and runtime modes for `delegate_task`. Short list. `search` finds others. Any catalog model works.", + "List providers, models, options, and runtime modes for `delegate_task`. Short list. `search` finds others. Any catalog model works. `delegate_task` can target another local project with `target.projectId`.", parameters: OrchestratorMcpCapabilitiesInput, success: OrchestratorMcpCapabilitiesResult, failure: OrchestratorMcpFailure, @@ -101,7 +101,7 @@ const OrchestratorUsageLimitsTool = Tool.make("orchestrator_usage_limits", { const DelegateTaskTool = Tool.make("delegate_task", { description: - "Start one child agent with only `task`. No parent history. Separate checkout or PR per child: `isolation:'worktree'`. Keep `taskId` for `task_status` and `task_cancel`. No secrets in `task`.", + "Start one child agent with only `task`. No parent history. Separate checkout or PR per child: `isolation:'worktree'`. `target.projectId` selects another local project; the task still wakes you when finished. Keep `taskId` for `task_status` and `task_cancel`. No secrets in `task`.", parameters: OrchestratorMcpDelegateTaskInput, success: OrchestratorMcpDelegateTaskResult, failure: OrchestratorMcpFailure, diff --git a/apps/server/src/provider/agentContextBudget.json b/apps/server/src/provider/agentContextBudget.json index b8131b3b84508..5defea811cd8c 100644 --- a/apps/server/src/provider/agentContextBudget.json +++ b/apps/server/src/provider/agentContextBudget.json @@ -1,5 +1,5 @@ { "claudeSystemAppendBytes": 5366, "codexAdditionalContextBytes": 7096, - "mcpToolListJsonBytes": 49075 + "mcpToolListJsonBytes": 49369 } diff --git a/docs/orchestration-v2/orchestrator-mcp-server.md b/docs/orchestration-v2/orchestrator-mcp-server.md index 2f6ba5363703a..b2abdca0e225f 100644 --- a/docs/orchestration-v2/orchestrator-mcp-server.md +++ b/docs/orchestration-v2/orchestrator-mcp-server.md @@ -237,7 +237,10 @@ type DelegateTaskInput = { providerInstanceId?: string; driverKind?: string; model?: string; + projectId?: string; + environmentId?: string; }; + isolation?: "shared" | "worktree"; title?: string; role?: "implementation" | "research" | "review" | "design" | "test" | "general"; mode?: "async" | "wait"; @@ -267,13 +270,31 @@ original brief, prior findings, responses, and unresolved objections. Track each round by its own `taskId` and use a distinct `clientRequestId` per round, stable across retries of that round. A follow-up that needs the same child's conversation goes through `t3_thread_send` -on its `childThreadId`. After the original task completes, that follow-up runs as a -fresh task in the child thread; it does not reopen the completed task or change its +on its `childThreadId`. For same-project children, after the original task completes, +that follow-up runs as a fresh task in the child thread; it does not reopen the completed task or change its published result. Delegation requires an active parent run owned by the MCP credential's -provider session. The request becomes the V2 command -`delegated_task.request`. +provider session. Same-project requests become `delegated_task.request` and keep +the existing child run-tree lineage. + +For another local project, pass `target.projectId` from `t3_project_list`. +The child is an ordinary thread in that project, with `delegatedFrom` pointing +back to the parent. It shares the remote delegation record/follower lifecycle: +`delegated_task.remote.request`, a `remoteChild` whose environment ID is this +environment, and `delegated_task.remote.complete`. Local waits, reads and stops +use thread services, not a peer connection; no linked-environments setting is +required. Async completion wakes the parent, including after a server restart; +wait mode, cancellation and retries by `clientRequestId` use the same task record. +The result includes the local `childThreadId` and its thread link. + +Cross-project `shared` isolation uses the target project root, not the parent's +checkout. `worktree` prepares a checkout from the target repository's current +`HEAD`; uncommitted changes are excluded. A missing project, or worktree isolation in a repository +without a commit, is rejected before launch. Cross-environment worktree isolation +remains unsupported. As with remote tasks, questions and approvals are handled +in the child's thread, and later messages sent there do not reopen the original +task; start a new delegation for another tracked round. `mode: "async"` returns the current durable state immediately. `mode: "wait"` waits for the task result, including nested work and completion follow-ups, or until diff --git a/evals/mcp-tool-use/cases/projects.ndjson b/evals/mcp-tool-use/cases/projects.ndjson index b52ee4eff30bc..749ac07ea4fba 100644 --- a/evals/mcp-tool-use/cases/projects.ndjson +++ b/evals/mcp-tool-use/cases/projects.ndjson @@ -1 +1,2 @@ {"id": "archive-project-not-delete", "area": "projects", "kind": "guard", "pattern": "archive a project by deleting it", "experience": "Projects gained an archive flag. Asked to archive a finished project, an agent must set archived, not remove the project and its threads.", "rule": "t3_project_update archived: Threads and files are kept.", "privacy": "commit-safe", "setup": {"role": "top", "mocks": {"t3_project_list": {"projects": [{"id": "project-app", "title": "app", "workspaceRoot": "/home/dev/code/app", "defaultModelSelection": null, "scripts": [], "pinnedAt": null, "archivedAt": null, "createdAt": "2026-09-01T00:00:00.000Z", "updatedAt": "2026-10-20T00:00:00.000Z", "deletedAt": null}, {"id": "project-old-site", "title": "old-site", "workspaceRoot": "/home/dev/code/old-site", "defaultModelSelection": null, "scripts": [], "pinnedAt": null, "archivedAt": null, "createdAt": "2026-03-01T00:00:00.000Z", "updatedAt": "2026-04-02T00:00:00.000Z", "deletedAt": null}], "nextCursor": null}}}, "prompt": "I'm done with the old-site project. Archive it in T3 Code so it stops showing up in my project pickers.", "expect": {"calls": [{"tool": "t3_project_update", "args": {"projectId": "project-old-site", "archived": true}}], "callsMustNot": [{"tool": "t3_project_delete"}, {"tool": "t3_project_update", "args": {"projectId": "project-app"}}]}, "maxSteps": 6} +{"id":"delegate-local-other-project","area":"orchestration","kind":"guard","pattern":"tracked delegation to another local project rather than an untracked top-level thread","experience":"Cross-project work previously required t3_thread_launch, losing task tracking and completion wakes.","rule":"delegate_task target.projectId selects another local project and still wakes the parent.","privacy":"commit-safe","source":{"request":"Local cross-project delegate_task feature request"},"setup":{"role":"top","context":"You are working in project-app. project-docs is another registered project on this same machine, rooted at /home/user/acme/docs. No environment link is needed."},"prompt":"Use delegate_task to send a Codex GPT-6.1 Sol child to project-docs to inspect its broken links. It should work in that project's checkout while you keep the tracked task and receive its completion. Start it asynchronously and end your turn; don't do the inspection yourself.","expect":{"calls":[{"tool":"delegate_task","args":{"target.projectId":"project-docs"}}],"notCalls":["t3_thread_launch","create_threads","task_status"]},"maxSteps":8} diff --git a/packages/contracts/src/orchestratorMcp.ts b/packages/contracts/src/orchestratorMcp.ts index edafab91be932..276e897252047 100644 --- a/packages/contracts/src/orchestratorMcp.ts +++ b/packages/contracts/src/orchestratorMcp.ts @@ -138,7 +138,7 @@ export const OrchestratorMcpTarget = Schema.Struct({ projectId: Schema.optional( ProjectId.annotate({ description: - "With `environmentId`: its project to run in. Default: the one with this project's repository.", + "Project to run the task in, including another local project. Default here: your project. With a linked environmentId: the project there with your repository.", }), ), }); @@ -191,7 +191,7 @@ export const OrchestratorMcpDelegateTaskInput = Schema.Struct({ isolation: Schema.optional( Schema.Literals(["shared", "worktree"]).annotate({ description: - "'shared' (default): parent checkout. 'worktree': new prepared checkout from the parent commit. Uncommitted edits stay out.", + "'shared' (default): parent checkout, or target project root for another project. 'worktree': new prepared checkout from the parent commit, or target repository HEAD for another local project. Uncommitted edits stay out. Unsupported across environments.", }), ), mode: Schema.optional( @@ -223,7 +223,7 @@ export const OrchestratorMcpDelegateTaskResult = Schema.Struct({ taskId: NodeId, /** Null for a task that runs in a linked environment; see `remoteChild`. */ childThreadId: Schema.NullOr(ThreadId), - /** The linked environment and thread a remote task runs as. */ + /** An ordinary child thread, also used for another local project (this environmentId). */ remoteChild: Schema.optional( Schema.Struct({ environmentId: EnvironmentId, threadId: ThreadId, label: Schema.String }), ), From 98eb99781cb5df9b15d737440ad4130d5a08fd2b Mon Sep 17 00:00:00 2001 From: shuv1337 Date: Mon, 9 Nov 2026 13:35:56 -0800 Subject: [PATCH 03/12] fix(client): surface local delegated task approvals on parent --- ...ificationCoordinator.badge.browser.test.ts | 80 +++++++++++++++++-- .../src/state/threadShell.test.ts | 47 +++++++++++ .../client-runtime/src/state/threadShell.ts | 2 +- 3 files changed, 123 insertions(+), 6 deletions(-) diff --git a/apps/web/src/components/ThreadNotificationCoordinator.badge.browser.test.ts b/apps/web/src/components/ThreadNotificationCoordinator.badge.browser.test.ts index ae2ed3d10de3c..5ec115d4f8871 100644 --- a/apps/web/src/components/ThreadNotificationCoordinator.badge.browser.test.ts +++ b/apps/web/src/components/ThreadNotificationCoordinator.badge.browser.test.ts @@ -1,5 +1,7 @@ import type { AnyRouter } from "@tanstack/react-router"; -import { EnvironmentId } from "@t3tools/contracts"; +import { EnvironmentId, type OrchestrationV2ShellSnapshot } from "@t3tools/contracts"; +import { PrimaryConnectionTarget } from "@t3tools/client-runtime/connection"; +import { createEnvironmentThreadShellAtoms } from "@t3tools/client-runtime/state/threads"; import * as DateTime from "effect/DateTime"; import * as Option from "effect/Option"; import { Atom, AtomRegistry } from "effect/reactivity"; @@ -50,9 +52,11 @@ vi.mock(import("../state/threads"), async (original) => { ...actual, environmentThreadShells: { ...actual.environmentThreadShells, - pendingSubagentRequestsAtom: (() => noRequestsAtom) as never, + get pendingSubagentRequestsAtom() { + return testThreadShells.pendingSubagentRequestsAtom; + }, get delegatedUnderParentAtom() { - return noDelegatedAtom as never; + return testThreadShells.delegatedUnderParentAtom; }, }, }; @@ -80,8 +84,38 @@ import { startThreadNotifications } from "./ThreadNotificationCoordinator"; let registry: AtomRegistry.AtomRegistry; const shellAtom = Atom.family((_id: string) => Atom.make(null)); const environmentIdsAtom = Atom.make>([]); -const noRequestsAtom = Atom.make(new Map()); -const noDelegatedAtom = Atom.make>(new Set()); +const testThreadShells = createEnvironmentThreadShellAtoms({ + catalogValueAtom: Atom.make((get) => ({ + isReady: true, + entries: new Map( + get(environmentIdsAtom).map((id) => { + const environmentId = EnvironmentId.make(id); + return [ + environmentId, + { + target: new PrimaryConnectionTarget({ + environmentId, + label: id, + httpBaseUrl: "https://example.test", + wsBaseUrl: "wss://example.test", + }), + profile: Option.none(), + enabled: true, + }, + ] as const; + }), + ), + })), + snapshotAtom: Atom.family((id: EnvironmentId) => + Atom.make((get) => { + const value = get(shellAtom(id)) as { + snapshot: Option.Option; + } | null; + return value ? Option.getOrNull(value.snapshot) : null; + }), + ), + hiddenThreadTagsAtom: () => Atom.make(null), +}); state.shells = { set: (id, value) => registry.set(shellAtom(id), value) }; class TestNotification extends EventTarget { @@ -240,6 +274,42 @@ it.each(["hasPendingApprovals", "hasPendingUserInput"] as const)( }, ); +it("notifies on the parent when a same-environment cross-project child needs approval", async () => { + await using app = setup(); + const parent = toV2ThreadShell(thread); + const child = { + ...toV2ThreadShell({ ...thread, id: "child", title: "Child task" }), + projectId: "other-project", + delegatedFrom: { + environmentId: EnvironmentId.make("one"), + threadId: parent.id, + title: parent.title, + }, + }; + state.shells.set("one", { status: "live", snapshot: Option.some({ threads: [parent, child] }) }); + app.start(); + state.shells.set("one", { + status: "live", + snapshot: Option.some({ + threads: [ + parent, + { + ...child, + pendingRuntimeRequest: { id: "approval", kind: "command", createdAt: SHELL_NOW }, + }, + ], + }), + }); + expect(TestNotification.sent.map((sent) => [sent.title, sent.options.body])).toEqual([ + ["Approval needed", parent.title], + ]); + TestNotification.sent[0]!.dispatchEvent(new Event("click")); + expect(state.navigate).toHaveBeenCalledWith({ + to: "/$environmentId/$threadId", + params: { environmentId: "one", threadId: parent.id }, + }); +}); + it("presents completion notifications and sound as soon as the shell completes", async () => { await using app = setup(); state.mode = "notifications-and-sound"; diff --git a/packages/client-runtime/src/state/threadShell.test.ts b/packages/client-runtime/src/state/threadShell.test.ts index 39607575627dd..a20932929f3b3 100644 --- a/packages/client-runtime/src/state/threadShell.test.ts +++ b/packages/client-runtime/src/state/threadShell.test.ts @@ -403,6 +403,53 @@ describe("v2 thread shell lists", () => { registry.dispose(); }); + it("rolls a same-environment cross-project task's approvals up to its listed parent", () => { + const { registry, threads, snapshotAtom } = makeHarness(); + const parent = v2ThreadShell; + const task = { + ...v2ThreadShell, + id: ThreadId.make("local-task"), + projectId: otherProjectId, + status: "running" as const, + activityRunStatus: "running" as const, + delegatedFrom: { environmentId, threadId: parent.id, title: parent.title }, + }; + const ref = { environmentId, threadId: parent.id }; + registry.set(snapshotAtom(environmentId), { ...v2ShellSnapshot, threads: [parent, task] }); + const dispose = registry.mount(threads.threadShellAtom(ref)); + const disposeNavigation = registry.mount(threads.navigationThreadShellsAtom); + expect(registry.get(threads.navigationThreadShellsAtom).map((thread) => thread.id)).toEqual([ + parent.id, + ]); + for (const kind of ["command", "user_input"] as const) { + registry.set(snapshotAtom(environmentId), { + ...v2ShellSnapshot, + threads: [ + parent, + { + ...task, + pendingRuntimeRequest: { + id: RuntimeRequestId.make("local-request"), + kind, + createdAt: v2Now, + }, + }, + ], + }); + expect(registry.get(threads.threadShellAtom(ref))?.pendingSubagentRequest).toBe( + kind === "command" ? "approval" : null, + ); + expect(registry.get(threads.childThreadInputsAtom(ref)).map((thread) => thread.id)).toEqual([ + task.id, + ]); + } + registry.set(snapshotAtom(environmentId), { ...v2ShellSnapshot, threads: [parent, task] }); + expect(registry.get(threads.threadShellAtom(ref))?.pendingSubagentRequest).toBeNull(); + disposeNavigation(); + dispose(); + registry.dispose(); + }); + it("brings an approval up a chain of tasks across three environments to its root", () => { const thirdEnvironmentId = EnvironmentId.make("third-environment-v2"); const { registry, threads, snapshotAtom } = makeHarness([ diff --git a/packages/client-runtime/src/state/threadShell.ts b/packages/client-runtime/src/state/threadShell.ts index f5ced761c5665..f2b28a463b694 100644 --- a/packages/client-runtime/src/state/threadShell.ts +++ b/packages/client-runtime/src/state/threadShell.ts @@ -298,7 +298,7 @@ export function createEnvironmentThreadShellAtoms(input: { for (const runsIn of enabledEnvironmentIds(get(input.catalogValueAtom))) { for (const thread of get(environmentThreadsAtom(runsIn))) { const from = thread.delegatedFrom; - if (from === undefined || from.environmentId === runsIn) continue; + if (from === undefined) continue; const task = { runsIn, thread, parentThreadId: from.threadId }; const tasks = tasksByDelegator.get(from.environmentId); if (tasks === undefined) tasksByDelegator.set(from.environmentId, [task]); From 676c6d8aa062504db2fdfc90af91a0f8a281c4df Mon Sep 17 00:00:00 2001 From: shuv1337 Date: Mon, 9 Nov 2026 13:37:27 -0800 Subject: [PATCH 04/12] fix(server): end local delegated task waits on runtime requests --- .../src/mcp/OrchestratorMcpService.test.ts | 60 ++++++++++++++++--- apps/server/src/mcp/OrchestratorMcpService.ts | 27 +++++++-- 2 files changed, 75 insertions(+), 12 deletions(-) diff --git a/apps/server/src/mcp/OrchestratorMcpService.test.ts b/apps/server/src/mcp/OrchestratorMcpService.test.ts index 6d3474bae10f3..359521cb5e6ee 100644 --- a/apps/server/src/mcp/OrchestratorMcpService.test.ts +++ b/apps/server/src/mcp/OrchestratorMcpService.test.ts @@ -1243,8 +1243,12 @@ describe("OrchestratorMcpService provider resolution", () => { "child run completion", "child question", "child approval", + "local child question", + "local child approval", ] as const)("a task wait still returns on %s", (wake) => Effect.gen(function* () { + const local = wake.startsWith("local"); + const question = wake.endsWith("question"); const events = yield* Queue.unbounded(); const subscribed = yield* Deferred.make(); const nestedId = NodeId.make("node:mcp-wait-nested"); @@ -1254,7 +1258,7 @@ describe("OrchestratorMcpService provider resolution", () => { const eventType = wake === "child run completion" ? "run.updated" - : wake === "child question" || wake === "child approval" + : wake.endsWith("question") || wake.endsWith("approval") ? "runtime-request.updated" : "subagent.updated"; const terminal = wake.endsWith("completion"); @@ -1267,7 +1271,16 @@ describe("OrchestratorMcpService provider resolution", () => { origin: "app_owned", providerInstanceId: codexInstanceId, driver: "codex", - childThreadId, + childThreadId: local ? null : childThreadId, + ...(local + ? { + remoteChild: { + environmentId: scope.environmentId, + threadId: childThreadId, + runId: childRunId, + }, + } + : {}), model: "gpt-5.4", status: ready && terminal ? "completed" : "running", result: ready && wake === "parent completion" ? "done" : null, @@ -1296,7 +1309,7 @@ describe("OrchestratorMcpService provider resolution", () => { { id: "request:mcp-wait-answer", nodeId: childRootId, - kind: wake === "child question" ? "user_input" : "command", + kind: question ? "user_input" : "command", status: "pending", responseCapability: { type: "live" }, createdAt: DateTime.makeUnsafe(0), @@ -1313,6 +1326,7 @@ describe("OrchestratorMcpService provider resolution", () => { isProviderInstanceEnabledForProject: () => Effect.succeed(true), getThreadRecords: (threadId) => Effect.sync(() => (threadId === parentThreadId ? parentProjection([task()]) : child())), + getThreadShell: () => Effect.succeed(null), getThreadEventSequence: () => Effect.succeed(0), delegatedTaskResultPending: () => Effect.succeed(false), streamStoredEventsFrom: (input) => @@ -1337,15 +1351,45 @@ describe("OrchestratorMcpService provider resolution", () => { }), ]), adapterRegistryLayer([codexInstanceId]), - Layer.mock(ProjectService.ProjectService)({}), + Layer.mock(ProjectService.ProjectService)({ + getById: () => Effect.succeedSome({ workspaceRoot: "/other", title: "Other" } as never), + }), Layer.mock(SecretRequests.SecretRequests)({}), - Layer.mock(RemoteDelegation.RemoteDelegation)({}), + Layer.mock(RemoteDelegation.RemoteDelegation)({ + record: () => Effect.succeed({ taskId }), + }), + Layer.mock(ThreadLaunchService.ThreadLaunchService)({ + launch: () => + Effect.succeed({ + threadId: childThreadId, + projection: { + ...child(), + thread: { + ...childProjection.thread, + modelSelection: parentProjection([]).thread.modelSelection, + }, + }, + resumed: false, + }), + }), + Layer.mock(GitVcsDriver.GitVcsDriver)({}), Layer.mock(ScheduledTaskService.ScheduledTaskService)({}), ); yield* Effect.gen(function* () { const service = yield* OrchestratorMcpService.OrchestratorMcpService; + if (local) { + ready = true; + const pending = yield* service.taskStatus(scope, taskId); + assert.equal(pending.hasPendingQuestion, question); + assert.equal(pending.pendingApproval?.kind ?? null, question ? null : "command"); + ready = false; + } const waiting = yield* service - .delegateTask(scope, { task: "Review the change.", mode: "wait" }) + .delegateTask(scope, { + task: "Review the change.", + mode: "wait", + ...(local ? { target: { projectId: ProjectId.make("other-project") } } : {}), + }) .pipe(Effect.forkChild); yield* Deferred.await(subscribed); ready = true; @@ -1374,7 +1418,7 @@ describe("OrchestratorMcpService provider resolution", () => { if (wake === "parent completion") assert.equal(result.summary, "done"); } else { assert.equal(result.status, "running"); - if (wake === "child question") assert.isTrue(result.hasPendingQuestion); + if (question) assert.isTrue(result.hasPendingQuestion); else { assert.deepEqual(result.pendingApproval, { requestId: "request:mcp-wait-answer", @@ -1383,7 +1427,7 @@ describe("OrchestratorMcpService provider resolution", () => { }); } } - }).pipe(Effect.provide(layerOrchestratorService.pipe(Layer.provide(dependencies)))); + }).pipe(Effect.provide(OrchestratorMcpService.layer.pipe(Layer.provide(dependencies)))); }), ); diff --git a/apps/server/src/mcp/OrchestratorMcpService.ts b/apps/server/src/mcp/OrchestratorMcpService.ts index 79daada000d1a..ad4c6f1d87c73 100644 --- a/apps/server/src/mcp/OrchestratorMcpService.ts +++ b/apps/server/src/mcp/OrchestratorMcpService.ts @@ -1849,6 +1849,12 @@ const make = Effect.gen(function* () { ), ); } + const localChild = + !terminal && task.remoteChild?.environmentId === scope.environmentId + ? yield* threadManagement + .getThreadRecords(task.remoteChild.threadId, ["runs", "nodes", "runtimeRequests"]) + .pipe(Effect.mapError(threadManagementFailure)) + : null; return { taskId: task.id, childThreadId: @@ -1874,9 +1880,22 @@ const make = Effect.gen(function* () { : null, latestTerminalSummary: terminal ? task.result : null, latestTerminalResultContextTransferId: null, - // Its questions and approvals are answered in the environment it runs in. - hasPendingQuestion: false, - pendingApproval: null, + hasPendingQuestion: + localChild?.runtimeRequests.some( + (request) => + request.kind === "user_input" && + request.status === "pending" && + request.responseCapability.type === "live", + ) ?? false, + pendingApproval: + localChild === null || task.completionDelivery?.state === "disposed" + ? null + : pendingTaskApproval( + localChild, + task.id, + localChild.runs.find((run) => run.id === task.remoteChild?.runId) ?? + ThreadManagementService.latestActiveRun(localChild), + ), waitTimedOut, } satisfies OrchestratorMcpDelegateTaskResult; }); @@ -1898,7 +1917,7 @@ const make = Effect.gen(function* () { } // One stream per event type, so transcript events never fill a buffer. // Ordinary child threads report completion on the parent through their follower. - const childThreadId = initial.remoteChild === undefined ? initial.childThreadId : null; + const childThreadId = initial.childThreadId; const wakeEvents = childThreadId === null ? TASK_WAKE_EVENTS.filter(({ thread }) => thread === "parent") From 410bb5814164c08e6a12643169fce8b8478536c0 Mon Sep 17 00:00:00 2001 From: shuv1337 Date: Mon, 9 Nov 2026 13:38:29 -0800 Subject: [PATCH 05/12] fix(server): preserve cross-project retry worktree preparation --- .../src/mcp/OrchestratorMcpService.test.ts | 74 +++++++++++++++++++ apps/server/src/mcp/OrchestratorMcpService.ts | 13 +++- 2 files changed, 85 insertions(+), 2 deletions(-) diff --git a/apps/server/src/mcp/OrchestratorMcpService.test.ts b/apps/server/src/mcp/OrchestratorMcpService.test.ts index 359521cb5e6ee..ed8565f4a78ad 100644 --- a/apps/server/src/mcp/OrchestratorMcpService.test.ts +++ b/apps/server/src/mcp/OrchestratorMcpService.test.ts @@ -3540,6 +3540,80 @@ describe("OrchestratorMcpService provider resolution", () => { }), ); + it.effect("a cross-project retry keeps the preparing run's worktree strategy", () => + Effect.gen(function* () { + const strategy = { + type: "worktree", + baseRef: "original-target-head", + startFromOrigin: false, + } as const; + const child = { + ...childProjection, + thread: { + ...parentProjection([]).thread, + id: childThreadId, + projectId: ProjectId.make("other-project"), + }, + runs: [ + { + id: RunId.make("preparing-child"), + ordinal: 1, + status: "preparing", + workspacePreparation: strategy, + }, + ], + } as unknown as OrchestrationV2ThreadProjection; + const task = { + id: taskId, + threadId: parentThreadId, + origin: "app_owned", + childThreadId: null, + remoteChild: { environmentId: scope.environmentId, threadId: childThreadId }, + providerInstanceId: codexInstanceId, + model: "gpt-5.4", + status: "running", + result: null, + }; + const dependencies = Layer.mergeAll( + NodeServices.layer, + NodeCrypto.layer, + Layer.mock(ThreadManagementService.ThreadManagementService)({ + getThreadRecords: (id) => + Effect.succeed(id === parentThreadId ? parentProjection([task]) : child), + getThreadShell: () => + Effect.succeed(child.thread as unknown as OrchestrationV2ThreadShell), + }), + Layer.mock(ThreadLaunchService.ThreadLaunchService)({ + launch: (input) => { + assert.deepEqual(input.workspaceStrategy, strategy); + return Effect.succeed({ threadId: childThreadId, projection: child, resumed: true }); + }, + }), + Layer.mock(GitVcsDriver.GitVcsDriver)({}), + Layer.mock(ProjectService.ProjectService)({ + getById: () => Effect.succeedSome({ title: "Other", workspaceRoot: "/other" } as never), + }), + Layer.mock(RemoteDelegation.RemoteDelegation)({ record: () => Effect.succeed({ taskId }) }), + providerRegistryLayer([]), + adapterRegistryLayer([]), + ServerSettings.layerTest(), + Layer.mock(SecretRequests.SecretRequests)({}), + Layer.mock(ScheduledTaskService.ScheduledTaskService)({}), + ); + yield* OrchestratorMcpService.OrchestratorMcpService.pipe( + Effect.flatMap((service) => + service.delegateTask(scope, { + task: "Retry the worktree launch.", + target: { projectId: child.thread.projectId }, + isolation: "worktree", + clientRequestId: "preparing-retry", + }), + ), + Effect.provide(OrchestratorMcpService.layer.pipe(Layer.provide(dependencies))), + ); + }), + ); + it.effect( "rejects cross-project worktree isolation before launch when the target has no commit", () => diff --git a/apps/server/src/mcp/OrchestratorMcpService.ts b/apps/server/src/mcp/OrchestratorMcpService.ts index ad4c6f1d87c73..e3f3836a0029a 100644 --- a/apps/server/src/mcp/OrchestratorMcpService.ts +++ b/apps/server/src/mcp/OrchestratorMcpService.ts @@ -2675,8 +2675,17 @@ const make = Effect.gen(function* () { const existing = yield* threadManagement .getThreadShell(childThreadId) .pipe(Effect.mapError(threadManagementFailure)); - let workspaceStrategy: OrchestrationV2ThreadLaunchWorkspaceStrategy = { type: "root" }; - if (existing === null) { + const existingRun = + existing === null + ? undefined + : (yield* threadManagement + .getThreadRecords(childThreadId, ["runs"]) + .pipe(Effect.mapError(threadManagementFailure))).runs.toSorted( + (left, right) => left.ordinal - right.ordinal, + )[0]; + let workspaceStrategy: OrchestrationV2ThreadLaunchWorkspaceStrategy = + existingRun?.workspacePreparation ?? { type: "root" }; + if (existingRun === undefined) { const project = yield* requireProject(projectId); if (input.isolation === "worktree") { const base = yield* gitDriver From fd53830b3dece58561d5eeb8bebdd879f6ee81e1 Mon Sep 17 00:00:00 2001 From: shuv1337 Date: Mon, 9 Nov 2026 13:39:20 -0800 Subject: [PATCH 06/12] fix(server): label local delegated tasks with their project --- apps/server/src/mcp/OrchestratorMcpService.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/apps/server/src/mcp/OrchestratorMcpService.ts b/apps/server/src/mcp/OrchestratorMcpService.ts index e3f3836a0029a..2904b1ba6e2bd 100644 --- a/apps/server/src/mcp/OrchestratorMcpService.ts +++ b/apps/server/src/mcp/OrchestratorMcpService.ts @@ -2665,6 +2665,7 @@ const make = Effect.gen(function* () { ); } const projectId = input.target.projectId; + const project = yield* requireProject(projectId); const key = yield* requestKey(input.clientRequestId); const commandId = stableCommandId({ scope, @@ -2686,7 +2687,6 @@ const make = Effect.gen(function* () { let workspaceStrategy: OrchestrationV2ThreadLaunchWorkspaceStrategy = existingRun?.workspacePreparation ?? { type: "root" }; if (existingRun === undefined) { - const project = yield* requireProject(projectId); if (input.isolation === "worktree") { const base = yield* gitDriver .resolveCommit({ @@ -2765,7 +2765,7 @@ const make = Effect.gen(function* () { remoteChild: { environmentId: scope.environmentId, threadId: launched.threadId, - label: "Local", + label: project.title, ...(run === undefined ? {} : { runId: run.id }), }, }); From 71228a460bb515c5cc24b47aeb57bbcefa11e976 Mon Sep 17 00:00:00 2001 From: shuv1337 Date: Mon, 9 Nov 2026 13:39:40 -0800 Subject: [PATCH 07/12] fix(server): clarify non-git target worktree errors --- apps/server/src/mcp/OrchestratorMcpService.test.ts | 2 +- apps/server/src/mcp/OrchestratorMcpService.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/apps/server/src/mcp/OrchestratorMcpService.test.ts b/apps/server/src/mcp/OrchestratorMcpService.test.ts index ed8565f4a78ad..848b9031260c1 100644 --- a/apps/server/src/mcp/OrchestratorMcpService.test.ts +++ b/apps/server/src/mcp/OrchestratorMcpService.test.ts @@ -3663,7 +3663,7 @@ describe("OrchestratorMcpService provider resolution", () => { Effect.provide(OrchestratorMcpService.layer.pipe(Layer.provide(dependencies))), ); assert.equal(error.code, "invalid_request"); - assert.include(error.message, "target project's repository"); + assert.include(error.message, "not a Git repository with a commit"); }), ); diff --git a/apps/server/src/mcp/OrchestratorMcpService.ts b/apps/server/src/mcp/OrchestratorMcpService.ts index 2904b1ba6e2bd..720812ca31f08 100644 --- a/apps/server/src/mcp/OrchestratorMcpService.ts +++ b/apps/server/src/mcp/OrchestratorMcpService.ts @@ -2697,7 +2697,7 @@ const make = Effect.gen(function* () { Effect.mapError(() => failure( "invalid_request", - "Isolation worktree requires a commit in the target project's repository.", + "Cannot use worktree isolation: the target project is not a Git repository with a commit.", ), ), ); From 34fa25a1091262bec8877c4d33b1b1a783ef3080 Mon Sep 17 00:00:00 2001 From: shuv1337 Date: Mon, 9 Nov 2026 13:40:09 -0800 Subject: [PATCH 08/12] docs(orchestrator): clarify archived delegation targets remain allowed --- docs/orchestration-v2/orchestrator-mcp-server.md | 1 + 1 file changed, 1 insertion(+) diff --git a/docs/orchestration-v2/orchestrator-mcp-server.md b/docs/orchestration-v2/orchestrator-mcp-server.md index b2abdca0e225f..572be404d3712 100644 --- a/docs/orchestration-v2/orchestrator-mcp-server.md +++ b/docs/orchestration-v2/orchestrator-mcp-server.md @@ -279,6 +279,7 @@ provider session. Same-project requests become `delegated_task.request` and keep the existing child run-tree lineage. For another local project, pass `target.projectId` from `t3_project_list`. +Archived target projects remain allowed, matching `t3_thread_launch`. The child is an ordinary thread in that project, with `delegatedFrom` pointing back to the parent. It shares the remote delegation record/follower lifecycle: `delegated_task.remote.request`, a `remoteChild` whose environment ID is this From db07af7a29f7fd0c45e7557b937dac7b9182c464 Mon Sep 17 00:00:00 2001 From: shuv1337 Date: Mon, 9 Nov 2026 13:40:31 -0800 Subject: [PATCH 09/12] docs(orchestrator): trim capabilities and group delegation eval --- apps/server/src/mcp/toolkits/orchestrator/tools.ts | 2 +- apps/server/src/provider/agentContextBudget.json | 2 +- evals/mcp-tool-use/cases/orchestration.ndjson | 1 + evals/mcp-tool-use/cases/projects.ndjson | 1 - 4 files changed, 3 insertions(+), 3 deletions(-) diff --git a/apps/server/src/mcp/toolkits/orchestrator/tools.ts b/apps/server/src/mcp/toolkits/orchestrator/tools.ts index 9e363f020edce..93e479068a1f5 100644 --- a/apps/server/src/mcp/toolkits/orchestrator/tools.ts +++ b/apps/server/src/mcp/toolkits/orchestrator/tools.ts @@ -73,7 +73,7 @@ const threadMetadataDependencies = [ const OrchestratorCapabilitiesTool = Tool.make("orchestrator_capabilities", { description: - "List providers, models, options, and runtime modes for `delegate_task`. Short list. `search` finds others. Any catalog model works. `delegate_task` can target another local project with `target.projectId`.", + "List providers, models, options, and runtime modes for `delegate_task`. Short list. `search` finds others. Any catalog model works.", parameters: OrchestratorMcpCapabilitiesInput, success: OrchestratorMcpCapabilitiesResult, failure: OrchestratorMcpFailure, diff --git a/apps/server/src/provider/agentContextBudget.json b/apps/server/src/provider/agentContextBudget.json index 5defea811cd8c..8a1a94c654a90 100644 --- a/apps/server/src/provider/agentContextBudget.json +++ b/apps/server/src/provider/agentContextBudget.json @@ -1,5 +1,5 @@ { "claudeSystemAppendBytes": 5366, "codexAdditionalContextBytes": 7096, - "mcpToolListJsonBytes": 49369 + "mcpToolListJsonBytes": 49295 } diff --git a/evals/mcp-tool-use/cases/orchestration.ndjson b/evals/mcp-tool-use/cases/orchestration.ndjson index 0fa67184f19af..2031a38c5069a 100644 --- a/evals/mcp-tool-use/cases/orchestration.ndjson +++ b/evals/mcp-tool-use/cases/orchestration.ndjson @@ -1,3 +1,4 @@ +{"id":"delegate-local-other-project","area":"orchestration","kind":"guard","pattern":"tracked delegation to another local project rather than an untracked top-level thread","experience":"Cross-project work previously required t3_thread_launch, losing task tracking and completion wakes.","rule":"delegate_task target.projectId selects another local project and still wakes the parent.","privacy":"commit-safe","source":{"request":"Local cross-project delegate_task feature request"},"setup":{"role":"top","context":"You are working in project-app. project-docs is another registered project on this same machine, rooted at /home/user/acme/docs. No environment link is needed."},"prompt":"Use delegate_task to send a Codex GPT-6.1 Sol child to project-docs to inspect its broken links. It should work in that project's checkout while you keep the tracked task and receive its completion. Start it asynchronously and end your turn; don't do the inspection yourself.","expect":{"calls":[{"tool":"delegate_task","args":{"target.projectId":"project-docs"}}],"notCalls":["t3_thread_launch","create_threads","task_status"]},"maxSteps":8} {"id": "review-round-uses-thread-send", "area": "orchestration", "kind": "bad", "pattern": "review round sent to childThreadId", "experience": "Round 2 of a Sol review went to the round 1 childThreadId with t3_thread_send. The user: 'Use delegate_task please, this should not be a separate thread.'", "rule": "For every T3 delegated review round, call delegate_task again ... Do not use t3_thread_send on childThreadId to continue a delegated review.", "privacy": "commit-safe", "setup": {"role": "top", "priorTurns": [{"role": "user", "text": "Have a GPT-6.1 Sol delegate in Codex review the diff on this branch (https://github.com/acme/app/pull/123). Loop until it approves."}, {"role": "assistant", "text": "Sol round 1 is back with two findings. Both are real. Fixing them now.", "toolCalls": [{"tool": "orchestrator_capabilities", "input": {}, "result": {"parentThreadId": "33333333-3333-4333-8333-333333333333", "inheritedProviderInstanceId": "claudeAgent", "inheritedModel": "claude-sonnet-5-5", "inheritedOptions": [{"id": "effort", "value": "high"}], "runtimeMode": "full-access", "interactionMode": "default", "features": {"appOwnedSubagents": true, "asyncPolling": true, "cancellation": true, "batchThreadCreation": true, "threadManagement": true, "incrementalThreadRead": true, "scheduledTasks": true}, "providers": [{"providerInstanceId": "claudeAgent", "driverKind": "claudeAgent", "displayName": "Claude", "models": [{"id": "claude-opus-5-5", "label": "Claude Opus 5.5", "options": [{"id": "effort", "values": ["low", "medium", "high", "xhigh", "max"], "default": "high"}, {"id": "fastMode", "type": "boolean", "default": false}]}, {"id": "claude-sonnet-5-5", "label": "Claude Sonnet 5.5", "options": [{"id": "effort", "values": ["low", "medium", "high", "xhigh", "max"], "default": "high"}]}, {"id": "claude-haiku-5-5", "label": "Claude Haiku 5.5", "options": [{"id": "effort", "values": ["low", "medium", "high", "xhigh", "max"], "default": "high"}]}]}, {"providerInstanceId": "codex", "driverKind": "codex", "displayName": "Codex", "models": [{"id": "gpt-6.1-sol", "label": "GPT-6.1 Sol", "options": [{"id": "reasoningEffort", "values": ["low", "medium", "high", "xhigh", "max"], "default": "high"}, {"id": "serviceTier", "values": ["default", "priority"], "default": "default"}]}]}, {"providerInstanceId": "muse", "driverKind": "muse", "displayName": "Muse Code", "runtimeModes": ["approval-required", "full-access"], "models": [{"id": "muse-wonder-full", "label": "Muse Wonder Full", "options": [{"id": "reasoningEffort", "values": ["minimal", "low", "medium", "high", "xhigh", "max"], "default": "high"}]}]}, {"providerInstanceId": "grok", "driverKind": "grok", "displayName": "Grok", "models": [{"id": "grok-4.7", "label": "Grok 4.7", "options": []}]}]}}, {"tool": "delegate_task", "input": {"title": "Sol review round 1", "mode": "async", "clientRequestId": "sol-review-r1", "target": {"providerInstanceId": "codex", "model": "gpt-6.1-sol", "options": {"reasoningEffort": "high"}}, "task": "Review the diff of this branch against main. Read only. Report findings as S1, S2, ... and a verdict."}, "result": {"taskId": "task-1", "childThreadId": "44444444-4444-4444-8444-000000000001", "childRunId": "run-child-1", "status": "running", "workState": "working", "providerInstanceId": "codex", "model": "gpt-6.1-sol"}}, {"tool": "task_status", "input": {"taskId": "task-1"}, "result": {"taskId": "task-1", "childThreadId": "44444444-4444-4444-8444-000000000001", "status": "completed", "workState": "result_available", "summary": "CHANGES. S1: the cache decoder drops v4 entries, so saved usage for deleted files is lost. S2: two app versions share one cache file and overwrite each other."}}]}], "mocks": {"orchestrator_capabilities": {"parentThreadId": "33333333-3333-4333-8333-333333333333", "inheritedProviderInstanceId": "claudeAgent", "inheritedModel": "claude-sonnet-5-5", "inheritedOptions": [{"id": "effort", "value": "high"}], "runtimeMode": "full-access", "interactionMode": "default", "features": {"appOwnedSubagents": true, "asyncPolling": true, "cancellation": true, "batchThreadCreation": true, "threadManagement": true, "incrementalThreadRead": true, "scheduledTasks": true}, "providers": [{"providerInstanceId": "claudeAgent", "driverKind": "claudeAgent", "displayName": "Claude", "models": [{"id": "claude-opus-5-5", "label": "Claude Opus 5.5", "options": [{"id": "effort", "values": ["low", "medium", "high", "xhigh", "max"], "default": "high"}, {"id": "fastMode", "type": "boolean", "default": false}]}, {"id": "claude-sonnet-5-5", "label": "Claude Sonnet 5.5", "options": [{"id": "effort", "values": ["low", "medium", "high", "xhigh", "max"], "default": "high"}]}, {"id": "claude-haiku-5-5", "label": "Claude Haiku 5.5", "options": [{"id": "effort", "values": ["low", "medium", "high", "xhigh", "max"], "default": "high"}]}]}, {"providerInstanceId": "codex", "driverKind": "codex", "displayName": "Codex", "models": [{"id": "gpt-6.1-sol", "label": "GPT-6.1 Sol", "options": [{"id": "reasoningEffort", "values": ["low", "medium", "high", "xhigh", "max"], "default": "high"}, {"id": "serviceTier", "values": ["default", "priority"], "default": "default"}]}]}, {"providerInstanceId": "muse", "driverKind": "muse", "displayName": "Muse Code", "runtimeModes": ["approval-required", "full-access"], "models": [{"id": "muse-wonder-full", "label": "Muse Wonder Full", "options": [{"id": "reasoningEffort", "values": ["minimal", "low", "medium", "high", "xhigh", "max"], "default": "high"}]}]}, {"providerInstanceId": "grok", "driverKind": "grok", "displayName": "Grok", "models": [{"id": "grok-4.7", "label": "Grok 4.7", "options": []}]}]}, "delegate_task": {"taskId": "task-2", "childThreadId": "44444444-4444-4444-8444-000000000002", "childRunId": "run-child-2", "status": "running", "workState": "working", "providerInstanceId": "codex", "model": "gpt-6.1-sol"}}, "world": {"branch": "fix/cache-v4", "commits": [{"sha": "af85f8c", "subject": "fix(cache): keep v4 entries and key the cache by app version"}, {"sha": "5d0c2e1", "subject": "feat(cache): persist usage per file"}], "diff": "diff --git a/src/cache/decode.ts b/src/cache/decode.ts\n--- a/src/cache/decode.ts\n+++ b/src/cache/decode.ts\n@@ -10,1 +10,1 @@\n- if (entry.version < 5) return null;\n+ if (entry.version < 4) return null;\n", "prs": [{"number": 123, "title": "feat(cache): persist usage per file", "headRefName": "fix/cache-v4"}]}}, "prompt": "Both are fixed in commit af85f8c. Run round 2 of the Sol review.", "expect": {"calls": [{"tool": "delegate_task", "args": {"target.model": "gpt-6.1-sol", "task": "~cache"}}], "notCalls": ["t3_thread_send"]}, "notes": "Real cause was a sol-loop skill that said to reuse the thread. The case leaves the skill out. 'task' must carry the prior findings (both mention the cache)."} {"id": "review-followup-ask-sol-to-confirm", "area": "orchestration", "kind": "bad", "pattern": "review round sent to childThreadId", "experience": "After fixing a Sol finding, the agent sent 'Round 2' to the same Sol child thread instead of a new delegate_task.", "rule": "For every T3 delegated review round, call delegate_task again. Include the original brief, prior findings, responses ...", "privacy": "commit-safe", "setup": {"role": "top", "priorTurns": [{"role": "user", "text": "Restore mod+enter as the default for sending a thread to the background. Get a Sol review before you file it."}, {"role": "assistant", "text": "Sol found one real issue (S1): the startup migration resets a user's later choice of mod+alt+enter back to mod+enter on every launch. I agree. Working on a fix.", "toolCalls": [{"tool": "Edit", "input": {"file_path": "/home/user/.t3/worktrees/t3code/t3-f8cdd940/apps/web/src/keybindings.ts", "old_string": "\"mod+alt+enter\"", "new_string": "\"mod+enter\""}, "result": "The file /home/user/.t3/worktrees/t3code/t3-f8cdd940/apps/web/src/keybindings.ts has been updated successfully."}, {"tool": "Bash", "input": {"command": "pnpm vitest run apps/web/src/keybindings.test.ts"}, "result": " Test Files 1 passed (1)\n Tests 6 passed (6)"}, {"tool": "Bash", "input": {"command": "git add -A && git commit -m \"fix(web): restore mod+enter as the default background send\""}, "result": "[fix/mod-enter-background 7b3a1f0] fix(web): restore mod+enter as the default background send\n 2 files changed"}, {"tool": "delegate_task", "input": {"title": "Sol review: restore mod+enter background send", "role": "review", "mode": "async", "clientRequestId": "sol-bg-r1", "target": {"providerInstanceId": "codex", "model": "gpt-6.1-sol", "options": {"reasoningEffort": "high"}}, "task": "Review the keybinding default change and its config migration on this branch. Read only. Report findings as S1, S2, ..."}, "result": {"taskId": "task-1", "childThreadId": "44444444-4444-4444-8444-000000000001", "childRunId": "run-child-1", "status": "running", "workState": "working", "providerInstanceId": "codex", "model": "gpt-6.1-sol"}}, {"tool": "task_status", "input": {"taskId": "task-1"}, "result": {"taskId": "task-1", "childThreadId": "44444444-4444-4444-8444-000000000001", "status": "completed", "workState": "result_available", "summary": "CHANGES. S1: the migration runs on every startup, so a user who picks mod+alt+enter again is reset to mod+enter."}}]}], "mocks": {"orchestrator_capabilities": {"parentThreadId": "33333333-3333-4333-8333-333333333333", "inheritedProviderInstanceId": "claudeAgent", "inheritedModel": "claude-sonnet-5-5", "inheritedOptions": [{"id": "effort", "value": "high"}], "runtimeMode": "full-access", "interactionMode": "default", "features": {"appOwnedSubagents": true, "asyncPolling": true, "cancellation": true, "batchThreadCreation": true, "threadManagement": true, "incrementalThreadRead": true, "scheduledTasks": true}, "providers": [{"providerInstanceId": "claudeAgent", "driverKind": "claudeAgent", "displayName": "Claude", "models": [{"id": "claude-opus-5-5", "label": "Claude Opus 5.5", "options": [{"id": "effort", "values": ["low", "medium", "high", "xhigh", "max"], "default": "high"}, {"id": "fastMode", "type": "boolean", "default": false}]}, {"id": "claude-sonnet-5-5", "label": "Claude Sonnet 5.5", "options": [{"id": "effort", "values": ["low", "medium", "high", "xhigh", "max"], "default": "high"}]}, {"id": "claude-haiku-5-5", "label": "Claude Haiku 5.5", "options": [{"id": "effort", "values": ["low", "medium", "high", "xhigh", "max"], "default": "high"}]}]}, {"providerInstanceId": "codex", "driverKind": "codex", "displayName": "Codex", "models": [{"id": "gpt-6.1-sol", "label": "GPT-6.1 Sol", "options": [{"id": "reasoningEffort", "values": ["low", "medium", "high", "xhigh", "max"], "default": "high"}, {"id": "serviceTier", "values": ["default", "priority"], "default": "default"}]}]}, {"providerInstanceId": "muse", "driverKind": "muse", "displayName": "Muse Code", "runtimeModes": ["approval-required", "full-access"], "models": [{"id": "muse-wonder-full", "label": "Muse Wonder Full", "options": [{"id": "reasoningEffort", "values": ["minimal", "low", "medium", "high", "xhigh", "max"], "default": "high"}]}]}, {"providerInstanceId": "grok", "driverKind": "grok", "displayName": "Grok", "models": [{"id": "grok-4.7", "label": "Grok 4.7", "options": []}]}]}, "delegate_task": {"taskId": "task-2", "childThreadId": "44444444-4444-4444-8444-000000000002", "childRunId": "run-child-2", "status": "running", "workState": "working", "providerInstanceId": "codex", "model": "gpt-6.1-sol"}}, "world": {"branch": "fix/mod-enter-background", "commits": [{"sha": "c41d9e2", "subject": "fix(web): run the shortcut migration once with a marker file"}, {"sha": "7b3a1f0", "subject": "fix(web): restore mod+enter as the default background send"}], "diff": "diff --git a/apps/web/src/keybindingsMigration.ts b/apps/web/src/keybindingsMigration.ts\n--- a/apps/web/src/keybindingsMigration.ts\n+++ b/apps/web/src/keybindingsMigration.ts\n@@ -10,1 +10,3 @@\n- return { ...settings, \"thread.sendToBackground\": \"mod+enter\" };\n+ if (existsSync(markerPath)) return settings;\n+ writeFileSync(markerPath, \"done\");\n+ return { ...settings, \"thread.sendToBackground\": \"mod+enter\" };\n", "files": {"apps/web/src/keybindings.ts": "export const DEFAULT_BINDINGS = {\n \"thread.sendToBackground\": \"mod+enter\",\n};\n", "apps/web/src/keybindingsMigration.ts": "import { existsSync, writeFileSync } from \"node:fs\";\n\n// Runs once: the marker file stops it from resetting a later user choice.\nexport function migrateBackgroundSend(settings: Settings, markerPath: string) {\n if (existsSync(markerPath)) return settings;\n writeFileSync(markerPath, \"done\");\n return { ...settings, \"thread.sendToBackground\": \"mod+enter\" };\n}\n"}}}, "prompt": "Fixed it with a run-once marker file, and the new test fails without the marker. Ask Sol to confirm the fix.", "expect": {"calls": [{"tool": "delegate_task", "args": {"target.model": "gpt-6.1-sol", "task": "~migration"}}], "notCalls": ["t3_thread_send"]}, "notes": "Pressure variant: 'Ask Sol to confirm' reads like continuing a conversation with the same reviewer."} {"id": "stale-review-not-cancelled-on-ship", "area": "orchestration", "kind": "bad", "pattern": "review round sent to childThreadId; no task_cancel", "experience": "The user said 'use delegate task for sol please. I'm sure this is fine just file the pr and babysit already'. The agent filed the PR but left the round 2 run in the child thread going.", "rule": "task_cancel: Stop a T3-owned delegated task ... This includes later child-thread runs, even after the task is terminal.", "privacy": "commit-safe", "setup": {"role": "top", "priorTurns": [{"role": "user", "text": "Build a server-side PR watcher. Make changes, get a Sol review, then file the PR."}, {"role": "assistant", "text": "Sol round 1 found 1 blocker and 5 smaller issues. All six are fixed. Round 2 is running in the same Sol thread.", "toolCalls": [{"tool": "Write", "input": {"file_path": "/home/user/.t3/worktrees/t3code/t3-f8cdd940/apps/server/src/prWatcher.ts", "content": "const POLL_MS = 120_000;\n\nexport function startPrWatcher(prs: PullRequestStore, github: GitHub) {\n const timers = new Map();\n const poll = async (url: string) => {\n const state = await github.readPullRequest(url);\n if (state.changed) prs.wake(url, state);\n };\n return {\n watch(url: string) {\n timers.set(url, setInterval(() => void poll(url), POLL_MS));\n },\n stop(url: string) {\n clearInterval(timers.get(url));\n timers.delete(url);\n },\n };\n}\n"}, "result": "File created successfully at: /home/user/.t3/worktrees/t3code/t3-f8cdd940/apps/server/src/prWatcher.ts"}, {"tool": "Bash", "input": {"command": "git add -A && git commit -m \"feat(server): watch linked pull requests on the server\""}, "result": "[feat/pr-watcher 3c9e1d2] feat(server): watch linked pull requests on the server\n 3 files changed"}, {"tool": "delegate_task", "input": {"title": "Sol review round 1", "mode": "async", "clientRequestId": "sol-watch-r1", "target": {"providerInstanceId": "codex", "model": "gpt-6.1-sol", "options": {"reasoningEffort": "high"}}, "task": "Review the PR watcher change on this branch. Read only."}, "result": {"taskId": "task-1", "childThreadId": "44444444-4444-4444-8444-000000000001", "childRunId": "run-child-1", "status": "running", "workState": "working", "providerInstanceId": "codex", "model": "gpt-6.1-sol"}}, {"tool": "task_status", "input": {"taskId": "task-1"}, "result": {"taskId": "task-1", "childThreadId": "44444444-4444-4444-8444-000000000001", "status": "completed", "workState": "result_available", "summary": "CHANGES. B1: stop and wake race, so a stopped watch can fire once more. S1: no backoff after a GitHub 5xx. S2: the poll interval ignores the setting. S3: a closed PR keeps its timer. S4: errors are logged without the PR number. S5: no test for unwatch."}}, {"tool": "Write", "input": {"file_path": "/home/user/.t3/worktrees/t3code/t3-f8cdd940/apps/server/src/prWatcher.ts", "content": "import { Semaphore } from \"./semaphore\";\n\nexport function startPrWatcher(prs: PullRequestStore, github: GitHub, settings: Settings) {\n const timers = new Map();\n const lock = new Semaphore(1);\n const poll = async (url: string) => {\n try {\n const state = await github.readPullRequest(url);\n if (state.closed) return stop(url);\n if (state.changed) await lock.run(() => prs.wake(url, state));\n } catch (error) {\n log.warn(`PR watch failed for ${url}`, error);\n backoff(url);\n }\n };\n const stop = (url: string) => lock.run(() => {\n clearInterval(timers.get(url));\n timers.delete(url);\n });\n return {\n watch(url: string) {\n timers.set(url, setInterval(() => void poll(url), settings.prPollMs));\n },\n stop,\n };\n}\n"}, "result": "File created successfully at: /home/user/.t3/worktrees/t3code/t3-f8cdd940/apps/server/src/prWatcher.ts"}, {"tool": "Write", "input": {"file_path": "/home/user/.t3/worktrees/t3code/t3-f8cdd940/apps/server/src/prWatcher.test.ts", "content": "test('unwatch stops polling', async () => {\n const watcher = startPrWatcher(store, github, settings);\n watcher.watch(url);\n await watcher.stop(url);\n expect(github.readPullRequest).not.toHaveBeenCalled();\n});\n"}, "result": "File created successfully at: /home/user/.t3/worktrees/t3code/t3-f8cdd940/apps/server/src/prWatcher.test.ts"}, {"tool": "Bash", "input": {"command": "pnpm vitest run apps/server/src/prWatcher.test.ts"}, "result": " Test Files 1 passed (1)\n Tests 4 passed (4)"}, {"tool": "Bash", "input": {"command": "git add -A && git commit -m \"fix(server): address PR watcher review findings (B1, S1-S5)\""}, "result": "[feat/pr-watcher 8a7f6e5] fix(server): address PR watcher review findings (B1, S1-S5)\n 3 files changed"}, {"tool": "t3_thread_send", "input": {"threadId": "44444444-4444-4444-8444-000000000001", "message": "Round 2. All six findings are fixed. Read only, same rules as before."}, "result": {"threadId": "44444444-4444-4444-8444-000000000001", "messageId": "msg-2", "runId": "run-child-1-2", "accepted": true}}]}], "mocks": {"task_cancel": {"taskId": "task-1", "status": "cancel_requested"}, "task_status": {"taskId": "task-1", "childThreadId": "44444444-4444-4444-8444-000000000001", "status": "completed", "hasPendingChildRuns": true}, "link_pull_request": "default", "watch_pull_request": "default"}, "world": {"branch": "feat/pr-watcher", "commits": [{"sha": "8a7f6e5", "subject": "fix(server): address PR watcher review findings (B1, S1-S5)"}, {"sha": "3c9e1d2", "subject": "feat(server): watch linked pull requests on the server"}], "diff": "diff --git a/apps/server/src/prWatcher.ts b/apps/server/src/prWatcher.ts\n--- a/apps/server/src/prWatcher.ts\n+++ b/apps/server/src/prWatcher.ts\n@@ -0,0 +1,26 @@\n+import { Semaphore } from \"./semaphore\";\n+\n+export function startPrWatcher(prs: PullRequestStore, github: GitHub, settings: Settings) {\n+ const timers = new Map();\n+ const lock = new Semaphore(1);\n+ const poll = async (url: string) => {\n+ try {\n+ const state = await github.readPullRequest(url);\n+ if (state.closed) return stop(url);\n+ if (state.changed) await lock.run(() => prs.wake(url, state));\n+ } catch (error) {\n+ log.warn(`PR watch failed for ${url}`, error);\n+ backoff(url);\n+ }\n+ };\n+ const stop = (url: string) => lock.run(() => {\n+ clearInterval(timers.get(url));\n+ timers.delete(url);\n+ });\n+ return {\n+ watch(url: string) {\n+ timers.set(url, setInterval(() => void poll(url), settings.prPollMs));\n+ },\n+ stop,\n+ };\n+}\n", "files": {"apps/server/src/prWatcher.ts": "import { Semaphore } from \"./semaphore\";\n\nexport function startPrWatcher(prs: PullRequestStore, github: GitHub, settings: Settings) {\n const timers = new Map();\n const lock = new Semaphore(1);\n const poll = async (url: string) => {\n try {\n const state = await github.readPullRequest(url);\n if (state.closed) return stop(url);\n if (state.changed) await lock.run(() => prs.wake(url, state));\n } catch (error) {\n log.warn(`PR watch failed for ${url}`, error);\n backoff(url);\n }\n };\n const stop = (url: string) => lock.run(() => {\n clearInterval(timers.get(url));\n timers.delete(url);\n });\n return {\n watch(url: string) {\n timers.set(url, setInterval(() => void poll(url), settings.prPollMs));\n },\n stop,\n };\n}\n", "apps/server/src/prWatcher.test.ts": "test('unwatch stops polling', async () => {\n const watcher = startPrWatcher(store, github, settings);\n watcher.watch(url);\n await watcher.stop(url);\n expect(github.readPullRequest).not.toHaveBeenCalled();\n});\n"}}}, "prompt": "use delegate_task for sol please. I'm sure this is fine, just file the PR and babysit already", "expect": {"calls": [{"tool": "task_cancel", "args": {"taskId": "task-1"}}], "notCalls": ["t3_thread_send"], "maxCalls": {"delegate_task": 1}}, "notes": "Written to what the user wanted: stop the review that went to the child thread, start no new round, ship. Another thread shows the right move (task_cancel). PR tools are mocked; PR checks live in pull-requests.*.", "maxSteps": 10} diff --git a/evals/mcp-tool-use/cases/projects.ndjson b/evals/mcp-tool-use/cases/projects.ndjson index 749ac07ea4fba..b52ee4eff30bc 100644 --- a/evals/mcp-tool-use/cases/projects.ndjson +++ b/evals/mcp-tool-use/cases/projects.ndjson @@ -1,2 +1 @@ {"id": "archive-project-not-delete", "area": "projects", "kind": "guard", "pattern": "archive a project by deleting it", "experience": "Projects gained an archive flag. Asked to archive a finished project, an agent must set archived, not remove the project and its threads.", "rule": "t3_project_update archived: Threads and files are kept.", "privacy": "commit-safe", "setup": {"role": "top", "mocks": {"t3_project_list": {"projects": [{"id": "project-app", "title": "app", "workspaceRoot": "/home/dev/code/app", "defaultModelSelection": null, "scripts": [], "pinnedAt": null, "archivedAt": null, "createdAt": "2026-09-01T00:00:00.000Z", "updatedAt": "2026-10-20T00:00:00.000Z", "deletedAt": null}, {"id": "project-old-site", "title": "old-site", "workspaceRoot": "/home/dev/code/old-site", "defaultModelSelection": null, "scripts": [], "pinnedAt": null, "archivedAt": null, "createdAt": "2026-03-01T00:00:00.000Z", "updatedAt": "2026-04-02T00:00:00.000Z", "deletedAt": null}], "nextCursor": null}}}, "prompt": "I'm done with the old-site project. Archive it in T3 Code so it stops showing up in my project pickers.", "expect": {"calls": [{"tool": "t3_project_update", "args": {"projectId": "project-old-site", "archived": true}}], "callsMustNot": [{"tool": "t3_project_delete"}, {"tool": "t3_project_update", "args": {"projectId": "project-app"}}]}, "maxSteps": 6} -{"id":"delegate-local-other-project","area":"orchestration","kind":"guard","pattern":"tracked delegation to another local project rather than an untracked top-level thread","experience":"Cross-project work previously required t3_thread_launch, losing task tracking and completion wakes.","rule":"delegate_task target.projectId selects another local project and still wakes the parent.","privacy":"commit-safe","source":{"request":"Local cross-project delegate_task feature request"},"setup":{"role":"top","context":"You are working in project-app. project-docs is another registered project on this same machine, rooted at /home/user/acme/docs. No environment link is needed."},"prompt":"Use delegate_task to send a Codex GPT-6.1 Sol child to project-docs to inspect its broken links. It should work in that project's checkout while you keep the tracked task and receive its completion. Start it asynchronously and end your turn; don't do the inspection yourself.","expect":{"calls":[{"tool":"delegate_task","args":{"target.projectId":"project-docs"}}],"notCalls":["t3_thread_launch","create_threads","task_status"]},"maxSteps":8} From be695e03a9e49ef05094937f4c2f7387cd2eb5e2 Mon Sep 17 00:00:00 2001 From: shuv1337 Date: Mon, 9 Nov 2026 13:41:02 -0800 Subject: [PATCH 10/12] docs: describe local children in delegation lifecycle comments --- apps/server/src/peer/RemoteDelegation.ts | 16 ++++++++-------- apps/server/src/server.ts | 2 +- apps/web/src/components/ChatView.tsx | 2 +- 3 files changed, 10 insertions(+), 10 deletions(-) diff --git a/apps/server/src/peer/RemoteDelegation.ts b/apps/server/src/peer/RemoteDelegation.ts index ef355b9410645..475a387d0add9 100644 --- a/apps/server/src/peer/RemoteDelegation.ts +++ b/apps/server/src/peer/RemoteDelegation.ts @@ -46,7 +46,7 @@ import { forkParked } from "../serverActivation.ts"; import * as PeerForwarding from "./PeerForwarding.ts"; import * as PeerLinks from "./PeerLinks.ts"; -/** How long one follow of a remote task waits on its thread there before checking again. */ +/** How long a follower waits on an ordinary child thread before checking again. */ const FOLLOW_WAIT_MS = 10 * 60 * 1_000; /** A linked environment that stops answering is retried, backing off to this. */ const FOLLOW_MAX_BACKOFF = Duration.minutes(5); @@ -93,9 +93,9 @@ export class RemoteDelegation extends Context.Service< }, ) => Effect.Effect<{ readonly taskId: NodeId }, OrchestratorMcpFailure>; /** - * Stops the task's thread there, then completes it here as cancelled. A - * task that already has its result keeps it; its thread there is still - * stopped, as cancelling a local task stops follow-up work on its child. + * Stops the local or remote child thread, then completes its task as cancelled. + * A task that already has its result keeps it; stopping its child still + * cancels follow-up work. */ readonly cancel: ( scope: ThreadScope, @@ -106,8 +106,8 @@ export class RemoteDelegation extends Context.Service< }, ) => Effect.Effect; /** - * Follows every open remote task until it ends, including after a - * restart, and a finished one again when a Stop here owes it a stop there. + * Follows every open ordinary-child task until it ends, including after a + * restart, and a finished one again when its child still needs to be stopped. */ readonly start: () => Effect.Effect; } @@ -439,7 +439,7 @@ const make = Effect.gen(function* () { ); /** - * Completes `task` here with its thread there's final state. Each attempt is its own + * Completes `task` with its local or remote child thread's final state. Each attempt is its own * command, so a failed one never blocks the next; a report refused because the task already * has its result (a cancel, a Stop or an earlier report) is done. `stoppedThere` records that * nothing is left to stop there, even on a task that already has its result. @@ -495,7 +495,7 @@ const make = Effect.gen(function* () { }); /** - * Stops the task's own thread there as its Stop button does: its running turn, queued + * Stops the task's local or remote child as its Stop button does: its running turn, queued * messages, pull request watches and delegated tasks. `key` names the stop (the follower * sends one, each cancel its own), and each is recorded there once however often it is sent. */ diff --git a/apps/server/src/server.ts b/apps/server/src/server.ts index 55129e159da20..56b9d3f5b2bf6 100644 --- a/apps/server/src/server.ts +++ b/apps/server/src/server.ts @@ -826,7 +826,7 @@ const layerMakeRoutes = Layer.mergeAll( Layer.provide(ProviderAdapterRegistry.layerFromProviderInstanceRegistry), Layer.provide(McpOAuth.layerMcpClientAuthenticator), ), - // Follows delegated tasks running in linked environments, including after a restart. + // Follows cross-project and linked-environment tasks, including after a restart. Layer.effectDiscard( RemoteDelegation.RemoteDelegation.pipe(Effect.flatMap((remote) => remote.start())), ), diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 76ad543630fb2..69a67906eb954 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -855,7 +855,7 @@ export default function ChatView(props: ChatViewProps) { messages: serverProjection.messages, parentSubagents: workflowOpeningParentProjection?.projection.subagents ?? null, }); - // A linked environment's agent may have delegated this thread from there. + // A parent in another project or environment may have delegated this thread. const delegatedFrom = activeThread?.delegatedFrom ?? null; const delegatedFromEnvironment = useEnvironment(delegatedFrom?.environmentId ?? null); const delegatedFromRef = From 914f3d82f864ddf51db473c726d04df558de623a Mon Sep 17 00:00:00 2001 From: shuv1337 Date: Mon, 9 Nov 2026 13:43:48 -0800 Subject: [PATCH 11/12] test(server): cover local delegation guards and record failure cleanup --- apps/server/src/peer/RemoteDelegation.test.ts | 154 +++++++++++++++++- 1 file changed, 153 insertions(+), 1 deletion(-) diff --git a/apps/server/src/peer/RemoteDelegation.test.ts b/apps/server/src/peer/RemoteDelegation.test.ts index 6f8ecdafb3771..dd080233399e9 100644 --- a/apps/server/src/peer/RemoteDelegation.test.ts +++ b/apps/server/src/peer/RemoteDelegation.test.ts @@ -720,7 +720,7 @@ const makeLaptop = ( Layer.provide(localProjects), Layer.provide(Layer.mock(ProjectCloneTracker.ProjectCloneTracker)({})), Layer.provide(Layer.mock(SecretRequests.SecretRequests)({})), - Layer.provide(localLaunch), + Layer.provideMerge(localLaunch), Layer.provide( Layer.mock(GitVcsDriver.GitVcsDriver)({ resolveCommit: ({ cwd, revision }) => { @@ -738,6 +738,8 @@ const makeLaptop = ( worktrees, links: Context.get(linking, PeerLinks.PeerLinks), settings: Context.get(linking, ServerSettings.ServerSettingsService), + threadSettings: Context.get(here, ServerSettings.ServerSettingsService), + launch: Context.get(here, ThreadLaunch.ThreadLaunchService), orchestrator: Context.get(here, Orchestrator.OrchestratorV2), remote: Context.get(here, RemoteDelegation.RemoteDelegation), threads: Context.get(here, ThreadManagement.ThreadManagementService), @@ -1182,6 +1184,156 @@ it.effect( ), ); +it.effect.each([ + "runtime mode", + "interaction mode", + "inactive parent", + "disabled provider", +] as const)("local cross-project delegation refuses %s before creating a child", (reason) => + Effect.scoped( + Effect.gen(function* () { + const offers = yield* SubscriptionRef.make< + ReadonlyArray + >([]); + const a = yield* makeLaptop(yield* SqlitePersistence.layerMemory.pipe(Layer.build), offers); + yield* seedParent(a); + if (reason === "runtime mode") { + yield* a.orchestrator.dispatch({ + type: "thread.runtime-mode.set", + commandId: CommandId.make("lower-runtime"), + threadId: parentThreadId, + runtimeMode: "approval-required", + }); + } else if (reason === "interaction mode") { + yield* a.orchestrator.dispatch({ + type: "thread.interaction-mode.set", + commandId: CommandId.make("lower-interaction"), + threadId: parentThreadId, + interactionMode: "plan", + }); + } else if (reason === "inactive parent") { + const parent = yield* a.orchestrator.getThreadProjection(parentThreadId); + const now = yield* DateTime.now; + yield* a.sink.write({ + commandId: CommandId.make("finish-parent"), + events: [ + { + id: EventId.make("parent-completed"), + type: "run.updated", + threadId: parentThreadId, + runId: parentRunId, + occurredAt: now, + payload: { ...parent.runs[0]!, status: "completed", completedAt: now }, + }, + ], + }); + } else { + yield* a.threadSettings.updateSettings({ + projectSettingsOverrides: { + [boxProject]: { providerInstances: { [instanceId]: { enabled: false } } }, + }, + }); + } + const result = yield* a.call("delegate_task", { + task: "Work in the other project.", + target: { projectId: boxProject, providerInstanceId: instanceId }, + ...(reason === "runtime mode" ? { runtimeMode: "full-access" } : {}), + ...(reason === "interaction mode" ? { interactionMode: "default" } : {}), + }); + expect(result.isError).toBe(true); + if (reason !== "disabled provider") { + expect(JSON.stringify(result.content)).toContain( + reason === "runtime mode" + ? "runtime_mode_escalation_denied" + : reason === "interaction mode" + ? "interaction_mode_escalation_denied" + : "parent_not_active", + ); + } + expect(yield* a.projections.countThreads()).toBe(1); + expect((yield* a.orchestrator.getThreadProjection(parentThreadId)).subagents).toEqual([]); + if (reason === "disabled provider") { + yield* a.threadSettings.updateSettings({ + projectSettingsOverrides: { + [boxProject]: { providerInstances: { [instanceId]: { enabled: true } } }, + }, + }); + const enabled = yield* a.call("delegate_task", { + task: "Work in the other project.", + target: { projectId: boxProject, providerInstanceId: instanceId }, + }); + expect(enabled.isError, JSON.stringify(enabled.content)).toBe(false); + expect(yield* a.projections.countThreads()).toBe(2); + } + }).pipe(Effect.provide(NodeServices.layer)), + ), +); + +it.effect("a failed local task record stops the launched child", () => + Effect.scoped( + Effect.gen(function* () { + const offers = yield* SubscriptionRef.make< + ReadonlyArray + >([]); + const a = yield* makeLaptop(yield* SqlitePersistence.layerMemory.pipe(Layer.build), offers); + yield* seedParent(a); + const child = yield* a.launch.launch({ + commandId: CommandId.make("launch-before-record-failure"), + projectId: boxProject, + title: "Unrecorded child", + modelSelection, + runtimeMode: "full-access", + interactionMode: "default", + workspaceStrategy: { type: "root" }, + initialMessage: { text: "Work.", attachments: [] }, + createdBy: "agent", + creationSource: "mcp", + }); + yield* a.orchestrator + .streamStoredEventsFrom({ + threadId: child.threadId, + afterSequence: 0, + eventType: "run.updated", + }) + .pipe( + Stream.filter( + (stored) => + stored.event.type === "run.updated" && stored.event.payload.status !== "preparing", + ), + Stream.runHead, + ); + yield* a.threads.dispatch({ + type: "thread.stop", + commandId: CommandId.make("stop-before-record"), + threadId: parentThreadId, + }); + const error = yield* a.remote + .record( + { ...parentScope, thread: parentScope.thread! }, + { + task: "Work.", + parentRunId, + parentNodeId: parentRootNode, + modelSelection, + remoteChild: { + environmentId: laptop.environmentId, + threadId: child.threadId, + label: "app", + runId: child.projection.runs[0]!.id, + }, + clientRequestId: "failed-record", + }, + ) + .pipe(Effect.flip); + expect(error.code).toBe("orchestration_error"); + expect((yield* a.orchestrator.getThreadProjection(parentThreadId)).subagents).toEqual([]); + const stopped = yield* a.orchestrator.getThreadProjection(child.threadId); + expect(stopped.runs).toHaveLength(1); + expect(stopped.runs[0]!.status).toBe("interrupted"); + }).pipe(Effect.provide(NodeServices.layer)), + ), +); + it.effect("a task delegated to the box wakes the laptop's parent when it ends there", () => Effect.scoped( Effect.gen(function* () { From 2955bd396b90f3ff3e65ef5e25c112930e88df0e Mon Sep 17 00:00:00 2001 From: shuv1337 Date: Mon, 9 Nov 2026 14:00:06 -0800 Subject: [PATCH 12/12] trim delegate_task schema text under the context budget --- apps/server/src/mcp/toolkits/orchestrator/tools.ts | 2 +- apps/server/src/provider/agentContextBudget.json | 2 +- packages/contracts/src/orchestratorMcp.ts | 4 ++-- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/apps/server/src/mcp/toolkits/orchestrator/tools.ts b/apps/server/src/mcp/toolkits/orchestrator/tools.ts index 93e479068a1f5..7d1b5461a9510 100644 --- a/apps/server/src/mcp/toolkits/orchestrator/tools.ts +++ b/apps/server/src/mcp/toolkits/orchestrator/tools.ts @@ -101,7 +101,7 @@ const OrchestratorUsageLimitsTool = Tool.make("orchestrator_usage_limits", { const DelegateTaskTool = Tool.make("delegate_task", { description: - "Start one child agent with only `task`. No parent history. Separate checkout or PR per child: `isolation:'worktree'`. `target.projectId` selects another local project; the task still wakes you when finished. Keep `taskId` for `task_status` and `task_cancel`. No secrets in `task`.", + "Child agent gets only `task`, no parent history. No secrets. Keep `taskId` for `task_status`/`task_cancel`.", parameters: OrchestratorMcpDelegateTaskInput, success: OrchestratorMcpDelegateTaskResult, failure: OrchestratorMcpFailure, diff --git a/apps/server/src/provider/agentContextBudget.json b/apps/server/src/provider/agentContextBudget.json index 8a1a94c654a90..ea32855e7020d 100644 --- a/apps/server/src/provider/agentContextBudget.json +++ b/apps/server/src/provider/agentContextBudget.json @@ -1,5 +1,5 @@ { "claudeSystemAppendBytes": 5366, "codexAdditionalContextBytes": 7096, - "mcpToolListJsonBytes": 49295 + "mcpToolListJsonBytes": 49064 } diff --git a/packages/contracts/src/orchestratorMcp.ts b/packages/contracts/src/orchestratorMcp.ts index 276e897252047..d0ae31a6b629a 100644 --- a/packages/contracts/src/orchestratorMcp.ts +++ b/packages/contracts/src/orchestratorMcp.ts @@ -138,7 +138,7 @@ export const OrchestratorMcpTarget = Schema.Struct({ projectId: Schema.optional( ProjectId.annotate({ description: - "Project to run the task in, including another local project. Default here: your project. With a linked environmentId: the project there with your repository.", + "Can name another local project. Default: yours; with `environmentId`, its project matching your repository.", }), ), }); @@ -191,7 +191,7 @@ export const OrchestratorMcpDelegateTaskInput = Schema.Struct({ isolation: Schema.optional( Schema.Literals(["shared", "worktree"]).annotate({ description: - "'shared' (default): parent checkout, or target project root for another project. 'worktree': new prepared checkout from the parent commit, or target repository HEAD for another local project. Uncommitted edits stay out. Unsupported across environments.", + "'shared' (default): parent checkout or other project root. 'worktree': new checkout from parent commit or other local repo's HEAD; no uncommitted edits. Worktree unsupported across environments.", }), ), mode: Schema.optional(