| author | |
| committer | |
| log | 25629a76a0458bdeb707483722894158af090b41 |
| tree | d51ab305c1b78d6a1e5994d53af98eef70f5f2c3 |
| parent | 3feae3a94077317e78b7fd4591454ed68f9d4fd2 |
| signature | Signed by PGP key B5690EEEBB952194 |
15 files changed, 454 insertions(+), 80 deletions(-)
apps/server/src/mcp/OrchestratorMcpService.activity.test.ts+11-4| ... | ... | @@ -22,10 +22,17 @@ import * as ProjectService from "../project/ProjectService.ts"; |
| 22 | 22 | import * as ScheduledTaskService from "../scheduledTasks/ScheduledTaskService.ts"; |
| 23 | 23 | import * as ServerSettings from "../serverSettings.ts"; |
| 24 | 24 | import * as SecretRequests from "../secrets/SecretRequests.ts"; |
| 25 | import * as ThreadLaunchService from "../orchestration-v2/ThreadLaunchService.ts"; | |
| 26 | import * as GitVcsDriver from "../vcs/GitVcsDriver.ts"; | |
| 25 | 27 | import * as ThreadManagementService from "../orchestration-v2/ThreadManagementService.ts"; |
| 26 | 28 | import type * as McpInvocationContext from "./McpInvocationContext.ts"; |
| 27 | 29 | import * as OrchestratorMcpService from "./OrchestratorMcpService.ts"; |
| 28 | 30 | |
| 31 | const layerOrchestratorService = OrchestratorMcpService.layer.pipe( | |
| 32 | Layer.provide(Layer.mock(ThreadLaunchService.ThreadLaunchService)({})), | |
| 33 | Layer.provide(Layer.mock(GitVcsDriver.GitVcsDriver)({})), | |
| 34 | ); | |
| 35 | ||
| 29 | 36 | const environmentId = EnvironmentId.make("environment-mcp-orchestrator-detail"); |
| 30 | 37 | const projectId = ProjectId.make("project-mcp-orchestrator-detail"); |
| 31 | 38 | const parentThreadId = ThreadId.make("thread-mcp-orchestrator-parent"); |
| ... | ... | @@ -127,7 +134,7 @@ it("readThread prefers activity-run status over a newer cancelled queued run", a |
| 127 | 134 | updatedAt: now, |
| 128 | 135 | } as unknown as OrchestrationV2ThreadProjection; |
| 129 | 136 | |
| 130 | const layer = OrchestratorMcpService.layer.pipe( | |
| 137 | const layer = layerOrchestratorService.pipe( | |
| 131 | 138 | Layer.provide( |
| 132 | 139 | Layer.mergeAll( |
| 133 | 140 | Layer.mock(ThreadManagementService.ThreadManagementService)({ |
| ... | ... | @@ -193,7 +200,7 @@ it("readThread prefers waiting activity status over a newer cancelled queued run |
| 193 | 200 | updatedAt: now, |
| 194 | 201 | } as unknown as OrchestrationV2ThreadProjection; |
| 195 | 202 | |
| 196 | const layer = OrchestratorMcpService.layer.pipe( | |
| 203 | const layer = layerOrchestratorService.pipe( | |
| 197 | 204 | Layer.provide( |
| 198 | 205 | Layer.mergeAll( |
| 199 | 206 | Layer.mock(ThreadManagementService.ThreadManagementService)({ |
| ... | ... | @@ -316,7 +323,7 @@ it("taskStatus returns task.providerInstanceId rather than the driver kind", asy |
| 316 | 323 | updatedAt: now, |
| 317 | 324 | } as unknown as OrchestrationV2ThreadProjection; |
| 318 | 325 | |
| 319 | const layer = OrchestratorMcpService.layer.pipe( | |
| 326 | const layer = layerOrchestratorService.pipe( | |
| 320 | 327 | Layer.provide( |
| 321 | 328 | Layer.mergeAll( |
| 322 | 329 | Layer.mock(ThreadManagementService.ThreadManagementService)({ |
| ... | ... | @@ -415,7 +422,7 @@ it("readThread and sendToThread reach threads in other projects", async () => { |
| 415 | 422 | updatedAt: now, |
| 416 | 423 | }) as unknown as OrchestrationV2ThreadProjection; |
| 417 | 424 | |
| 418 | const layer = OrchestratorMcpService.layer.pipe( | |
| 425 | const layer = layerOrchestratorService.pipe( | |
| 419 | 426 | Layer.provide( |
| 420 | 427 | Layer.mergeAll( |
| 421 | 428 | Layer.mock(ThreadManagementService.ThreadManagementService)({ |
apps/server/src/mcp/OrchestratorMcpService.test.ts+27-28| ... | ... | @@ -29,6 +29,8 @@ import { |
| 29 | 29 | OrchestratorThreadAboveModeLimitError, |
| 30 | 30 | } from "../orchestration-v2/Orchestrator.ts"; |
| 31 | 31 | import * as ProviderAdapterRegistry from "../orchestration-v2/ProviderAdapterRegistry.ts"; |
| 32 | import * as ThreadLaunchService from "../orchestration-v2/ThreadLaunchService.ts"; | |
| 33 | import * as GitVcsDriver from "../vcs/GitVcsDriver.ts"; | |
| 32 | 34 | import * as ThreadManagementService from "../orchestration-v2/ThreadManagementService.ts"; |
| 33 | 35 | import * as ProviderRegistry from "../provider/ProviderRegistry.ts"; |
| 34 | 36 | import { buildUnavailableProviderSnapshot } from "../provider/unavailableProviderSnapshot.ts"; |
| ... | ... | @@ -41,6 +43,11 @@ import { idleThreadProjection, liveThreadShell } from "./McpToolAccess.testkit.t |
| 41 | 43 | import * as OrchestratorMcpService from "./OrchestratorMcpService.ts"; |
| 42 | 44 | import type * as ProviderAdapter from "@t3tools/provider-core/server/ProviderAdapter"; |
| 43 | 45 | |
| 46 | const layerOrchestratorService = OrchestratorMcpService.layer.pipe( | |
| 47 | Layer.provide(Layer.mock(ThreadLaunchService.ThreadLaunchService)({})), | |
| 48 | Layer.provide(Layer.mock(GitVcsDriver.GitVcsDriver)({})), | |
| 49 | ); | |
| 50 | ||
| 44 | 51 | describe("OrchestratorMcpService", () => { |
| 45 | 52 | it.effect("retries terminal acknowledgement with a fresh command id", () => |
| 46 | 53 | Effect.gen(function* () { |
| ... | ... | @@ -170,7 +177,7 @@ describe("OrchestratorMcpService", () => { |
| 170 | 177 | const commandIds = yield* Ref.get(acknowledgementCommandIds); |
| 171 | 178 | assert.equal(commandIds.length, 2); |
| 172 | 179 | assert.notEqual(commandIds[0], commandIds[1]); |
| 173 | }).pipe(Effect.provide(OrchestratorMcpService.layer.pipe(Layer.provide(layerDependencies)))); | |
| 180 | }).pipe(Effect.provide(layerOrchestratorService.pipe(Layer.provide(layerDependencies)))); | |
| 174 | 181 | }), |
| 175 | 182 | ); |
| 176 | 183 | |
| ... | ... | @@ -267,7 +274,7 @@ describe("OrchestratorMcpService", () => { |
| 267 | 274 | const settled = yield* service.taskStatus(scope, taskId); |
| 268 | 275 | assert.equal(settled.status, "cancelled"); |
| 269 | 276 | assert.equal(yield* Ref.get(dispatched), 1); |
| 270 | }).pipe(Effect.provide(OrchestratorMcpService.layer.pipe(Layer.provide(layerDependencies)))); | |
| 277 | }).pipe(Effect.provide(layerOrchestratorService.pipe(Layer.provide(layerDependencies)))); | |
| 271 | 278 | }), |
| 272 | 279 | ); |
| 273 | 280 | |
| ... | ... | @@ -343,7 +350,7 @@ describe("OrchestratorMcpService", () => { |
| 343 | 350 | .pipe(Effect.flip); |
| 344 | 351 | assert.equal(error.code, "task_not_cancellable"); |
| 345 | 352 | assert.deepEqual(yield* Ref.get(dispatched), []); |
| 346 | }).pipe(Effect.provide(OrchestratorMcpService.layer.pipe(Layer.provide(layerDependencies)))); | |
| 353 | }).pipe(Effect.provide(layerOrchestratorService.pipe(Layer.provide(layerDependencies)))); | |
| 347 | 354 | }), |
| 348 | 355 | ); |
| 349 | 356 | |
| ... | ... | @@ -423,7 +430,7 @@ describe("OrchestratorMcpService", () => { |
| 423 | 430 | (yield* Ref.get(dispatched)).map((command) => (command as { type: string }).type), |
| 424 | 431 | ["thread.stop"], |
| 425 | 432 | ); |
| 426 | }).pipe(Effect.provide(OrchestratorMcpService.layer.pipe(Layer.provide(layerDependencies)))); | |
| 433 | }).pipe(Effect.provide(layerOrchestratorService.pipe(Layer.provide(layerDependencies)))); | |
| 427 | 434 | }), |
| 428 | 435 | ); |
| 429 | 436 | |
| ... | ... | @@ -509,7 +516,7 @@ describe("OrchestratorMcpService", () => { |
| 509 | 516 | (yield* Ref.get(dispatched)).map((command) => (command as { type: string }).type), |
| 510 | 517 | ["thread.stop", "delegated_task.completion-delivery.dispose"], |
| 511 | 518 | ); |
| 512 | }).pipe(Effect.provide(OrchestratorMcpService.layer.pipe(Layer.provide(layerDependencies)))); | |
| 519 | }).pipe(Effect.provide(layerOrchestratorService.pipe(Layer.provide(layerDependencies)))); | |
| 513 | 520 | }), |
| 514 | 521 | ); |
| 515 | 522 | |
| ... | ... | @@ -592,7 +599,7 @@ describe("OrchestratorMcpService", () => { |
| 592 | 599 | .pipe(Effect.flip); |
| 593 | 600 | assert.equal(error.code, "runtime_mode_escalation_denied"); |
| 594 | 601 | assert.deepEqual(yield* Ref.get(dispatched), []); |
| 595 | }).pipe(Effect.provide(OrchestratorMcpService.layer.pipe(Layer.provide(layerDependencies)))); | |
| 602 | }).pipe(Effect.provide(layerOrchestratorService.pipe(Layer.provide(layerDependencies)))); | |
| 596 | 603 | }), |
| 597 | 604 | ); |
| 598 | 605 | |
| ... | ... | @@ -736,7 +743,7 @@ describe("OrchestratorMcpService", () => { |
| 736 | 743 | dispatched: yield* Ref.get(dispatched), |
| 737 | 744 | stoppedBelow: yield* Ref.get(stoppedBelow), |
| 738 | 745 | }; |
| 739 | }).pipe(Effect.provide(OrchestratorMcpService.layer.pipe(Layer.provide(layerDependencies)))); | |
| 746 | }).pipe(Effect.provide(layerOrchestratorService.pipe(Layer.provide(layerDependencies)))); | |
| 740 | 747 | }); |
| 741 | 748 | |
| 742 | 749 | it.effect("refuses to cancel a task when a task under it now runs above the parent's modes", () => |
| ... | ... | @@ -1019,9 +1026,7 @@ describe("OrchestratorMcpService provider resolution", () => { |
| 1019 | 1026 | assert.isTrue( |
| 1020 | 1027 | fork!.constraints.includes("Driver 'forkOnly' is not registered in this build."), |
| 1021 | 1028 | ); |
| 1022 | }).pipe( | |
| 1023 | Effect.provide(OrchestratorMcpService.layer.pipe(Layer.provide(layerDependencies))), | |
| 1024 | ); | |
| 1029 | }).pipe(Effect.provide(layerOrchestratorService.pipe(Layer.provide(layerDependencies)))); | |
| 1025 | 1030 | }), |
| 1026 | 1031 | ); |
| 1027 | 1032 | |
| ... | ... | @@ -1066,7 +1071,7 @@ describe("OrchestratorMcpService provider resolution", () => { |
| 1066 | 1071 | capabilities.providers[0]?.models.map((model) => model.id), |
| 1067 | 1072 | ["gpt-5.4", "my-model"], |
| 1068 | 1073 | ); |
| 1069 | }).pipe(Effect.provide(OrchestratorMcpService.layer.pipe(Layer.provide(layerDependencies)))); | |
| 1074 | }).pipe(Effect.provide(layerOrchestratorService.pipe(Layer.provide(layerDependencies)))); | |
| 1070 | 1075 | }), |
| 1071 | 1076 | ); |
| 1072 | 1077 | |
| ... | ... | @@ -1166,9 +1171,7 @@ describe("OrchestratorMcpService provider resolution", () => { |
| 1166 | 1171 | assert.equal(request.type, "delegated_task.request"); |
| 1167 | 1172 | assert.equal(request.modelSelection.instanceId, antigravityInstanceId); |
| 1168 | 1173 | assert.equal(request.modelSelection.model, "ant-model"); |
| 1169 | }).pipe( | |
| 1170 | Effect.provide(OrchestratorMcpService.layer.pipe(Layer.provide(layerDependencies))), | |
| 1171 | ); | |
| 1174 | }).pipe(Effect.provide(layerOrchestratorService.pipe(Layer.provide(layerDependencies)))); | |
| 1172 | 1175 | }), |
| 1173 | 1176 | ); |
| 1174 | 1177 | |
| ... | ... | @@ -1264,7 +1267,7 @@ describe("OrchestratorMcpService provider resolution", () => { |
| 1264 | 1267 | }; |
| 1265 | 1268 | assert.equal(request.modelSelection.instanceId, antigravityInstanceId); |
| 1266 | 1269 | assert.equal(request.modelSelection.model, "ant-model"); |
| 1267 | }).pipe(Effect.provide(OrchestratorMcpService.layer.pipe(Layer.provide(layerDependencies)))); | |
| 1270 | }).pipe(Effect.provide(layerOrchestratorService.pipe(Layer.provide(layerDependencies)))); | |
| 1268 | 1271 | }), |
| 1269 | 1272 | ); |
| 1270 | 1273 | |
| ... | ... | @@ -1323,7 +1326,7 @@ describe("OrchestratorMcpService provider resolution", () => { |
| 1323 | 1326 | assert.isTrue( |
| 1324 | 1327 | byDriver.message.includes("No V2 provider adapter is registered for driver forkOnly."), |
| 1325 | 1328 | ); |
| 1326 | }).pipe(Effect.provide(OrchestratorMcpService.layer.pipe(Layer.provide(layerDependencies)))); | |
| 1329 | }).pipe(Effect.provide(layerOrchestratorService.pipe(Layer.provide(layerDependencies)))); | |
| 1327 | 1330 | }), |
| 1328 | 1331 | ); |
| 1329 | 1332 | |
| ... | ... | @@ -1369,7 +1372,7 @@ describe("OrchestratorMcpService provider resolution", () => { |
| 1369 | 1372 | }) |
| 1370 | 1373 | .pipe(Effect.flip); |
| 1371 | 1374 | assert.equal(error.code, "invalid_request"); |
| 1372 | }).pipe(Effect.provide(OrchestratorMcpService.layer.pipe(Layer.provide(layerDependencies)))); | |
| 1375 | }).pipe(Effect.provide(layerOrchestratorService.pipe(Layer.provide(layerDependencies)))); | |
| 1373 | 1376 | }), |
| 1374 | 1377 | ); |
| 1375 | 1378 | |
| ... | ... | @@ -1474,7 +1477,7 @@ describe("OrchestratorMcpService provider resolution", () => { |
| 1474 | 1477 | modelSelection: { instanceId: string; model: string }; |
| 1475 | 1478 | }; |
| 1476 | 1479 | assert.equal(request.modelSelection.instanceId, altInstanceId); |
| 1477 | }).pipe(Effect.provide(OrchestratorMcpService.layer.pipe(Layer.provide(layerDependencies)))); | |
| 1480 | }).pipe(Effect.provide(layerOrchestratorService.pipe(Layer.provide(layerDependencies)))); | |
| 1478 | 1481 | }), |
| 1479 | 1482 | ); |
| 1480 | 1483 | |
| ... | ... | @@ -1599,9 +1602,7 @@ describe("OrchestratorMcpService provider resolution", () => { |
| 1599 | 1602 | modelSelection: { instanceId: string; model: string }; |
| 1600 | 1603 | }; |
| 1601 | 1604 | assert.equal(retryCommand.modelSelection.instanceId, codexInstanceId); |
| 1602 | }).pipe( | |
| 1603 | Effect.provide(OrchestratorMcpService.layer.pipe(Layer.provide(layerDependencies))), | |
| 1604 | ); | |
| 1605 | }).pipe(Effect.provide(layerOrchestratorService.pipe(Layer.provide(layerDependencies)))); | |
| 1605 | 1606 | }), |
| 1606 | 1607 | ); |
| 1607 | 1608 | |
| ... | ... | @@ -1706,7 +1707,7 @@ describe("OrchestratorMcpService provider resolution", () => { |
| 1706 | 1707 | assert.equal(result.providerInstanceId, claudeInstanceId); |
| 1707 | 1708 | assert.equal(yield* Ref.get(probes), 2); |
| 1708 | 1709 | assert.equal(yield* Ref.get(dispatched), 1); |
| 1709 | }).pipe(Effect.provide(OrchestratorMcpService.layer.pipe(Layer.provide(layerDependencies)))); | |
| 1710 | }).pipe(Effect.provide(layerOrchestratorService.pipe(Layer.provide(layerDependencies)))); | |
| 1710 | 1711 | }), |
| 1711 | 1712 | ); |
| 1712 | 1713 | |
| ... | ... | @@ -1917,9 +1918,7 @@ describe("OrchestratorMcpService provider resolution", () => { |
| 1917 | 1918 | } else { |
| 1918 | 1919 | assert.equal(request.modelSelection.model, "codex-alt-model", testCase.name); |
| 1919 | 1920 | } |
| 1920 | }).pipe( | |
| 1921 | Effect.provide(OrchestratorMcpService.layer.pipe(Layer.provide(layerDependencies))), | |
| 1922 | ); | |
| 1921 | }).pipe(Effect.provide(layerOrchestratorService.pipe(Layer.provide(layerDependencies)))); | |
| 1923 | 1922 | } |
| 1924 | 1923 | }), |
| 1925 | 1924 | ); |
| ... | ... | @@ -1972,7 +1971,7 @@ describe("OrchestratorMcpService provider resolution", () => { |
| 1972 | 1971 | boundThread: OrchestrationV2ThreadShell | null, |
| 1973 | 1972 | upserted: Ref.Ref<number>, |
| 1974 | 1973 | ) => |
| 1975 | OrchestratorMcpService.layer.pipe( | |
| 1974 | layerOrchestratorService.pipe( | |
| 1976 | 1975 | Layer.provide( |
| 1977 | 1976 | Layer.mergeAll( |
| 1978 | 1977 | NodeServices.layer, |
| ... | ... | @@ -2040,7 +2039,7 @@ describe("OrchestratorMcpService provider resolution", () => { |
| 2040 | 2039 | ), |
| 2041 | 2040 | ), |
| 2042 | 2041 | Effect.provide( |
| 2043 | OrchestratorMcpService.layer.pipe( | |
| 2042 | layerOrchestratorService.pipe( | |
| 2044 | 2043 | Layer.provide( |
| 2045 | 2044 | Layer.mergeAll( |
| 2046 | 2045 | NodeServices.layer, |
| ... | ... | @@ -2123,7 +2122,7 @@ describe("OrchestratorMcpService provider resolution", () => { |
| 2123 | 2122 | // The edit's own check reads the thread; the read after the save fails. |
| 2124 | 2123 | const mcp = yield* OrchestratorMcpService.OrchestratorMcpService.pipe( |
| 2125 | 2124 | Effect.provide( |
| 2126 | OrchestratorMcpService.layer.pipe( | |
| 2125 | layerOrchestratorService.pipe( | |
| 2127 | 2126 | Layer.provide( |
| 2128 | 2127 | Layer.mergeAll( |
| 2129 | 2128 | NodeServices.layer, |
apps/server/src/mcp/OrchestratorMcpService.ts+39| ... | ... | @@ -10,6 +10,7 @@ import { |
| 10 | 10 | type OrchestrationV2Subagent, |
| 11 | 11 | type OrchestrationV2ThreadProjection, |
| 12 | 12 | type OrchestrationV2ThreadShell, |
| 13 | type OrchestrationV2ThreadLaunchWorkspaceStrategy, | |
| 13 | 14 | type OrchestrationV2TurnItem, |
| 14 | 15 | OrchestratorMcpFailure, |
| 15 | 16 | type OrchestratorMcpCapabilitiesResult, |
| ... | ... | @@ -81,6 +82,8 @@ import { |
| 81 | 82 | type DispatchModeRefusal, |
| 82 | 83 | } from "../orchestration-v2/DispatchModeLimit.ts"; |
| 83 | 84 | import * as ThreadManagementService from "../orchestration-v2/ThreadManagementService.ts"; |
| 85 | import * as ThreadLaunchService from "../orchestration-v2/ThreadLaunchService.ts"; | |
| 86 | import * as GitVcsDriver from "../vcs/GitVcsDriver.ts"; | |
| 84 | 87 | import { isSnoozed } from "../orchestration-v2/ThreadSettlementService.ts"; |
| 85 | 88 | import * as ProjectService from "../project/ProjectService.ts"; |
| 86 | 89 | import * as ProviderRegistry from "../provider/ProviderRegistry.ts"; |
| ... | ... | @@ -904,6 +907,8 @@ const make = Effect.gen(function* () { |
| 904 | 907 | const providerAdapters = yield* ProviderAdapterRegistry.ProviderAdapterRegistryV2; |
| 905 | 908 | const scheduledTasks = yield* ScheduledTaskService.ScheduledTaskService; |
| 906 | 909 | const projects = yield* ProjectService.ProjectService; |
| 910 | const threadLaunch = yield* ThreadLaunchService.ThreadLaunchService; | |
| 911 | const gitDriver = yield* GitVcsDriver.GitVcsDriver; | |
| 907 | 912 | const serverSettings = yield* ServerSettings.ServerSettingsService; |
| 908 | 913 | |
| 909 | 914 | /** A caller-named project, which must exist before anything is recorded against it. */ |
| ... | ... | @@ -1993,6 +1998,21 @@ const make = Effect.gen(function* () { |
| 1993 | 1998 | parent.thread.interactionMode, |
| 1994 | 1999 | input.interactionMode, |
| 1995 | 2000 | ); |
| 2001 | let workspaceStrategy: OrchestrationV2ThreadLaunchWorkspaceStrategy | undefined; | |
| 2002 | if (input.isolation === "worktree" && !isRetryOfAcceptedDelegation) { | |
| 2003 | const project = yield* requireProject(parent.thread.projectId); | |
| 2004 | const base = yield* gitDriver | |
| 2005 | .resolveCommit({ | |
| 2006 | cwd: parent.thread.worktreePath ?? project.workspaceRoot, | |
| 2007 | revision: "HEAD", | |
| 2008 | }) | |
| 2009 | .pipe( | |
| 2010 | Effect.mapError(() => | |
| 2011 | failure("invalid_request", "Unable to resolve the parent checkout commit."), | |
| 2012 | ), | |
| 2013 | ); | |
| 2014 | workspaceStrategy = { type: "worktree", baseRef: base.commitSha, startFromOrigin: false }; | |
| 2015 | } | |
| 1996 | 2016 | // ThreadManagementService.dispatch is the chokepoint for this command |
| 1997 | 2017 | // type's own enablement check, using the same command id and its |
| 1998 | 2018 | // receipt-replay exemption, so no separate assert runs here. |
| ... | ... | @@ -2010,6 +2030,7 @@ const make = Effect.gen(function* () { |
| 2010 | 2030 | modelSelection: target.modelSelection, |
| 2011 | 2031 | runtimeMode, |
| 2012 | 2032 | interactionMode, |
| 2033 | ...(workspaceStrategy === undefined ? {} : { workspaceStrategy }), | |
| 2013 | 2034 | // Async delegations wake the parent on every child terminal; wait |
| 2014 | 2035 | // delegations deliver through the blocking tool call, so a wake is |
| 2015 | 2036 | // only needed if the parent settled first (timeout, disconnect). |
| ... | ... | @@ -2036,6 +2057,22 @@ const make = Effect.gen(function* () { |
| 2036 | 2057 | ); |
| 2037 | 2058 | } |
| 2038 | 2059 | const taskId = taskEvent.event.payload.id; |
| 2060 | const childThreadId = taskEvent.event.payload.childThreadId; | |
| 2061 | const childRunEvent = result.storedEvents.find( | |
| 2062 | (stored) => | |
| 2063 | stored.event.type === "run.created" && stored.event.threadId === childThreadId, | |
| 2064 | ); | |
| 2065 | if ( | |
| 2066 | childThreadId !== null && | |
| 2067 | childRunEvent?.event.type === "run.created" && | |
| 2068 | childRunEvent.event.payload.workspacePreparation !== undefined | |
| 2069 | ) { | |
| 2070 | yield* threadLaunch.prepareRun({ | |
| 2071 | commandId, | |
| 2072 | threadId: childThreadId, | |
| 2073 | runId: childRunEvent.event.payload.id, | |
| 2074 | }); | |
| 2075 | } | |
| 2039 | 2076 | |
| 2040 | 2077 | if (input.mode !== "wait") { |
| 2041 | 2078 | return yield* readTask(scope, taskId, false, true); |
| ... | ... | @@ -2660,6 +2697,8 @@ export const layer: Layer.Layer< |
| 2660 | 2697 | never, |
| 2661 | 2698 | | Crypto.Crypto |
| 2662 | 2699 | | ThreadManagementService.ThreadManagementService |
| 2700 | | ThreadLaunchService.ThreadLaunchService | |
| 2701 | | GitVcsDriver.GitVcsDriver | |
| 2663 | 2702 | | ProviderRegistry.ProviderRegistry |
| 2664 | 2703 | | ProviderAdapterRegistry.ProviderAdapterRegistryV2 |
| 2665 | 2704 | | ScheduledTaskService.ScheduledTaskService |
apps/server/src/mcp/OrchestratorMcpToolkit.integration.test.ts+9-2| ... | ... | @@ -57,6 +57,8 @@ import { threadShellFromProjection } from "../orchestration-v2/ProjectionStore.t |
| 57 | 57 | import * as CommandReceiptStore from "../orchestration-v2/CommandReceiptStore.ts"; |
| 58 | 58 | import * as EventSink from "../orchestration-v2/EventSink.ts"; |
| 59 | 59 | import * as Orchestrator from "../orchestration-v2/Orchestrator.ts"; |
| 60 | import * as ThreadLaunchService from "../orchestration-v2/ThreadLaunchService.ts"; | |
| 61 | import * as GitVcsDriver from "../vcs/GitVcsDriver.ts"; | |
| 60 | 62 | import * as ThreadManagementService from "../orchestration-v2/ThreadManagementService.ts"; |
| 61 | 63 | import * as ServerSettings from "../serverSettings.ts"; |
| 62 | 64 | import * as SqlitePersistence from "../persistence/Sqlite.ts"; |
| ... | ... | @@ -80,6 +82,11 @@ import * as McpInvocationContext from "./McpInvocationContext.ts"; |
| 80 | 82 | import { delegatedTaskRun, hasPendingChildRuns } from "./OrchestratorMcpService.ts"; |
| 81 | 83 | import * as ProviderAdapter from "@t3tools/provider-core/server/ProviderAdapter"; |
| 82 | 84 | |
| 85 | const layerOrchestratorToolkit = McpHttpServer.layerOrchestratorToolkit.pipe( | |
| 86 | Layer.provide(Layer.mock(ThreadLaunchService.ThreadLaunchService)({})), | |
| 87 | Layer.provide(Layer.mock(GitVcsDriver.GitVcsDriver)({})), | |
| 88 | ); | |
| 89 | ||
| 83 | 90 | // Effect returns a declared tool failure as `isError` with its encoded payload |
| 84 | 91 | // as JSON text, never as `structuredContent`. |
| 85 | 92 | const declaredFailure = (result: McpSchema.CallToolResult) => { |
| ... | ... | @@ -682,7 +689,7 @@ describe("orchestrator MCP toolkit", () => { |
| 682 | 689 | }), |
| 683 | 690 | ); |
| 684 | 691 | const layerTest = Layer.merge( |
| 685 | McpHttpServer.layerOrchestratorToolkit, | |
| 692 | layerOrchestratorToolkit, | |
| 686 | 693 | McpHttpServer.layerThreadToolkit, |
| 687 | 694 | ).pipe( |
| 688 | 695 | Layer.provideMerge(McpServer.McpServer.layer), |
| ... | ... | @@ -3838,7 +3845,7 @@ describe("orchestrator MCP toolkit", () => { |
| 3838 | 3845 | model: codexModel, |
| 3839 | 3846 | }), |
| 3840 | 3847 | ]); |
| 3841 | const layerTest = McpHttpServer.layerOrchestratorToolkit.pipe( | |
| 3848 | const layerTest = layerOrchestratorToolkit.pipe( | |
| 3842 | 3849 | Layer.provideMerge(McpServer.McpServer.layer), |
| 3843 | 3850 | Layer.provideMerge(layerOrchestration), |
| 3844 | 3851 | Layer.provide( |
apps/server/src/mcp/TaskCancelNativeSubagent.integration.test.ts+8-1| ... | ... | @@ -27,6 +27,8 @@ import * as EffectWorker from "../orchestration-v2/EffectWorker.ts"; |
| 27 | 27 | import * as Orchestrator from "../orchestration-v2/Orchestrator.ts"; |
| 28 | 28 | import * as ProviderAdapter from "@t3tools/provider-core/server/ProviderAdapter"; |
| 29 | 29 | import * as ProviderAdapterRegistry from "../orchestration-v2/ProviderAdapterRegistry.ts"; |
| 30 | import * as ThreadLaunchService from "../orchestration-v2/ThreadLaunchService.ts"; | |
| 31 | import * as GitVcsDriver from "../vcs/GitVcsDriver.ts"; | |
| 30 | 32 | import * as ThreadManagementService from "../orchestration-v2/ThreadManagementService.ts"; |
| 31 | 33 | import * as ServerSettings from "../serverSettings.ts"; |
| 32 | 34 | import * as ProviderReplayHarness from "../orchestration-v2/testkit/ProviderReplayHarness.ts"; |
| ... | ... | @@ -39,6 +41,11 @@ import * as SecretRequests from "../secrets/SecretRequests.ts"; |
| 39 | 41 | import type { McpInvocationScope } from "./McpInvocationContext.ts"; |
| 40 | 42 | import * as OrchestratorMcpService from "./OrchestratorMcpService.ts"; |
| 41 | 43 | |
| 44 | const layerOrchestratorService = OrchestratorMcpService.layer.pipe( | |
| 45 | Layer.provide(Layer.mock(ThreadLaunchService.ThreadLaunchService)({})), | |
| 46 | Layer.provide(Layer.mock(GitVcsDriver.GitVcsDriver)({})), | |
| 47 | ); | |
| 48 | ||
| 42 | 49 | const driver = ProviderDriverKind.make("claudeAgent"); |
| 43 | 50 | const instanceId = ProviderInstanceId.make("claudeAgent"); |
| 44 | 51 | const modelSelection = { instanceId, model: "claude-test" }; |
| ... | ... | @@ -143,7 +150,7 @@ it.effect("task_cancel ends a delegated child's background native subagent", () |
| 143 | 150 | ), |
| 144 | 151 | ), |
| 145 | 152 | ); |
| 146 | const layerTest = OrchestratorMcpService.layer.pipe( | |
| 153 | const layerTest = layerOrchestratorService.pipe( | |
| 147 | 154 | Layer.provideMerge(layerOrchestration), |
| 148 | 155 | Layer.provide( |
| 149 | 156 | Layer.mergeAll( |
apps/server/src/mcp/toolkits/core.test.ts+11-4| ... | ... | @@ -35,6 +35,8 @@ import { |
| 35 | 35 | import * as ServerSecretStore from "../../auth/ServerSecretStore.ts"; |
| 36 | 36 | import * as ServerConfig from "../../config.ts"; |
| 37 | 37 | import * as ProviderAdapterRegistry from "../../orchestration-v2/ProviderAdapterRegistry.ts"; |
| 38 | import * as ThreadLaunchService from "../../orchestration-v2/ThreadLaunchService.ts"; | |
| 39 | import * as GitVcsDriver from "../../vcs/GitVcsDriver.ts"; | |
| 38 | 40 | import * as ThreadManagement from "../../orchestration-v2/ThreadManagementService.ts"; |
| 39 | 41 | import * as ThreadSearch from "../../orchestration-v2/ThreadSearch.ts"; |
| 40 | 42 | import * as PreviewBrowser from "../../preview/PreviewBrowser.ts"; |
| ... | ... | @@ -59,6 +61,11 @@ import { ThreadToolkit } from "./thread/tools.ts"; |
| 59 | 61 | import { WorktreeToolkit } from "./worktree/tools.ts"; |
| 60 | 62 | import { DeviceToolkit } from "./device/tools.ts"; |
| 61 | 63 | |
| 64 | const layerOrchestratorToolkit = McpHttpServer.layerOrchestratorToolkit.pipe( | |
| 65 | Layer.provide(Layer.mock(ThreadLaunchService.ThreadLaunchService)({})), | |
| 66 | Layer.provide(Layer.mock(GitVcsDriver.GitVcsDriver)({})), | |
| 67 | ); | |
| 68 | ||
| 62 | 69 | // Effect returns a declared tool failure as `isError` with its encoded payload |
| 63 | 70 | // as JSON text, never as `structuredContent`. |
| 64 | 71 | const declaredFailure = (result: McpSchema.CallToolResult) => { |
| ... | ... | @@ -578,7 +585,7 @@ it.effect("refuses act-as-caller tools to a client caller", () => |
| 578 | 585 | expect(declaredFailure(result)).toMatchObject({ code: "thread_credential_required" }); |
| 579 | 586 | }).pipe( |
| 580 | 587 | Effect.provide( |
| 581 | McpHttpServer.layerOrchestratorToolkit.pipe( | |
| 588 | layerOrchestratorToolkit.pipe( | |
| 582 | 589 | Layer.provideMerge(McpServer.McpServer.layer), |
| 583 | 590 | Layer.provide(NodeCrypto.layer), |
| 584 | 591 | Layer.provide(Layer.mock(ThreadManagement.ThreadManagementService)({})), |
| ... | ... | @@ -617,7 +624,7 @@ it.effect("a caller cannot rewrite a scheduled task that runs above its own mode |
| 617 | 624 | expect(allowed.isError).toBe(false); |
| 618 | 625 | }).pipe( |
| 619 | 626 | Effect.provide( |
| 620 | McpHttpServer.layerOrchestratorToolkit.pipe( | |
| 627 | layerOrchestratorToolkit.pipe( | |
| 621 | 628 | Layer.provideMerge(McpServer.McpServer.layer), |
| 622 | 629 | Layer.provide(NodeCrypto.layer), |
| 623 | 630 | Layer.provide(Layer.mock(ThreadManagement.ThreadManagementService)({})), |
| ... | ... | @@ -692,7 +699,7 @@ it.effect.each(["t3_thread_interrupt", "t3_workflow_stop"])( |
| 692 | 699 | expect(declaredFailure(result)).toMatchObject({ code: "runtime_mode_escalation_denied" }); |
| 693 | 700 | }).pipe( |
| 694 | 701 | Effect.provide( |
| 695 | McpHttpServer.layerOrchestratorToolkit.pipe( | |
| 702 | layerOrchestratorToolkit.pipe( | |
| 696 | 703 | Layer.provideMerge(McpServer.McpServer.layer), |
| 697 | 704 | Layer.provide(NodeCrypto.layer), |
| 698 | 705 | Layer.provide( |
| ... | ... | @@ -796,7 +803,7 @@ it.effect("discovers workflow stop targets and requires orchestration capability |
| 796 | 803 | expect(stopped).toEqual([{ threadId: "workflow-parent", subagentId: "workflow-coordinator" }]); |
| 797 | 804 | }).pipe( |
| 798 | 805 | Effect.provide( |
| 799 | McpHttpServer.layerOrchestratorToolkit.pipe( | |
| 806 | layerOrchestratorToolkit.pipe( | |
| 800 | 807 | Layer.provideMerge(McpServer.McpServer.layer), |
| 801 | 808 | Layer.provide(NodeCrypto.layer), |
| 802 | 809 | Layer.provide( |
apps/server/src/mcp/toolkits/orchestrator/tools.ts+1-1| ... | ... | @@ -65,7 +65,7 @@ const OrchestratorCapabilitiesTool = Tool.make("orchestrator_capabilities", { |
| 65 | 65 | |
| 66 | 66 | export const DelegateTaskTool = Tool.make("delegate_task", { |
| 67 | 67 | description: |
| 68 | "Needs an agent running inside a T3 thread. Delegate one task to a T3-owned child agent/subagent of THIS thread and run it with only the supplied task prompt, without copying parent conversation history. Choose providers and models from orchestrator_capabilities, which uses the same live catalog as the composer. Prefer native subagent tools for same-provider work only when they support the chosen model. Use this for any model missing from the native tool, including same-provider work, for cross-provider work, or for explicitly T3-owned child tasks. For every T3 delegated review round, call delegate_task again with the original brief, prior findings, responses, and unresolved objections in the task prompt. Track each round by its own taskId and use a distinct clientRequestId per round, stable across retries of that round. The childThreadId is backing storage, not the target for starting another delegated review round through t3_thread_send. Provider, model, model options (see orchestrator_capabilities), runtime mode, and interaction mode inherit unless target overrides them. A requested runtimeMode must be one the target provider lists in orchestrator_capabilities runtimeModes; an inherited one steps down to the broadest mode it offers. Prefer mode='async' for long work; mode='wait' blocks until completion or timeout. timeoutMs on mode=wait is only the parent's wait budget and does not cancel the child. waitTimedOut on that wait call means the timeout fired; keep that taskId and read status on later task_status. An async child's completion wakes this thread through a notification, steered into active turns where supported or queued otherwise, so end the turn instead of polling or spawning watchers; use task_status only when the result is needed mid-turn.", | |
| 68 | "Needs an agent running inside a T3 thread. Delegate one task to a T3-owned child agent/subagent of THIS thread and run it with only the supplied task prompt, without copying parent conversation history. Choose providers and models from orchestrator_capabilities, which uses the same live catalog as the composer. Prefer native subagent tools for same-provider work only when they support the chosen model. Use this for any model missing from the native tool, including same-provider work, for cross-provider work, or for explicitly T3-owned child tasks. For every T3 delegated review round, call delegate_task again with the original brief, prior findings, responses, and unresolved objections in the task prompt. Track each round by its own taskId and use a distinct clientRequestId per round, stable across retries of that round. The childThreadId is backing storage, not the target for starting another delegated review round through t3_thread_send. Set isolation to worktree for a separate checkout based on the parent local commit, with project setup completed before the child starts. Uncommitted edits are not copied; omitted isolation shares the parent checkout. Provider, model, model options (see orchestrator_capabilities), runtime mode, and interaction mode inherit unless target overrides them. A requested runtimeMode must be one the target provider lists in orchestrator_capabilities runtimeModes; an inherited one steps down to the broadest mode it offers. Prefer mode='async' for long work; mode='wait' blocks until completion or timeout. timeoutMs on mode=wait is only the parent's wait budget and does not cancel the child. waitTimedOut on that wait call means the timeout fired; keep that taskId and read status on later task_status. An async child's completion wakes this thread through a notification, steered into active turns where supported or queued otherwise, so end the turn instead of polling or spawning watchers; use task_status only when the result is needed mid-turn.", | |
| 69 | 69 | parameters: OrchestratorMcpDelegateTaskInput, |
| 70 | 70 | success: OrchestratorMcpDelegateTaskResult, |
| 71 | 71 | failure: OrchestratorMcpFailure, |
apps/server/src/orchestration-v2/Orchestrator.ts+5-1| ... | ... | @@ -6666,6 +6666,7 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio |
| 6666 | 6666 | }), |
| 6667 | 6667 | runtimeMode: command.runtimeMode, |
| 6668 | 6668 | interactionMode: command.interactionMode, |
| 6669 | ...(command.workspaceStrategy === undefined ? {} : { branch: null, worktreePath: null }), | |
| 6669 | 6670 | }; |
| 6670 | 6671 | const task: OrchestrationV2Subagent = { |
| 6671 | 6672 | id: taskNodeId, |
| ... | ... | @@ -6783,7 +6784,10 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio |
| 6783 | 6784 | text: command.task, |
| 6784 | 6785 | attachments: [], |
| 6785 | 6786 | modelSelection: command.modelSelection, |
| 6786 | dispatchMode: { type: "start_immediately" }, | |
| 6787 | dispatchMode: | |
| 6788 | command.workspaceStrategy === undefined | |
| 6789 | ? { type: "start_immediately" } | |
| 6790 | : { type: "defer_start", workspaceStrategy: command.workspaceStrategy }, | |
| 6787 | 6791 | } satisfies Extract<OrchestrationV2Command, { readonly type: "message.dispatch" }>; |
| 6788 | 6792 | yield* dispatchMessage(childMessageCommand, events, effects); |
| 6789 | 6793 |
apps/server/src/orchestration-v2/ProviderRuntimeRecoveryService.test.ts+32-8| ... | ... | @@ -643,7 +643,7 @@ it.effect("closes a secret request card's form when its run is recovered", () => |
| 643 | 643 | }).pipe(Effect.provide(layer)); |
| 644 | 644 | }); |
| 645 | 645 | |
| 646 | it.effect("holds accepted queued work without cancelling its execution state after restart", () => { | |
| 646 | it.effect.each(["queued", "preparing"] as const)("recovers $0 work after restart", (status) => { | |
| 647 | 647 | const threadId = ThreadId.make("thread_queued_restart"); |
| 648 | 648 | const runId = RunId.make("run_queued_restart"); |
| 649 | 649 | const attemptId = RunAttemptId.make("attempt_queued_restart"); |
| ... | ... | @@ -659,9 +659,18 @@ it.effect("holds accepted queued work without cancelling its execution state aft |
| 659 | 659 | runs: [ |
| 660 | 660 | { |
| 661 | 661 | id: runId, |
| 662 | status: "queued", | |
| 662 | status, | |
| 663 | 663 | queuePosition: 1, |
| 664 | 664 | providerInstanceId: ProviderInstanceId.make("codex"), |
| 665 | ...(status === "preparing" | |
| 666 | ? { | |
| 667 | workspacePreparation: { | |
| 668 | type: "worktree", | |
| 669 | baseRef: "parent-local-commit", | |
| 670 | startFromOrigin: false, | |
| 671 | }, | |
| 672 | } | |
| 673 | : {}), | |
| 665 | 674 | }, |
| 666 | 675 | ], |
| 667 | 676 | attempts: [ |
| ... | ... | @@ -712,18 +721,33 @@ it.effect("holds accepted queued work without cancelling its execution state aft |
| 712 | 721 | return Effect.gen(function* () { |
| 713 | 722 | const summary = |
| 714 | 723 | yield* (yield* ProviderRuntimeRecovery.ProviderRuntimeRecoveryService).reconcile("startup"); |
| 715 | assert.equal(summary.terminalizedRuns, 0); | |
| 724 | assert.equal(summary.terminalizedRuns, status === "preparing" ? 1 : 0); | |
| 716 | 725 | const command = committedInput; |
| 717 | 726 | assert.isNotNull(command); |
| 718 | 727 | if (command === null) return; |
| 719 | 728 | const runEvent = command.events.find((event) => event.type === "run.updated"); |
| 720 | 729 | const attemptEvent = command.events.find((event) => event.type === "run-attempt.updated"); |
| 721 | 730 | const nodeEvent = command.events.find((event) => event.type === "node.updated"); |
| 722 | assert.equal(runEvent?.type === "run.updated" ? runEvent.payload.status : null, "queued"); | |
| 723 | assert.equal(runEvent?.type === "run.updated" ? runEvent.payload.queuePosition : null, 1); | |
| 724 | assert.equal(runEvent?.type === "run.updated" ? runEvent.payload.queueHeld : false, true); | |
| 725 | assert.isUndefined(attemptEvent); | |
| 726 | assert.isUndefined(nodeEvent); | |
| 731 | assert.equal( | |
| 732 | runEvent?.type === "run.updated" ? runEvent.payload.status : null, | |
| 733 | status === "preparing" ? "cancelled" : "queued", | |
| 734 | ); | |
| 735 | assert.isEmpty(command.effects); | |
| 736 | if (status === "preparing") { | |
| 737 | assert.equal( | |
| 738 | attemptEvent?.type === "run-attempt.updated" ? attemptEvent.payload.status : null, | |
| 739 | "cancelled", | |
| 740 | ); | |
| 741 | assert.equal( | |
| 742 | nodeEvent?.type === "node.updated" ? nodeEvent.payload.status : null, | |
| 743 | "cancelled", | |
| 744 | ); | |
| 745 | } else { | |
| 746 | assert.equal(runEvent?.type === "run.updated" ? runEvent.payload.queuePosition : null, 1); | |
| 747 | assert.equal(runEvent?.type === "run.updated" ? runEvent.payload.queueHeld : false, true); | |
| 748 | assert.isUndefined(attemptEvent); | |
| 749 | assert.isUndefined(nodeEvent); | |
| 750 | } | |
| 727 | 751 | }).pipe(Effect.provide(layer)); |
| 728 | 752 | }); |
| 729 | 753 |
apps/server/src/orchestration-v2/ThreadLaunchService.test.ts+228-2| ... | ... | @@ -15,6 +15,7 @@ import { |
| 15 | 15 | ComposerContextId, |
| 16 | 16 | type ChatAttachment, |
| 17 | 17 | CommandId, |
| 18 | EnvironmentId, | |
| 18 | 19 | DEFAULT_SERVER_SETTINGS, |
| 19 | 20 | GitCommandError, |
| 20 | 21 | MessageId, |
| ... | ... | @@ -56,6 +57,8 @@ import * as EffectOutbox from "./EffectOutbox.ts"; |
| 56 | 57 | import * as IdAllocator from "@t3tools/provider-core/server/IdAllocator"; |
| 57 | 58 | import * as ProviderAdapterRegistry from "./ProviderAdapterRegistry.ts"; |
| 58 | 59 | import * as ThreadLaunch from "./ThreadLaunchService.ts"; |
| 60 | import * as OrchestratorMcpService from "../mcp/OrchestratorMcpService.ts"; | |
| 61 | import * as GitVcsDriver from "../vcs/GitVcsDriver.ts"; | |
| 59 | 62 | import * as ThreadManagement from "./ThreadManagementService.ts"; |
| 60 | 63 | import * as ThreadTitleRegeneration from "./ThreadTitleRegenerationService.ts"; |
| 61 | 64 | import * as ProviderReplayHarness from "./testkit/ProviderReplayHarness.ts"; |
| ... | ... | @@ -97,6 +100,8 @@ const adapter = { |
| 97 | 100 | } as ProviderAdapter.ProviderAdapterV2["Service"]; |
| 98 | 101 | |
| 99 | 102 | interface HarnessOptions { |
| 103 | readonly closeTerminal?: TerminalManager.TerminalManager["Service"]["close"]; | |
| 104 | readonly adapters?: ReadonlyArray<ProviderAdapter.ProviderAdapterV2["Service"]>; | |
| 100 | 105 | readonly managedFolders?: Layer.Layer<ManagedProjectFolders.ManagedProjectFolders>; |
| 101 | 106 | readonly createWorktree?: GitWorkflow.GitWorkflowService["Service"]["createWorktree"]; |
| 102 | 107 | readonly fetchRemote?: GitWorkflow.GitWorkflowService["Service"]["fetchRemote"]; |
| ... | ... | @@ -112,7 +117,7 @@ interface HarnessOptions { |
| 112 | 117 | |
| 113 | 118 | function makeHarness(options: HarnessOptions = {}) { |
| 114 | 119 | const layerDatabase = SqlitePersistence.layerMemory; |
| 115 | const layerRegistry = ProviderAdapterRegistry.layerFromAdapters([adapter]); | |
| 120 | const layerRegistry = ProviderAdapterRegistry.layerFromAdapters(options.adapters ?? [adapter]); | |
| 116 | 121 | const layerOrchestrator = ProviderReplayHarness.layerWithRegistry( |
| 117 | 122 | { name: "thread-launch" }, |
| 118 | 123 | layerRegistry, |
| ... | ... | @@ -143,6 +148,7 @@ function makeHarness(options: HarnessOptions = {}) { |
| 143 | 148 | const runSetup = vi.fn( |
| 144 | 149 | options.runSetup ?? (() => Effect.succeed({ status: "no-script" as const })), |
| 145 | 150 | ); |
| 151 | const closeTerminal = vi.fn(options.closeTerminal ?? (() => Effect.void)); | |
| 146 | 152 | const generateBranchName = vi.fn( |
| 147 | 153 | options.generateBranchName ?? (() => Effect.succeed({ branch: "generated-branch" })), |
| 148 | 154 | ); |
| ... | ... | @@ -152,7 +158,7 @@ function makeHarness(options: HarnessOptions = {}) { |
| 152 | 158 | const layerExternalServices = Layer.mergeAll( |
| 153 | 159 | WorktreeSetupTracker.layer, |
| 154 | 160 | Layer.mock(ProjectCloneTracker.ProjectCloneTracker)({ get: () => Effect.succeed(null) }), |
| 155 | Layer.mock(TerminalManager.TerminalManager)({ close: () => Effect.void }), | |
| 161 | Layer.mock(TerminalManager.TerminalManager)({ close: closeTerminal }), | |
| 156 | 162 | Layer.succeed(ProjectService.ProjectService, { |
| 157 | 163 | create: () => Effect.die("unused"), |
| 158 | 164 | bootstrap: () => Effect.die("unused"), |
| ... | ... | @@ -237,6 +243,7 @@ function makeHarness(options: HarnessOptions = {}) { |
| 237 | 243 | return { |
| 238 | 244 | layer: Layer.mergeAll( |
| 239 | 245 | layerLaunch, |
| 246 | layerRegistry, | |
| 240 | 247 | layerThreadManagement, |
| 241 | 248 | layerTitleRegeneration, |
| 242 | 249 | layerOutbox, |
| ... | ... | @@ -249,6 +256,7 @@ function makeHarness(options: HarnessOptions = {}) { |
| 249 | 256 | generateBranchName, |
| 250 | 257 | generateThreadTitle, |
| 251 | 258 | runSetup, |
| 259 | closeTerminal, | |
| 252 | 260 | }; |
| 253 | 261 | } |
| 254 | 262 | |
| ... | ... | @@ -2263,6 +2271,7 @@ it.effect("shared intake preserves durable attachment bytes after a lost launch |
| 2263 | 2271 | ), |
| 2264 | 2272 | checkWorktreeBase: launches.checkWorktreeBase, |
| 2265 | 2273 | retryPreparation: launches.retryPreparation, |
| 2274 | prepareRun: launches.prepareRun, | |
| 2266 | 2275 | }), |
| 2267 | 2276 | Effect.flip, |
| 2268 | 2277 | ); |
| ... | ... | @@ -2705,3 +2714,220 @@ it.effect("replays an accepted message.dispatch after the project disables the i |
| 2705 | 2714 | } |
| 2706 | 2715 | }).pipe(Effect.provide(Layer.mergeAll(harness.layer, layerFiles))); |
| 2707 | 2716 | }); |
| 2717 | ||
| 2718 | it.effect.each( | |
| 2719 | (["codex", "claudeAgent"] as const).flatMap((driver) => | |
| 2720 | [ | |
| 2721 | { failSetup: false, failResolve: false }, | |
| 2722 | { failSetup: true, failResolve: false }, | |
| 2723 | { failSetup: false, failResolve: true }, | |
| 2724 | { failSetup: false, failResolve: false, cancelSetup: true }, | |
| 2725 | { failSetup: false, failResolve: false, cancelSetup: true, cancelAsync: true }, | |
| 2726 | ].map((failures) => ({ driver, cancelSetup: false, cancelAsync: false, ...failures })), | |
| 2727 | ), | |
| 2728 | )( | |
| 2729 | "prepares $driver delegated worktrees before execution, setup failure=$failSetup, commit failure=$failResolve, cancellation=$cancelSetup, async=$cancelAsync", | |
| 2730 | ({ driver, failSetup, failResolve, cancelSetup, cancelAsync }) => | |
| 2731 | Effect.gen(function* () { | |
| 2732 | const setupEntered = yield* Deferred.make<void>(); | |
| 2733 | const allowSetup = yield* Deferred.make<void>(); | |
| 2734 | const terminalClosed = yield* Deferred.make<void>(); | |
| 2735 | const childInstanceId = ProviderInstanceId.make(`child-${driver}`); | |
| 2736 | const parentWorktree = driver === "codex" ? null : "/parent-worktree"; | |
| 2737 | const resolveCommit = vi.fn(() => | |
| 2738 | failResolve | |
| 2739 | ? Effect.fail( | |
| 2740 | new GitCommandError({ | |
| 2741 | operation: "resolveCommit", | |
| 2742 | command: "git", | |
| 2743 | cwd: "/private-checkout", | |
| 2744 | detail: "private Git failure details", | |
| 2745 | }), | |
| 2746 | ) | |
| 2747 | : Effect.succeed({ commitSha: "parent-local-commit" }), | |
| 2748 | ); | |
| 2749 | const harness = makeHarness({ | |
| 2750 | closeTerminal: () => Deferred.succeed(terminalClosed, undefined).pipe(Effect.asVoid), | |
| 2751 | adapters: [ | |
| 2752 | adapter, | |
| 2753 | { ...adapter, instanceId: childInstanceId, driver: ProviderDriverKind.make(driver) }, | |
| 2754 | ], | |
| 2755 | serverSettings: { | |
| 2756 | providerInstances: { | |
| 2757 | [childInstanceId]: { driver: ProviderDriverKind.make(driver), config: {} }, | |
| 2758 | }, | |
| 2759 | }, | |
| 2760 | hasCommit: () => Effect.succeed(true), | |
| 2761 | providers: [ | |
| 2762 | { | |
| 2763 | instanceId: childInstanceId, | |
| 2764 | driver: ProviderDriverKind.make(driver), | |
| 2765 | enabled: true, | |
| 2766 | installed: true, | |
| 2767 | version: "test", | |
| 2768 | checkedAt: "2026-10-13T00:00:00.000Z", | |
| 2769 | slashCommands: [], | |
| 2770 | skills: [], | |
| 2771 | status: "ready", | |
| 2772 | auth: { status: "authenticated" }, | |
| 2773 | models: [{ slug: "child-model", name: "Child", isCustom: true, capabilities: null }], | |
| 2774 | }, | |
| 2775 | ], | |
| 2776 | runSetup: () => | |
| 2777 | cancelSetup | |
| 2778 | ? Effect.succeed({ | |
| 2779 | status: "started" as const, | |
| 2780 | async: cancelAsync, | |
| 2781 | scriptId: "setup", | |
| 2782 | scriptName: "Setup", | |
| 2783 | scriptCommand: "setup", | |
| 2784 | terminalId: "setup", | |
| 2785 | cwd: "/repo-worktrees/feature", | |
| 2786 | completion: Deferred.succeed(setupEntered, undefined).pipe( | |
| 2787 | Effect.andThen(Deferred.await(allowSetup)), | |
| 2788 | Effect.as({ exitCode: 0, durationMs: 0 }), | |
| 2789 | ), | |
| 2790 | }) | |
| 2791 | : Deferred.succeed(setupEntered, undefined).pipe( | |
| 2792 | Effect.andThen(Deferred.await(allowSetup)), | |
| 2793 | Effect.andThen( | |
| 2794 | failSetup | |
| 2795 | ? Effect.fail( | |
| 2796 | new ProjectSetupScriptRunner.ProjectSetupScriptOperationError({ | |
| 2797 | threadId: "child", | |
| 2798 | worktreePath: "/repo-worktrees/feature", | |
| 2799 | operation: "writeCommand", | |
| 2800 | cause: "setup failed", | |
| 2801 | }), | |
| 2802 | ) | |
| 2803 | : Effect.succeed({ status: "no-script" as const }), | |
| 2804 | ), | |
| 2805 | ), | |
| 2806 | }); | |
| 2807 | const layerService = OrchestratorMcpService.layer.pipe( | |
| 2808 | Layer.provide(NodeCrypto.layer), | |
| 2809 | Layer.provide(harness.layer), | |
| 2810 | Layer.provide(Layer.mock(GitVcsDriver.GitVcsDriver)({ resolveCommit })), | |
| 2811 | Layer.provide(Layer.mock(ScheduledTasks.ScheduledTaskService)({})), | |
| 2812 | Layer.provide(Layer.mock(SecretRequests.SecretRequests)({})), | |
| 2813 | ); | |
| 2814 | yield* Effect.gen(function* () { | |
| 2815 | const service = yield* OrchestratorMcpService.OrchestratorMcpService; | |
| 2816 | const threads = yield* ThreadManagement.ThreadManagementService; | |
| 2817 | const parentThreadId = ThreadId.make("thread:delegated-worktree-parent"); | |
| 2818 | yield* threads.dispatch({ | |
| 2819 | type: "thread.create", | |
| 2820 | commandId: CommandId.make("create-parent"), | |
| 2821 | threadId: parentThreadId, | |
| 2822 | projectId, | |
| 2823 | title: "Parent", | |
| 2824 | modelSelection, | |
| 2825 | runtimeMode: "full-access", | |
| 2826 | interactionMode: "default", | |
| 2827 | branch: "parent-feature", | |
| 2828 | worktreePath: parentWorktree, | |
| 2829 | createdBy: "user", | |
| 2830 | creationSource: "web", | |
| 2831 | }); | |
| 2832 | yield* threads.dispatch({ | |
| 2833 | type: "message.dispatch", | |
| 2834 | commandId: CommandId.make("start-parent"), | |
| 2835 | threadId: parentThreadId, | |
| 2836 | messageId: MessageId.make("parent-message"), | |
| 2837 | text: "Delegate work", | |
| 2838 | attachments: [], | |
| 2839 | dispatchMode: { type: "start_immediately" }, | |
| 2840 | createdBy: "user", | |
| 2841 | creationSource: "web", | |
| 2842 | }); | |
| 2843 | const scope = { | |
| 2844 | environmentId: EnvironmentId.make("delegated-worktree-test"), | |
| 2845 | requestNamespace: "parent-session", | |
| 2846 | thread: { | |
| 2847 | threadId: parentThreadId, | |
| 2848 | providerSessionId: "parent-session", | |
| 2849 | providerInstanceId: modelSelection.instanceId, | |
| 2850 | }, | |
| 2851 | client: undefined, | |
| 2852 | capabilities: new Set(["orchestration"] as const), | |
| 2853 | issuedAt: 1, | |
| 2854 | }; | |
| 2855 | const input = { | |
| 2856 | task: "Implement the child task", | |
| 2857 | target: { providerInstanceId: childInstanceId, model: "child-model" }, | |
| 2858 | isolation: "worktree" as const, | |
| 2859 | clientRequestId: "isolated-child", | |
| 2860 | }; | |
| 2861 | if (failResolve) { | |
| 2862 | const error = yield* service.delegateTask(scope, input).pipe(Effect.flip); | |
| 2863 | assert.equal(error.code, "invalid_request"); | |
| 2864 | assert.equal(error.message, "Unable to resolve the parent checkout commit."); | |
| 2865 | assert.isEmpty((yield* threads.getThreadProjection(parentThreadId)).subagents); | |
| 2866 | assert.equal(harness.createWorktree.mock.calls.length, 0); | |
| 2867 | return; | |
| 2868 | } | |
| 2869 | const delegated = yield* service.delegateTask(scope, input); | |
| 2870 | yield* Deferred.await(setupEntered); | |
| 2871 | const child = yield* threads.getThreadProjection(delegated.childThreadId); | |
| 2872 | assert.equal(child.thread.lineage.parentThreadId, parentThreadId); | |
| 2873 | assert.equal(child.thread.worktreePath, "/repo-worktrees/feature"); | |
| 2874 | assert.equal(child.runs[0]?.status, cancelAsync ? "starting" : "preparing"); | |
| 2875 | assert.equal(child.thread.modelSelection.instanceId, childInstanceId); | |
| 2876 | assert.equal(child.thread.modelSelection.model, "child-model"); | |
| 2877 | assert.deepEqual(resolveCommit.mock.calls, [ | |
| 2878 | [{ cwd: parentWorktree ?? "/repo", revision: "HEAD" }], | |
| 2879 | ]); | |
| 2880 | assert.equal(harness.createWorktree.mock.calls[0]?.[0].refName, "parent-local-commit"); | |
| 2881 | assert.equal((yield* service.delegateTask(scope, input)).taskId, delegated.taskId); | |
| 2882 | assert.equal(harness.createWorktree.mock.calls.length, 1); | |
| 2883 | assert.equal(resolveCommit.mock.calls.length, 1); | |
| 2884 | if (!cancelAsync) assert.isEmpty(child.checkpointScopes); | |
| 2885 | const parent = yield* threads.getThreadProjection(parentThreadId); | |
| 2886 | assert.equal(parent.thread.worktreePath, parentWorktree); | |
| 2887 | assert.equal(parent.thread.branch, "parent-feature"); | |
| 2888 | if (cancelSetup) { | |
| 2889 | yield* service.cancelTask(scope, { taskId: delegated.taskId }); | |
| 2890 | yield* Deferred.await(terminalClosed); | |
| 2891 | if (!cancelAsync) { | |
| 2892 | yield* threads.streamStoredEventsFrom({ threadId: delegated.childThreadId }).pipe( | |
| 2893 | Stream.filter( | |
| 2894 | (stored) => | |
| 2895 | stored.event.type === "thread.metadata-updated" && | |
| 2896 | stored.event.payload.worktreePath === null, | |
| 2897 | ), | |
| 2898 | Stream.runHead, | |
| 2899 | ); | |
| 2900 | } | |
| 2901 | assert.equal(harness.closeTerminal.mock.calls.length, 1); | |
| 2902 | assert.equal(harness.removeWorktree.mock.calls.length, cancelAsync ? 0 : 1); | |
| 2903 | const cancelled = yield* threads.getThreadProjection(delegated.childThreadId); | |
| 2904 | assert.equal(cancelled.runs[0]?.status, "interrupted"); | |
| 2905 | if (cancelAsync) { | |
| 2906 | assert.equal(cancelled.thread.worktreePath, "/repo-worktrees/feature"); | |
| 2907 | assert.equal(cancelled.checkpointScopes[0]?.cwd, "/repo-worktrees/feature"); | |
| 2908 | } else { | |
| 2909 | assert.isNull(cancelled.thread.worktreePath); | |
| 2910 | assert.isEmpty(cancelled.checkpointScopes); | |
| 2911 | } | |
| 2912 | return; | |
| 2913 | } | |
| 2914 | yield* Deferred.succeed(allowSetup, undefined); | |
| 2915 | yield* threads | |
| 2916 | .streamStoredEventsFrom({ threadId: delegated.childThreadId, eventType: "run.updated" }) | |
| 2917 | .pipe( | |
| 2918 | Stream.filter( | |
| 2919 | (stored) => | |
| 2920 | stored.event.type === "run.updated" && | |
| 2921 | stored.event.payload.status === (failSetup ? "failed" : "starting"), | |
| 2922 | ), | |
| 2923 | Stream.runHead, | |
| 2924 | ); | |
| 2925 | const prepared = yield* threads.getThreadProjection(delegated.childThreadId); | |
| 2926 | if (failSetup) { | |
| 2927 | assert.isEmpty(prepared.checkpointScopes); | |
| 2928 | } else { | |
| 2929 | assert.equal(prepared.checkpointScopes[0]?.cwd, "/repo-worktrees/feature"); | |
| 2930 | } | |
| 2931 | }).pipe(Effect.provide(Layer.merge(harness.layer, layerService))); | |
| 2932 | }), | |
| 2933 | ); |
apps/server/src/orchestration-v2/ThreadLaunchService.ts+68-28| ... | ... | @@ -28,6 +28,7 @@ import * as Option from "effect/Option"; |
| 28 | 28 | import * as Ref from "effect/Ref"; |
| 29 | 29 | import * as Schema from "effect/Schema"; |
| 30 | 30 | import * as Scope from "effect/Scope"; |
| 31 | import * as Stream from "effect/Stream"; | |
| 31 | 32 | import { |
| 32 | 33 | buildTemporaryWorktreeBranchName, |
| 33 | 34 | flattenTemporaryWorktreeBranchName, |
| ... | ... | @@ -109,7 +110,7 @@ type PreparationInput = Pick< |
| 109 | 110 | readonly reusedWorktree?: { readonly baseRef: string }; |
| 110 | 111 | }; |
| 111 | 112 | |
| 112 | export interface ThreadLaunchRetryInput { | |
| 113 | export interface ThreadLaunchPreparationInput { | |
| 113 | 114 | readonly commandId: CommandId; |
| 114 | 115 | readonly threadId: ThreadId; |
| 115 | 116 | readonly runId: RunId; |
| ... | ... | @@ -180,13 +181,14 @@ export class ThreadLaunchService extends Context.Service< |
| 180 | 181 | input: Pick<ThreadLaunchInput, "projectId" | "workspaceStrategy">, |
| 181 | 182 | ) => Effect.Effect<void, ThreadLaunchBaseRefError>; |
| 182 | 183 | /** Dispatches prepared-run.retry and prepares the run's workspace again. */ |
| 183 | readonly retryPreparation: (input: ThreadLaunchRetryInput) => Effect.Effect< | |
| 184 | readonly retryPreparation: (input: ThreadLaunchPreparationInput) => Effect.Effect< | |
| 184 | 185 | Orchestrator.OrchestratorV2DispatchResult, |
| 185 | 186 | // Dispatches prepared-run.retry, a command the enablement chokepoint |
| 186 | 187 | // never gates; the type stays accurate anyway. |
| 187 | 188 | | Orchestrator.OrchestratorV2Error |
| 188 | 189 | | ThreadManagement.ThreadManagementProviderInstanceDisabledError |
| 189 | 190 | >; |
| 191 | readonly prepareRun: (input: ThreadLaunchPreparationInput) => Effect.Effect<void>; | |
| 190 | 192 | } |
| 191 | 193 | >()("t3/orchestration-v2/ThreadLaunchService") {} |
| 192 | 194 | |
| ... | ... | @@ -317,6 +319,7 @@ const make = Effect.gen(function* () { |
| 317 | 319 | let createdWorktreePath: string | null = null; |
| 318 | 320 | let setupTerminalId: string | null = null; |
| 319 | 321 | let workspaceRecorded = false; |
| 322 | let runReleased = false; | |
| 320 | 323 | if (input.workspaceStrategy.type === "worktree") { |
| 321 | 324 | yield* setupTracker.begin({ |
| 322 | 325 | threadId, |
| ... | ... | @@ -623,7 +626,15 @@ const make = Effect.gen(function* () { |
| 623 | 626 | threadId, |
| 624 | 627 | runId, |
| 625 | 628 | }) |
| 626 | .pipe(Effect.mapError(mapError(input, "release-run", threadId))); | |
| 629 | .pipe( | |
| 630 | Effect.mapError(mapError(input, "release-run", threadId)), | |
| 631 | Effect.tap(() => | |
| 632 | Effect.sync(() => { | |
| 633 | runReleased = true; | |
| 634 | }), | |
| 635 | ), | |
| 636 | Effect.uninterruptible, | |
| 637 | ); | |
| 627 | 638 | } |
| 628 | 639 | yield* setupTracker.stageStatus(threadId, "agent", "done"); |
| 629 | 640 | yield* awaitAsyncSetup; |
| ... | ... | @@ -637,14 +648,15 @@ const make = Effect.gen(function* () { |
| 637 | 648 | cancelled ? "cancelled" : "failed", |
| 638 | 649 | cancelled ? null : failureDetail(Cause.squash(cause)), |
| 639 | 650 | ); |
| 640 | // A cancelled setup leaves nothing behind. A failed one keeps a worktree | |
| 641 | // the thread recorded, so a retry reuses it, and removes one it never | |
| 642 | // recorded, which a retry would otherwise duplicate. | |
| 643 | if (tracked && createdWorktreePath && (cancelled || !workspaceRecorded)) { | |
| 644 | if (setupTerminalId) | |
| 645 | yield* terminals | |
| 646 | .close({ threadId, terminalId: setupTerminalId, deleteHistory: true }) | |
| 647 | .pipe(Effect.ignore); | |
| 651 | if (cancelled && setupTerminalId) | |
| 652 | yield* terminals | |
| 653 | .close({ threadId, terminalId: setupTerminalId, deleteHistory: true }) | |
| 654 | .pipe(Effect.ignore); | |
| 655 | if ( | |
| 656 | tracked && | |
| 657 | createdWorktreePath && | |
| 658 | ((cancelled && !runReleased) || !workspaceRecorded) | |
| 659 | ) { | |
| 648 | 660 | const removedPath = createdWorktreePath; |
| 649 | 661 | // The thread forgets the worktree only once it is gone; a failed |
| 650 | 662 | // removal leaves the directory for the user to clean up rather than |
| ... | ... | @@ -734,7 +746,33 @@ const make = Effect.gen(function* () { |
| 734 | 746 | threadId: ThreadId, |
| 735 | 747 | runId: RunId | null, |
| 736 | 748 | ) { |
| 737 | yield* prepareInBackground(input, threadId, runId).pipe( | |
| 749 | const preparation = Effect.gen(function* () { | |
| 750 | const prepare = prepareInBackground(input, threadId, runId); | |
| 751 | if ( | |
| 752 | runId === null || | |
| 753 | (input.workspaceStrategy.type !== "worktree" && input.reusedWorktree === undefined) | |
| 754 | ) { | |
| 755 | return yield* prepare; | |
| 756 | } | |
| 757 | const afterSequence = yield* threads.getThreadEventSequence(threadId); | |
| 758 | const projection = yield* threads.getThreadRecords(threadId, ["runs"]); | |
| 759 | if (projection.runs.find((run) => run.id === runId)?.status !== "preparing") return; | |
| 760 | const interruption = threads | |
| 761 | .streamStoredEventsFrom({ threadId, afterSequence, eventType: "run.updated" }) | |
| 762 | .pipe( | |
| 763 | Stream.filter( | |
| 764 | (stored) => | |
| 765 | stored.event.type === "run.updated" && | |
| 766 | stored.event.payload.id === runId && | |
| 767 | (stored.event.payload.status === "interrupted" || | |
| 768 | stored.event.payload.status === "cancelled"), | |
| 769 | ), | |
| 770 | Stream.runHead, | |
| 771 | Effect.flatMap((event) => (Option.isSome(event) ? Effect.interrupt : Effect.never)), | |
| 772 | ); | |
| 773 | yield* Effect.raceFirst(prepare, interruption); | |
| 774 | }); | |
| 775 | yield* preparation.pipe( | |
| 738 | 776 | Effect.onError((cause) => |
| 739 | 777 | failPreparedRun( |
| 740 | 778 | input, |
| ... | ... | @@ -979,37 +1017,39 @@ const make = Effect.gen(function* () { |
| 979 | 1017 | }, |
| 980 | 1018 | ); |
| 981 | 1019 | |
| 982 | const retryPreparation: ThreadLaunchService["Service"]["retryPreparation"] = Effect.fn( | |
| 983 | "ThreadLaunchService.retryPreparation", | |
| 1020 | const prepareRun: ThreadLaunchService["Service"]["prepareRun"] = Effect.fn( | |
| 1021 | "ThreadLaunchService.prepareRun", | |
| 984 | 1022 | )(function* (input) { |
| 985 | const dispatched = yield* threads.dispatch({ | |
| 986 | type: "prepared-run.retry", | |
| 987 | commandId: input.commandId, | |
| 988 | threadId: input.threadId, | |
| 989 | runId: input.runId, | |
| 990 | }); | |
| 991 | // A replayed retry finds the run already past preparation, or prepared by | |
| 992 | // the attempt that first reserved this command. | |
| 993 | // From here the run is preparing again; anything that stops preparation | |
| 994 | // from being scheduled must fail it, or it would wait in preparing forever. | |
| 995 | 1023 | const scheduled = yield* Effect.gen(function* () { |
| 996 | 1024 | const projection = yield* threads.getThreadProjection(input.threadId); |
| 997 | 1025 | const run = projection.runs.find((candidate) => candidate.id === input.runId); |
| 998 | 1026 | const workspacePreparation = run?.workspacePreparation; |
| 999 | 1027 | if (run?.status !== "preparing" || workspacePreparation === undefined) return; |
| 1000 | 1028 | if (!(yield* reservePreparation(input.commandId))) return; |
| 1001 | yield* scheduleRetriedPreparation(input, projection, run, workspacePreparation).pipe( | |
| 1029 | yield* scheduleRunPreparation(input, projection, run, workspacePreparation).pipe( | |
| 1002 | 1030 | Effect.onError(() => releasePreparation(input.commandId)), |
| 1003 | 1031 | ); |
| 1004 | 1032 | }).pipe(Effect.exit); |
| 1005 | 1033 | if (Exit.isFailure(scheduled)) { |
| 1006 | 1034 | yield* failPreparedRun(input, input.threadId, input.runId, Cause.squash(scheduled.cause)); |
| 1007 | 1035 | } |
| 1036 | }); | |
| 1037 | ||
| 1038 | const retryPreparation: ThreadLaunchService["Service"]["retryPreparation"] = Effect.fn( | |
| 1039 | "ThreadLaunchService.retryPreparation", | |
| 1040 | )(function* (input) { | |
| 1041 | const dispatched = yield* threads.dispatch({ | |
| 1042 | type: "prepared-run.retry", | |
| 1043 | commandId: input.commandId, | |
| 1044 | threadId: input.threadId, | |
| 1045 | runId: input.runId, | |
| 1046 | }); | |
| 1047 | yield* prepareRun(input); | |
| 1008 | 1048 | return dispatched; |
| 1009 | 1049 | }); |
| 1010 | 1050 | |
| 1011 | const scheduleRetriedPreparation = ( | |
| 1012 | input: ThreadLaunchRetryInput, | |
| 1051 | const scheduleRunPreparation = ( | |
| 1052 | input: ThreadLaunchPreparationInput, | |
| 1013 | 1053 | projection: OrchestrationV2ThreadProjection, |
| 1014 | 1054 | run: OrchestrationV2ThreadProjection["runs"][number], |
| 1015 | 1055 | workspacePreparation: ThreadLaunchWorkspaceStrategy, |
| ... | ... | @@ -1050,7 +1090,7 @@ const make = Effect.gen(function* () { |
| 1050 | 1090 | ); |
| 1051 | 1091 | }; |
| 1052 | 1092 | |
| 1053 | return ThreadLaunchService.of({ launch, checkWorktreeBase, retryPreparation }); | |
| 1093 | return ThreadLaunchService.of({ launch, checkWorktreeBase, retryPreparation, prepareRun }); | |
| 1054 | 1094 | }); |
| 1055 | 1095 | |
| 1056 | 1096 | export const layer = Layer.effect(ThreadLaunchService, make); |
packages/contracts/src/orchestrationV2.ts+1| ... | ... | @@ -3110,6 +3110,7 @@ export const OrchestrationV2Command = Schema.Union([ |
| 3110 | 3110 | modelSelection: ModelSelection, |
| 3111 | 3111 | runtimeMode: RuntimeMode, |
| 3112 | 3112 | interactionMode: ProviderInteractionMode, |
| 3113 | workspaceStrategy: Schema.optional(OrchestrationV2ThreadLaunchWorkspaceStrategy), | |
| 3113 | 3114 | // Omitted behaves as "settled_only" (no wake while the parent has a live |
| 3114 | 3115 | // run); producers that want fire-and-forget wakes must set "always". |
| 3115 | 3116 | completionWake: Schema.optional(Schema.Literals(["always", "settled_only"])), |
packages/contracts/src/orchestratorMcp.test.ts+7| ... | ... | @@ -22,6 +22,13 @@ const decodeThreadSendInput = Schema.decodeUnknownSync(OrchestratorMcpThreadSend |
| 22 | 22 | const decodeThreadWaitInput = Schema.decodeUnknownSync(OrchestratorMcpThreadWaitInput); |
| 23 | 23 | |
| 24 | 24 | describe("orchestrator MCP contracts", () => { |
| 25 | it("accepts explicit worktree isolation and keeps shared delegation as the default", () => { | |
| 26 | expect(decodeDelegateTaskInput({ task: "Review changes" }).isolation).toBeUndefined(); | |
| 27 | expect( | |
| 28 | decodeDelegateTaskInput({ task: "Review changes", isolation: "worktree" }).isolation, | |
| 29 | ).toBe("worktree"); | |
| 30 | expect(() => decodeDelegateTaskInput({ task: "Review changes", isolation: "copy" })).toThrow(); | |
| 31 | }); | |
| 25 | 32 | it("decodes cross-provider delegated task requests and durable results", () => { |
| 26 | 33 | const request = decodeDelegateTaskInput({ |
| 27 | 34 | task: "Inspect the workspace and report the result.", |
packages/contracts/src/orchestratorMcp.ts+6| ... | ... | @@ -174,6 +174,12 @@ export const OrchestratorMcpDelegateTaskInput = Schema.Struct({ |
| 174 | 174 | target: Schema.optional(OrchestratorMcpTarget), |
| 175 | 175 | title: Schema.optional(OrchestratorMcpTitle), |
| 176 | 176 | role: Schema.optional(OrchestratorMcpTaskRole), |
| 177 | isolation: Schema.optional( | |
| 178 | Schema.Literals(["shared", "worktree"]).annotate({ | |
| 179 | description: | |
| 180 | "Defaults to shared: use the parent's checkout. Worktree creates a separate checkout from the parent's current local commit before starting the child. Uncommitted edits are not copied.", | |
| 181 | }), | |
| 182 | ), | |
| 177 | 183 | mode: Schema.optional( |
| 178 | 184 | Schema.Literals(["async", "wait"]).annotate({ |
| 179 | 185 | description: |
packages/provider-core/src/server/orchestrationInstructions.ts+1-1| ... | ... | @@ -6,7 +6,7 @@ export const T3_CODE_ORCHESTRATION_INSTRUCTIONS = ` |
| 6 | 6 | |
| 7 | 7 | The \`t3-code\` MCP server provides app-owned orchestration. Treat these concepts distinctly: |
| 8 | 8 | |
| 9 | - A delegated task/subagent is child work owned by the current thread. Use \`orchestrator_capabilities\` to discover the current provider/model IDs from the same live catalog as the composer, including configured custom models. Do not treat a native tool's model list as the full list of available subagent models. Prefer native subagent tools for same-provider work only when they support the chosen model. Use \`delegate_task\` with that provider instance and model when native tools cannot, including for same-provider work. Also use \`delegate_task\` for cross-provider or explicitly T3-owned child tasks. Retain each returned \`taskId\`, and use \`task_status\` or \`task_cancel\` to manage it. The returned \`childThreadId\` is backing storage for the subagent, not the target for starting another delegated review round. | |
| 9 | - A delegated task/subagent is child work owned by the current thread. Use \`orchestrator_capabilities\` to discover the current provider/model IDs from the same live catalog as the composer, including configured custom models. Do not treat a native tool's model list as the full list of available subagent models. Prefer native subagent tools for same-provider work only when they support the chosen model. Use \`delegate_task\` with that provider instance and model when native tools cannot, including for same-provider work. Also use \`delegate_task\` for cross-provider or explicitly T3-owned child tasks. For a child task that needs a separate checkout, use \`delegate_task\` with \`isolation:"worktree"\` across any provider or model. It starts from the parent local commit and runs project setup; uncommitted edits are not copied. Retain each returned \`taskId\`, and use \`task_status\` or \`task_cancel\` to manage it. The returned \`childThreadId\` is backing storage for the subagent, not the target for starting another delegated review round. | |
| 10 | 10 | - \`t3_thread_launch\` and \`create_threads\` create ordinary top-level T3 conversations. Use them only when the user explicitly asks for separate/new/top-level threads or conversations. Never use them merely because the user said "subagent" or requested parallel delegated work. |
| 11 | 11 | - For every T3 delegated review round, call \`delegate_task\` again. Include the original brief, prior findings, responses, and unresolved objections in each new task prompt. Track each round by its own \`taskId\`. Use a distinct \`clientRequestId\` per round, stable across retries of that round. Do not use \`t3_thread_send\` on \`childThreadId\` to continue a delegated review. |
| 12 | 12 | - \`schedule_task\` creates persistent recurring work in the app scheduler. Pass \`schedule\` as a structured object, never as JSON text: \`{"type":"interval","everyMs":3600000}\` for an interval, or \`{"type":"fixed_time","timeOfDay":"09:00","weekdays":[1,2,3,4,5]}\` for a wall-clock schedule, or \`{"type":"webhook"}\` to run on each request to the returned \`webhookUrl\` (the run sees the request only through \`{{body.path}}\`-style placeholders in the prompt). By default runs return to the current thread, which suits orchestrating: each trigger arrives here and you delegate or dedupe; set \`bindToCurrentThread=false\` only when the user wants a fresh thread for every run. After scheduling a timer, report the returned cadence and next run time; for a webhook, report its \`webhookUrl\`, or say T3 Connect remote access is needed if it is missing. |