| 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,10 +22,17 @@ import * as ProjectService from "../project/ProjectService.ts"; |
| 22 | import * as ScheduledTaskService from "../scheduledTasks/ScheduledTaskService.ts"; | 22 | import * as ScheduledTaskService from "../scheduledTasks/ScheduledTaskService.ts"; |
| 23 | import * as ServerSettings from "../serverSettings.ts"; | 23 | import * as ServerSettings from "../serverSettings.ts"; |
| 24 | import * as SecretRequests from "../secrets/SecretRequests.ts"; | 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 | import * as ThreadManagementService from "../orchestration-v2/ThreadManagementService.ts"; | 27 | import * as ThreadManagementService from "../orchestration-v2/ThreadManagementService.ts"; |
| 26 | import type * as McpInvocationContext from "./McpInvocationContext.ts"; | 28 | import type * as McpInvocationContext from "./McpInvocationContext.ts"; |
| 27 | import * as OrchestratorMcpService from "./OrchestratorMcpService.ts"; | 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 | const environmentId = EnvironmentId.make("environment-mcp-orchestrator-detail"); | 36 | const environmentId = EnvironmentId.make("environment-mcp-orchestrator-detail"); |
| 30 | const projectId = ProjectId.make("project-mcp-orchestrator-detail"); | 37 | const projectId = ProjectId.make("project-mcp-orchestrator-detail"); |
| 31 | const parentThreadId = ThreadId.make("thread-mcp-orchestrator-parent"); | 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,7 +134,7 @@ it("readThread prefers activity-run status over a newer cancelled queued run", a |
| 127 | updatedAt: now, | 134 | updatedAt: now, |
| 128 | } as unknown as OrchestrationV2ThreadProjection; | 135 | } as unknown as OrchestrationV2ThreadProjection; |
| 129 | 136 | ||
| 130 | const layer = OrchestratorMcpService.layer.pipe( | 137 | const layer = layerOrchestratorService.pipe( |
| 131 | Layer.provide( | 138 | Layer.provide( |
| 132 | Layer.mergeAll( | 139 | Layer.mergeAll( |
| 133 | Layer.mock(ThreadManagementService.ThreadManagementService)({ | 140 | Layer.mock(ThreadManagementService.ThreadManagementService)({ |
| ... | @@ -193,7 +200,7 @@ it("readThread prefers waiting activity status over a newer cancelled queued run | ... | @@ -193,7 +200,7 @@ it("readThread prefers waiting activity status over a newer cancelled queued run |
| 193 | updatedAt: now, | 200 | updatedAt: now, |
| 194 | } as unknown as OrchestrationV2ThreadProjection; | 201 | } as unknown as OrchestrationV2ThreadProjection; |
| 195 | 202 | ||
| 196 | const layer = OrchestratorMcpService.layer.pipe( | 203 | const layer = layerOrchestratorService.pipe( |
| 197 | Layer.provide( | 204 | Layer.provide( |
| 198 | Layer.mergeAll( | 205 | Layer.mergeAll( |
| 199 | Layer.mock(ThreadManagementService.ThreadManagementService)({ | 206 | Layer.mock(ThreadManagementService.ThreadManagementService)({ |
| ... | @@ -316,7 +323,7 @@ it("taskStatus returns task.providerInstanceId rather than the driver kind", asy | ... | @@ -316,7 +323,7 @@ it("taskStatus returns task.providerInstanceId rather than the driver kind", asy |
| 316 | updatedAt: now, | 323 | updatedAt: now, |
| 317 | } as unknown as OrchestrationV2ThreadProjection; | 324 | } as unknown as OrchestrationV2ThreadProjection; |
| 318 | 325 | ||
| 319 | const layer = OrchestratorMcpService.layer.pipe( | 326 | const layer = layerOrchestratorService.pipe( |
| 320 | Layer.provide( | 327 | Layer.provide( |
| 321 | Layer.mergeAll( | 328 | Layer.mergeAll( |
| 322 | Layer.mock(ThreadManagementService.ThreadManagementService)({ | 329 | Layer.mock(ThreadManagementService.ThreadManagementService)({ |
| ... | @@ -415,7 +422,7 @@ it("readThread and sendToThread reach threads in other projects", async () => { | ... | @@ -415,7 +422,7 @@ it("readThread and sendToThread reach threads in other projects", async () => { |
| 415 | updatedAt: now, | 422 | updatedAt: now, |
| 416 | }) as unknown as OrchestrationV2ThreadProjection; | 423 | }) as unknown as OrchestrationV2ThreadProjection; |
| 417 | 424 | ||
| 418 | const layer = OrchestratorMcpService.layer.pipe( | 425 | const layer = layerOrchestratorService.pipe( |
| 419 | Layer.provide( | 426 | Layer.provide( |
| 420 | Layer.mergeAll( | 427 | Layer.mergeAll( |
| 421 | Layer.mock(ThreadManagementService.ThreadManagementService)({ | 428 | Layer.mock(ThreadManagementService.ThreadManagementService)({ |
apps/server/src/mcp/OrchestratorMcpService.test.ts+27-28| ... | @@ -29,6 +29,8 @@ import { | ... | @@ -29,6 +29,8 @@ import { |
| 29 | OrchestratorThreadAboveModeLimitError, | 29 | OrchestratorThreadAboveModeLimitError, |
| 30 | } from "../orchestration-v2/Orchestrator.ts"; | 30 | } from "../orchestration-v2/Orchestrator.ts"; |
| 31 | import * as ProviderAdapterRegistry from "../orchestration-v2/ProviderAdapterRegistry.ts"; | 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 | import * as ThreadManagementService from "../orchestration-v2/ThreadManagementService.ts"; | 34 | import * as ThreadManagementService from "../orchestration-v2/ThreadManagementService.ts"; |
| 33 | import * as ProviderRegistry from "../provider/ProviderRegistry.ts"; | 35 | import * as ProviderRegistry from "../provider/ProviderRegistry.ts"; |
| 34 | import { buildUnavailableProviderSnapshot } from "../provider/unavailableProviderSnapshot.ts"; | 36 | import { buildUnavailableProviderSnapshot } from "../provider/unavailableProviderSnapshot.ts"; |
| ... | @@ -41,6 +43,11 @@ import { idleThreadProjection, liveThreadShell } from "./McpToolAccess.testkit.t | ... | @@ -41,6 +43,11 @@ import { idleThreadProjection, liveThreadShell } from "./McpToolAccess.testkit.t |
| 41 | import * as OrchestratorMcpService from "./OrchestratorMcpService.ts"; | 43 | import * as OrchestratorMcpService from "./OrchestratorMcpService.ts"; |
| 42 | import type * as ProviderAdapter from "@t3tools/provider-core/server/ProviderAdapter"; | 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 | describe("OrchestratorMcpService", () => { | 51 | describe("OrchestratorMcpService", () => { |
| 45 | it.effect("retries terminal acknowledgement with a fresh command id", () => | 52 | it.effect("retries terminal acknowledgement with a fresh command id", () => |
| 46 | Effect.gen(function* () { | 53 | Effect.gen(function* () { |
| ... | @@ -170,7 +177,7 @@ describe("OrchestratorMcpService", () => { | ... | @@ -170,7 +177,7 @@ describe("OrchestratorMcpService", () => { |
| 170 | const commandIds = yield* Ref.get(acknowledgementCommandIds); | 177 | const commandIds = yield* Ref.get(acknowledgementCommandIds); |
| 171 | assert.equal(commandIds.length, 2); | 178 | assert.equal(commandIds.length, 2); |
| 172 | assert.notEqual(commandIds[0], commandIds[1]); | 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,7 +274,7 @@ describe("OrchestratorMcpService", () => { |
| 267 | const settled = yield* service.taskStatus(scope, taskId); | 274 | const settled = yield* service.taskStatus(scope, taskId); |
| 268 | assert.equal(settled.status, "cancelled"); | 275 | assert.equal(settled.status, "cancelled"); |
| 269 | assert.equal(yield* Ref.get(dispatched), 1); | 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,7 +350,7 @@ describe("OrchestratorMcpService", () => { |
| 343 | .pipe(Effect.flip); | 350 | .pipe(Effect.flip); |
| 344 | assert.equal(error.code, "task_not_cancellable"); | 351 | assert.equal(error.code, "task_not_cancellable"); |
| 345 | assert.deepEqual(yield* Ref.get(dispatched), []); | 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,7 +430,7 @@ describe("OrchestratorMcpService", () => { |
| 423 | (yield* Ref.get(dispatched)).map((command) => (command as { type: string }).type), | 430 | (yield* Ref.get(dispatched)).map((command) => (command as { type: string }).type), |
| 424 | ["thread.stop"], | 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,7 +516,7 @@ describe("OrchestratorMcpService", () => { |
| 509 | (yield* Ref.get(dispatched)).map((command) => (command as { type: string }).type), | 516 | (yield* Ref.get(dispatched)).map((command) => (command as { type: string }).type), |
| 510 | ["thread.stop", "delegated_task.completion-delivery.dispose"], | 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,7 +599,7 @@ describe("OrchestratorMcpService", () => { |
| 592 | .pipe(Effect.flip); | 599 | .pipe(Effect.flip); |
| 593 | assert.equal(error.code, "runtime_mode_escalation_denied"); | 600 | assert.equal(error.code, "runtime_mode_escalation_denied"); |
| 594 | assert.deepEqual(yield* Ref.get(dispatched), []); | 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,7 +743,7 @@ describe("OrchestratorMcpService", () => { |
| 736 | dispatched: yield* Ref.get(dispatched), | 743 | dispatched: yield* Ref.get(dispatched), |
| 737 | stoppedBelow: yield* Ref.get(stoppedBelow), | 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 | it.effect("refuses to cancel a task when a task under it now runs above the parent's modes", () => | 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,9 +1026,7 @@ describe("OrchestratorMcpService provider resolution", () => { |
| 1019 | assert.isTrue( | 1026 | assert.isTrue( |
| 1020 | fork!.constraints.includes("Driver 'forkOnly' is not registered in this build."), | 1027 | fork!.constraints.includes("Driver 'forkOnly' is not registered in this build."), |
| 1021 | ); | 1028 | ); |
| 1022 | }).pipe( | 1029 | }).pipe(Effect.provide(layerOrchestratorService.pipe(Layer.provide(layerDependencies)))); |
| 1023 | Effect.provide(OrchestratorMcpService.layer.pipe(Layer.provide(layerDependencies))), | ||
| 1024 | ); | ||
| 1025 | }), | 1030 | }), |
| 1026 | ); | 1031 | ); |
| 1027 | 1032 | ||
| ... | @@ -1066,7 +1071,7 @@ describe("OrchestratorMcpService provider resolution", () => { | ... | @@ -1066,7 +1071,7 @@ describe("OrchestratorMcpService provider resolution", () => { |
| 1066 | capabilities.providers[0]?.models.map((model) => model.id), | 1071 | capabilities.providers[0]?.models.map((model) => model.id), |
| 1067 | ["gpt-5.4", "my-model"], | 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,9 +1171,7 @@ describe("OrchestratorMcpService provider resolution", () => { |
| 1166 | assert.equal(request.type, "delegated_task.request"); | 1171 | assert.equal(request.type, "delegated_task.request"); |
| 1167 | assert.equal(request.modelSelection.instanceId, antigravityInstanceId); | 1172 | assert.equal(request.modelSelection.instanceId, antigravityInstanceId); |
| 1168 | assert.equal(request.modelSelection.model, "ant-model"); | 1173 | assert.equal(request.modelSelection.model, "ant-model"); |
| 1169 | }).pipe( | 1174 | }).pipe(Effect.provide(layerOrchestratorService.pipe(Layer.provide(layerDependencies)))); |
| 1170 | Effect.provide(OrchestratorMcpService.layer.pipe(Layer.provide(layerDependencies))), | ||
| 1171 | ); | ||
| 1172 | }), | 1175 | }), |
| 1173 | ); | 1176 | ); |
| 1174 | 1177 | ||
| ... | @@ -1264,7 +1267,7 @@ describe("OrchestratorMcpService provider resolution", () => { | ... | @@ -1264,7 +1267,7 @@ describe("OrchestratorMcpService provider resolution", () => { |
| 1264 | }; | 1267 | }; |
| 1265 | assert.equal(request.modelSelection.instanceId, antigravityInstanceId); | 1268 | assert.equal(request.modelSelection.instanceId, antigravityInstanceId); |
| 1266 | assert.equal(request.modelSelection.model, "ant-model"); | 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,7 +1326,7 @@ describe("OrchestratorMcpService provider resolution", () => { |
| 1323 | assert.isTrue( | 1326 | assert.isTrue( |
| 1324 | byDriver.message.includes("No V2 provider adapter is registered for driver forkOnly."), | 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,7 +1372,7 @@ describe("OrchestratorMcpService provider resolution", () => { |
| 1369 | }) | 1372 | }) |
| 1370 | .pipe(Effect.flip); | 1373 | .pipe(Effect.flip); |
| 1371 | assert.equal(error.code, "invalid_request"); | 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,7 +1477,7 @@ describe("OrchestratorMcpService provider resolution", () => { |
| 1474 | modelSelection: { instanceId: string; model: string }; | 1477 | modelSelection: { instanceId: string; model: string }; |
| 1475 | }; | 1478 | }; |
| 1476 | assert.equal(request.modelSelection.instanceId, altInstanceId); | 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,9 +1602,7 @@ describe("OrchestratorMcpService provider resolution", () => { |
| 1599 | modelSelection: { instanceId: string; model: string }; | 1602 | modelSelection: { instanceId: string; model: string }; |
| 1600 | }; | 1603 | }; |
| 1601 | assert.equal(retryCommand.modelSelection.instanceId, codexInstanceId); | 1604 | assert.equal(retryCommand.modelSelection.instanceId, codexInstanceId); |
| 1602 | }).pipe( | 1605 | }).pipe(Effect.provide(layerOrchestratorService.pipe(Layer.provide(layerDependencies)))); |
| 1603 | Effect.provide(OrchestratorMcpService.layer.pipe(Layer.provide(layerDependencies))), | ||
| 1604 | ); | ||
| 1605 | }), | 1606 | }), |
| 1606 | ); | 1607 | ); |
| 1607 | 1608 | ||
| ... | @@ -1706,7 +1707,7 @@ describe("OrchestratorMcpService provider resolution", () => { | ... | @@ -1706,7 +1707,7 @@ describe("OrchestratorMcpService provider resolution", () => { |
| 1706 | assert.equal(result.providerInstanceId, claudeInstanceId); | 1707 | assert.equal(result.providerInstanceId, claudeInstanceId); |
| 1707 | assert.equal(yield* Ref.get(probes), 2); | 1708 | assert.equal(yield* Ref.get(probes), 2); |
| 1708 | assert.equal(yield* Ref.get(dispatched), 1); | 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,9 +1918,7 @@ describe("OrchestratorMcpService provider resolution", () => { |
| 1917 | } else { | 1918 | } else { |
| 1918 | assert.equal(request.modelSelection.model, "codex-alt-model", testCase.name); | 1919 | assert.equal(request.modelSelection.model, "codex-alt-model", testCase.name); |
| 1919 | } | 1920 | } |
| 1920 | }).pipe( | 1921 | }).pipe(Effect.provide(layerOrchestratorService.pipe(Layer.provide(layerDependencies)))); |
| 1921 | Effect.provide(OrchestratorMcpService.layer.pipe(Layer.provide(layerDependencies))), | ||
| 1922 | ); | ||
| 1923 | } | 1922 | } |
| 1924 | }), | 1923 | }), |
| 1925 | ); | 1924 | ); |
| ... | @@ -1972,7 +1971,7 @@ describe("OrchestratorMcpService provider resolution", () => { | ... | @@ -1972,7 +1971,7 @@ describe("OrchestratorMcpService provider resolution", () => { |
| 1972 | boundThread: OrchestrationV2ThreadShell | null, | 1971 | boundThread: OrchestrationV2ThreadShell | null, |
| 1973 | upserted: Ref.Ref<number>, | 1972 | upserted: Ref.Ref<number>, |
| 1974 | ) => | 1973 | ) => |
| 1975 | OrchestratorMcpService.layer.pipe( | 1974 | layerOrchestratorService.pipe( |
| 1976 | Layer.provide( | 1975 | Layer.provide( |
| 1977 | Layer.mergeAll( | 1976 | Layer.mergeAll( |
| 1978 | NodeServices.layer, | 1977 | NodeServices.layer, |
| ... | @@ -2040,7 +2039,7 @@ describe("OrchestratorMcpService provider resolution", () => { | ... | @@ -2040,7 +2039,7 @@ describe("OrchestratorMcpService provider resolution", () => { |
| 2040 | ), | 2039 | ), |
| 2041 | ), | 2040 | ), |
| 2042 | Effect.provide( | 2041 | Effect.provide( |
| 2043 | OrchestratorMcpService.layer.pipe( | 2042 | layerOrchestratorService.pipe( |
| 2044 | Layer.provide( | 2043 | Layer.provide( |
| 2045 | Layer.mergeAll( | 2044 | Layer.mergeAll( |
| 2046 | NodeServices.layer, | 2045 | NodeServices.layer, |
| ... | @@ -2123,7 +2122,7 @@ describe("OrchestratorMcpService provider resolution", () => { | ... | @@ -2123,7 +2122,7 @@ describe("OrchestratorMcpService provider resolution", () => { |
| 2123 | // The edit's own check reads the thread; the read after the save fails. | 2122 | // The edit's own check reads the thread; the read after the save fails. |
| 2124 | const mcp = yield* OrchestratorMcpService.OrchestratorMcpService.pipe( | 2123 | const mcp = yield* OrchestratorMcpService.OrchestratorMcpService.pipe( |
| 2125 | Effect.provide( | 2124 | Effect.provide( |
| 2126 | OrchestratorMcpService.layer.pipe( | 2125 | layerOrchestratorService.pipe( |
| 2127 | Layer.provide( | 2126 | Layer.provide( |
| 2128 | Layer.mergeAll( | 2127 | Layer.mergeAll( |
| 2129 | NodeServices.layer, | 2128 | NodeServices.layer, |
apps/server/src/mcp/OrchestratorMcpService.ts+39| ... | @@ -10,6 +10,7 @@ import { | ... | @@ -10,6 +10,7 @@ import { |
| 10 | type OrchestrationV2Subagent, | 10 | type OrchestrationV2Subagent, |
| 11 | type OrchestrationV2ThreadProjection, | 11 | type OrchestrationV2ThreadProjection, |
| 12 | type OrchestrationV2ThreadShell, | 12 | type OrchestrationV2ThreadShell, |
| 13 | type OrchestrationV2ThreadLaunchWorkspaceStrategy, | ||
| 13 | type OrchestrationV2TurnItem, | 14 | type OrchestrationV2TurnItem, |
| 14 | OrchestratorMcpFailure, | 15 | OrchestratorMcpFailure, |
| 15 | type OrchestratorMcpCapabilitiesResult, | 16 | type OrchestratorMcpCapabilitiesResult, |
| ... | @@ -81,6 +82,8 @@ import { | ... | @@ -81,6 +82,8 @@ import { |
| 81 | type DispatchModeRefusal, | 82 | type DispatchModeRefusal, |
| 82 | } from "../orchestration-v2/DispatchModeLimit.ts"; | 83 | } from "../orchestration-v2/DispatchModeLimit.ts"; |
| 83 | import * as ThreadManagementService from "../orchestration-v2/ThreadManagementService.ts"; | 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 | import { isSnoozed } from "../orchestration-v2/ThreadSettlementService.ts"; | 87 | import { isSnoozed } from "../orchestration-v2/ThreadSettlementService.ts"; |
| 85 | import * as ProjectService from "../project/ProjectService.ts"; | 88 | import * as ProjectService from "../project/ProjectService.ts"; |
| 86 | import * as ProviderRegistry from "../provider/ProviderRegistry.ts"; | 89 | import * as ProviderRegistry from "../provider/ProviderRegistry.ts"; |
| ... | @@ -904,6 +907,8 @@ const make = Effect.gen(function* () { | ... | @@ -904,6 +907,8 @@ const make = Effect.gen(function* () { |
| 904 | const providerAdapters = yield* ProviderAdapterRegistry.ProviderAdapterRegistryV2; | 907 | const providerAdapters = yield* ProviderAdapterRegistry.ProviderAdapterRegistryV2; |
| 905 | const scheduledTasks = yield* ScheduledTaskService.ScheduledTaskService; | 908 | const scheduledTasks = yield* ScheduledTaskService.ScheduledTaskService; |
| 906 | const projects = yield* ProjectService.ProjectService; | 909 | const projects = yield* ProjectService.ProjectService; |
| 910 | const threadLaunch = yield* ThreadLaunchService.ThreadLaunchService; | ||
| 911 | const gitDriver = yield* GitVcsDriver.GitVcsDriver; | ||
| 907 | const serverSettings = yield* ServerSettings.ServerSettingsService; | 912 | const serverSettings = yield* ServerSettings.ServerSettingsService; |
| 908 | 913 | ||
| 909 | /** A caller-named project, which must exist before anything is recorded against it. */ | 914 | /** A caller-named project, which must exist before anything is recorded against it. */ |
| ... | @@ -1993,6 +1998,21 @@ const make = Effect.gen(function* () { | ... | @@ -1993,6 +1998,21 @@ const make = Effect.gen(function* () { |
| 1993 | parent.thread.interactionMode, | 1998 | parent.thread.interactionMode, |
| 1994 | input.interactionMode, | 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 | // ThreadManagementService.dispatch is the chokepoint for this command | 2016 | // ThreadManagementService.dispatch is the chokepoint for this command |
| 1997 | // type's own enablement check, using the same command id and its | 2017 | // type's own enablement check, using the same command id and its |
| 1998 | // receipt-replay exemption, so no separate assert runs here. | 2018 | // receipt-replay exemption, so no separate assert runs here. |
| ... | @@ -2010,6 +2030,7 @@ const make = Effect.gen(function* () { | ... | @@ -2010,6 +2030,7 @@ const make = Effect.gen(function* () { |
| 2010 | modelSelection: target.modelSelection, | 2030 | modelSelection: target.modelSelection, |
| 2011 | runtimeMode, | 2031 | runtimeMode, |
| 2012 | interactionMode, | 2032 | interactionMode, |
| 2033 | ...(workspaceStrategy === undefined ? {} : { workspaceStrategy }), | ||
| 2013 | // Async delegations wake the parent on every child terminal; wait | 2034 | // Async delegations wake the parent on every child terminal; wait |
| 2014 | // delegations deliver through the blocking tool call, so a wake is | 2035 | // delegations deliver through the blocking tool call, so a wake is |
| 2015 | // only needed if the parent settled first (timeout, disconnect). | 2036 | // only needed if the parent settled first (timeout, disconnect). |
| ... | @@ -2036,6 +2057,22 @@ const make = Effect.gen(function* () { | ... | @@ -2036,6 +2057,22 @@ const make = Effect.gen(function* () { |
| 2036 | ); | 2057 | ); |
| 2037 | } | 2058 | } |
| 2038 | const taskId = taskEvent.event.payload.id; | 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 | if (input.mode !== "wait") { | 2077 | if (input.mode !== "wait") { |
| 2041 | return yield* readTask(scope, taskId, false, true); | 2078 | return yield* readTask(scope, taskId, false, true); |
| ... | @@ -2660,6 +2697,8 @@ export const layer: Layer.Layer< | ... | @@ -2660,6 +2697,8 @@ export const layer: Layer.Layer< |
| 2660 | never, | 2697 | never, |
| 2661 | | Crypto.Crypto | 2698 | | Crypto.Crypto |
| 2662 | | ThreadManagementService.ThreadManagementService | 2699 | | ThreadManagementService.ThreadManagementService |
| 2700 | | ThreadLaunchService.ThreadLaunchService | ||
| 2701 | | GitVcsDriver.GitVcsDriver | ||
| 2663 | | ProviderRegistry.ProviderRegistry | 2702 | | ProviderRegistry.ProviderRegistry |
| 2664 | | ProviderAdapterRegistry.ProviderAdapterRegistryV2 | 2703 | | ProviderAdapterRegistry.ProviderAdapterRegistryV2 |
| 2665 | | ScheduledTaskService.ScheduledTaskService | 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,6 +57,8 @@ import { threadShellFromProjection } from "../orchestration-v2/ProjectionStore.t |
| 57 | import * as CommandReceiptStore from "../orchestration-v2/CommandReceiptStore.ts"; | 57 | import * as CommandReceiptStore from "../orchestration-v2/CommandReceiptStore.ts"; |
| 58 | import * as EventSink from "../orchestration-v2/EventSink.ts"; | 58 | import * as EventSink from "../orchestration-v2/EventSink.ts"; |
| 59 | import * as Orchestrator from "../orchestration-v2/Orchestrator.ts"; | 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 | import * as ThreadManagementService from "../orchestration-v2/ThreadManagementService.ts"; | 62 | import * as ThreadManagementService from "../orchestration-v2/ThreadManagementService.ts"; |
| 61 | import * as ServerSettings from "../serverSettings.ts"; | 63 | import * as ServerSettings from "../serverSettings.ts"; |
| 62 | import * as SqlitePersistence from "../persistence/Sqlite.ts"; | 64 | import * as SqlitePersistence from "../persistence/Sqlite.ts"; |
| ... | @@ -80,6 +82,11 @@ import * as McpInvocationContext from "./McpInvocationContext.ts"; | ... | @@ -80,6 +82,11 @@ import * as McpInvocationContext from "./McpInvocationContext.ts"; |
| 80 | import { delegatedTaskRun, hasPendingChildRuns } from "./OrchestratorMcpService.ts"; | 82 | import { delegatedTaskRun, hasPendingChildRuns } from "./OrchestratorMcpService.ts"; |
| 81 | import * as ProviderAdapter from "@t3tools/provider-core/server/ProviderAdapter"; | 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 | // Effect returns a declared tool failure as `isError` with its encoded payload | 90 | // Effect returns a declared tool failure as `isError` with its encoded payload |
| 84 | // as JSON text, never as `structuredContent`. | 91 | // as JSON text, never as `structuredContent`. |
| 85 | const declaredFailure = (result: McpSchema.CallToolResult) => { | 92 | const declaredFailure = (result: McpSchema.CallToolResult) => { |
| ... | @@ -682,7 +689,7 @@ describe("orchestrator MCP toolkit", () => { | ... | @@ -682,7 +689,7 @@ describe("orchestrator MCP toolkit", () => { |
| 682 | }), | 689 | }), |
| 683 | ); | 690 | ); |
| 684 | const layerTest = Layer.merge( | 691 | const layerTest = Layer.merge( |
| 685 | McpHttpServer.layerOrchestratorToolkit, | 692 | layerOrchestratorToolkit, |
| 686 | McpHttpServer.layerThreadToolkit, | 693 | McpHttpServer.layerThreadToolkit, |
| 687 | ).pipe( | 694 | ).pipe( |
| 688 | Layer.provideMerge(McpServer.McpServer.layer), | 695 | Layer.provideMerge(McpServer.McpServer.layer), |
| ... | @@ -3838,7 +3845,7 @@ describe("orchestrator MCP toolkit", () => { | ... | @@ -3838,7 +3845,7 @@ describe("orchestrator MCP toolkit", () => { |
| 3838 | model: codexModel, | 3845 | model: codexModel, |
| 3839 | }), | 3846 | }), |
| 3840 | ]); | 3847 | ]); |
| 3841 | const layerTest = McpHttpServer.layerOrchestratorToolkit.pipe( | 3848 | const layerTest = layerOrchestratorToolkit.pipe( |
| 3842 | Layer.provideMerge(McpServer.McpServer.layer), | 3849 | Layer.provideMerge(McpServer.McpServer.layer), |
| 3843 | Layer.provideMerge(layerOrchestration), | 3850 | Layer.provideMerge(layerOrchestration), |
| 3844 | Layer.provide( | 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,6 +27,8 @@ import * as EffectWorker from "../orchestration-v2/EffectWorker.ts"; |
| 27 | import * as Orchestrator from "../orchestration-v2/Orchestrator.ts"; | 27 | import * as Orchestrator from "../orchestration-v2/Orchestrator.ts"; |
| 28 | import * as ProviderAdapter from "@t3tools/provider-core/server/ProviderAdapter"; | 28 | import * as ProviderAdapter from "@t3tools/provider-core/server/ProviderAdapter"; |
| 29 | import * as ProviderAdapterRegistry from "../orchestration-v2/ProviderAdapterRegistry.ts"; | 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 | import * as ThreadManagementService from "../orchestration-v2/ThreadManagementService.ts"; | 32 | import * as ThreadManagementService from "../orchestration-v2/ThreadManagementService.ts"; |
| 31 | import * as ServerSettings from "../serverSettings.ts"; | 33 | import * as ServerSettings from "../serverSettings.ts"; |
| 32 | import * as ProviderReplayHarness from "../orchestration-v2/testkit/ProviderReplayHarness.ts"; | 34 | import * as ProviderReplayHarness from "../orchestration-v2/testkit/ProviderReplayHarness.ts"; |
| ... | @@ -39,6 +41,11 @@ import * as SecretRequests from "../secrets/SecretRequests.ts"; | ... | @@ -39,6 +41,11 @@ import * as SecretRequests from "../secrets/SecretRequests.ts"; |
| 39 | import type { McpInvocationScope } from "./McpInvocationContext.ts"; | 41 | import type { McpInvocationScope } from "./McpInvocationContext.ts"; |
| 40 | import * as OrchestratorMcpService from "./OrchestratorMcpService.ts"; | 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 | const driver = ProviderDriverKind.make("claudeAgent"); | 49 | const driver = ProviderDriverKind.make("claudeAgent"); |
| 43 | const instanceId = ProviderInstanceId.make("claudeAgent"); | 50 | const instanceId = ProviderInstanceId.make("claudeAgent"); |
| 44 | const modelSelection = { instanceId, model: "claude-test" }; | 51 | const modelSelection = { instanceId, model: "claude-test" }; |
| ... | @@ -143,7 +150,7 @@ it.effect("task_cancel ends a delegated child's background native subagent", () | ... | @@ -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 | Layer.provideMerge(layerOrchestration), | 154 | Layer.provideMerge(layerOrchestration), |
| 148 | Layer.provide( | 155 | Layer.provide( |
| 149 | Layer.mergeAll( | 156 | Layer.mergeAll( |
apps/server/src/mcp/toolkits/core.test.ts+11-4| ... | @@ -35,6 +35,8 @@ import { | ... | @@ -35,6 +35,8 @@ import { |
| 35 | import * as ServerSecretStore from "../../auth/ServerSecretStore.ts"; | 35 | import * as ServerSecretStore from "../../auth/ServerSecretStore.ts"; |
| 36 | import * as ServerConfig from "../../config.ts"; | 36 | import * as ServerConfig from "../../config.ts"; |
| 37 | import * as ProviderAdapterRegistry from "../../orchestration-v2/ProviderAdapterRegistry.ts"; | 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 | import * as ThreadManagement from "../../orchestration-v2/ThreadManagementService.ts"; | 40 | import * as ThreadManagement from "../../orchestration-v2/ThreadManagementService.ts"; |
| 39 | import * as ThreadSearch from "../../orchestration-v2/ThreadSearch.ts"; | 41 | import * as ThreadSearch from "../../orchestration-v2/ThreadSearch.ts"; |
| 40 | import * as PreviewBrowser from "../../preview/PreviewBrowser.ts"; | 42 | import * as PreviewBrowser from "../../preview/PreviewBrowser.ts"; |
| ... | @@ -59,6 +61,11 @@ import { ThreadToolkit } from "./thread/tools.ts"; | ... | @@ -59,6 +61,11 @@ import { ThreadToolkit } from "./thread/tools.ts"; |
| 59 | import { WorktreeToolkit } from "./worktree/tools.ts"; | 61 | import { WorktreeToolkit } from "./worktree/tools.ts"; |
| 60 | import { DeviceToolkit } from "./device/tools.ts"; | 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 | // Effect returns a declared tool failure as `isError` with its encoded payload | 69 | // Effect returns a declared tool failure as `isError` with its encoded payload |
| 63 | // as JSON text, never as `structuredContent`. | 70 | // as JSON text, never as `structuredContent`. |
| 64 | const declaredFailure = (result: McpSchema.CallToolResult) => { | 71 | const declaredFailure = (result: McpSchema.CallToolResult) => { |
| ... | @@ -578,7 +585,7 @@ it.effect("refuses act-as-caller tools to a client caller", () => | ... | @@ -578,7 +585,7 @@ it.effect("refuses act-as-caller tools to a client caller", () => |
| 578 | expect(declaredFailure(result)).toMatchObject({ code: "thread_credential_required" }); | 585 | expect(declaredFailure(result)).toMatchObject({ code: "thread_credential_required" }); |
| 579 | }).pipe( | 586 | }).pipe( |
| 580 | Effect.provide( | 587 | Effect.provide( |
| 581 | McpHttpServer.layerOrchestratorToolkit.pipe( | 588 | layerOrchestratorToolkit.pipe( |
| 582 | Layer.provideMerge(McpServer.McpServer.layer), | 589 | Layer.provideMerge(McpServer.McpServer.layer), |
| 583 | Layer.provide(NodeCrypto.layer), | 590 | Layer.provide(NodeCrypto.layer), |
| 584 | Layer.provide(Layer.mock(ThreadManagement.ThreadManagementService)({})), | 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,7 +624,7 @@ it.effect("a caller cannot rewrite a scheduled task that runs above its own mode |
| 617 | expect(allowed.isError).toBe(false); | 624 | expect(allowed.isError).toBe(false); |
| 618 | }).pipe( | 625 | }).pipe( |
| 619 | Effect.provide( | 626 | Effect.provide( |
| 620 | McpHttpServer.layerOrchestratorToolkit.pipe( | 627 | layerOrchestratorToolkit.pipe( |
| 621 | Layer.provideMerge(McpServer.McpServer.layer), | 628 | Layer.provideMerge(McpServer.McpServer.layer), |
| 622 | Layer.provide(NodeCrypto.layer), | 629 | Layer.provide(NodeCrypto.layer), |
| 623 | Layer.provide(Layer.mock(ThreadManagement.ThreadManagementService)({})), | 630 | Layer.provide(Layer.mock(ThreadManagement.ThreadManagementService)({})), |
| ... | @@ -692,7 +699,7 @@ it.effect.each(["t3_thread_interrupt", "t3_workflow_stop"])( | ... | @@ -692,7 +699,7 @@ it.effect.each(["t3_thread_interrupt", "t3_workflow_stop"])( |
| 692 | expect(declaredFailure(result)).toMatchObject({ code: "runtime_mode_escalation_denied" }); | 699 | expect(declaredFailure(result)).toMatchObject({ code: "runtime_mode_escalation_denied" }); |
| 693 | }).pipe( | 700 | }).pipe( |
| 694 | Effect.provide( | 701 | Effect.provide( |
| 695 | McpHttpServer.layerOrchestratorToolkit.pipe( | 702 | layerOrchestratorToolkit.pipe( |
| 696 | Layer.provideMerge(McpServer.McpServer.layer), | 703 | Layer.provideMerge(McpServer.McpServer.layer), |
| 697 | Layer.provide(NodeCrypto.layer), | 704 | Layer.provide(NodeCrypto.layer), |
| 698 | Layer.provide( | 705 | Layer.provide( |
| ... | @@ -796,7 +803,7 @@ it.effect("discovers workflow stop targets and requires orchestration capability | ... | @@ -796,7 +803,7 @@ it.effect("discovers workflow stop targets and requires orchestration capability |
| 796 | expect(stopped).toEqual([{ threadId: "workflow-parent", subagentId: "workflow-coordinator" }]); | 803 | expect(stopped).toEqual([{ threadId: "workflow-parent", subagentId: "workflow-coordinator" }]); |
| 797 | }).pipe( | 804 | }).pipe( |
| 798 | Effect.provide( | 805 | Effect.provide( |
| 799 | McpHttpServer.layerOrchestratorToolkit.pipe( | 806 | layerOrchestratorToolkit.pipe( |
| 800 | Layer.provideMerge(McpServer.McpServer.layer), | 807 | Layer.provideMerge(McpServer.McpServer.layer), |
| 801 | Layer.provide(NodeCrypto.layer), | 808 | Layer.provide(NodeCrypto.layer), |
| 802 | Layer.provide( | 809 | Layer.provide( |
apps/server/src/mcp/toolkits/orchestrator/tools.ts+1-1| ... | @@ -65,7 +65,7 @@ const OrchestratorCapabilitiesTool = Tool.make("orchestrator_capabilities", { | ... | @@ -65,7 +65,7 @@ const OrchestratorCapabilitiesTool = Tool.make("orchestrator_capabilities", { |
| 65 | 65 | ||
| 66 | export const DelegateTaskTool = Tool.make("delegate_task", { | 66 | export const DelegateTaskTool = Tool.make("delegate_task", { |
| 67 | description: | 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 | parameters: OrchestratorMcpDelegateTaskInput, | 69 | parameters: OrchestratorMcpDelegateTaskInput, |
| 70 | success: OrchestratorMcpDelegateTaskResult, | 70 | success: OrchestratorMcpDelegateTaskResult, |
| 71 | failure: OrchestratorMcpFailure, | 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,6 +6666,7 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio |
| 6666 | }), | 6666 | }), |
| 6667 | runtimeMode: command.runtimeMode, | 6667 | runtimeMode: command.runtimeMode, |
| 6668 | interactionMode: command.interactionMode, | 6668 | interactionMode: command.interactionMode, |
| 6669 | ...(command.workspaceStrategy === undefined ? {} : { branch: null, worktreePath: null }), | ||
| 6669 | }; | 6670 | }; |
| 6670 | const task: OrchestrationV2Subagent = { | 6671 | const task: OrchestrationV2Subagent = { |
| 6671 | id: taskNodeId, | 6672 | id: taskNodeId, |
| ... | @@ -6783,7 +6784,10 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio | ... | @@ -6783,7 +6784,10 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio |
| 6783 | text: command.task, | 6784 | text: command.task, |
| 6784 | attachments: [], | 6785 | attachments: [], |
| 6785 | modelSelection: command.modelSelection, | 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 | } satisfies Extract<OrchestrationV2Command, { readonly type: "message.dispatch" }>; | 6791 | } satisfies Extract<OrchestrationV2Command, { readonly type: "message.dispatch" }>; |
| 6788 | yield* dispatchMessage(childMessageCommand, events, effects); | 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,7 +643,7 @@ it.effect("closes a secret request card's form when its run is recovered", () => |
| 643 | }).pipe(Effect.provide(layer)); | 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 | const threadId = ThreadId.make("thread_queued_restart"); | 647 | const threadId = ThreadId.make("thread_queued_restart"); |
| 648 | const runId = RunId.make("run_queued_restart"); | 648 | const runId = RunId.make("run_queued_restart"); |
| 649 | const attemptId = RunAttemptId.make("attempt_queued_restart"); | 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,9 +659,18 @@ it.effect("holds accepted queued work without cancelling its execution state aft |
| 659 | runs: [ | 659 | runs: [ |
| 660 | { | 660 | { |
| 661 | id: runId, | 661 | id: runId, |
| 662 | status: "queued", | 662 | status, |
| 663 | queuePosition: 1, | 663 | queuePosition: 1, |
| 664 | providerInstanceId: ProviderInstanceId.make("codex"), | 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 | attempts: [ | 676 | attempts: [ |
| ... | @@ -712,18 +721,33 @@ it.effect("holds accepted queued work without cancelling its execution state aft | ... | @@ -712,18 +721,33 @@ it.effect("holds accepted queued work without cancelling its execution state aft |
| 712 | return Effect.gen(function* () { | 721 | return Effect.gen(function* () { |
| 713 | const summary = | 722 | const summary = |
| 714 | yield* (yield* ProviderRuntimeRecovery.ProviderRuntimeRecoveryService).reconcile("startup"); | 723 | yield* (yield* ProviderRuntimeRecovery.ProviderRuntimeRecoveryService).reconcile("startup"); |
| 715 | assert.equal(summary.terminalizedRuns, 0); | 724 | assert.equal(summary.terminalizedRuns, status === "preparing" ? 1 : 0); |
| 716 | const command = committedInput; | 725 | const command = committedInput; |
| 717 | assert.isNotNull(command); | 726 | assert.isNotNull(command); |
| 718 | if (command === null) return; | 727 | if (command === null) return; |
| 719 | const runEvent = command.events.find((event) => event.type === "run.updated"); | 728 | const runEvent = command.events.find((event) => event.type === "run.updated"); |
| 720 | const attemptEvent = command.events.find((event) => event.type === "run-attempt.updated"); | 729 | const attemptEvent = command.events.find((event) => event.type === "run-attempt.updated"); |
| 721 | const nodeEvent = command.events.find((event) => event.type === "node.updated"); | 730 | const nodeEvent = command.events.find((event) => event.type === "node.updated"); |
| 722 | assert.equal(runEvent?.type === "run.updated" ? runEvent.payload.status : null, "queued"); | 731 | assert.equal( |
| 723 | assert.equal(runEvent?.type === "run.updated" ? runEvent.payload.queuePosition : null, 1); | 732 | runEvent?.type === "run.updated" ? runEvent.payload.status : null, |
| 724 | assert.equal(runEvent?.type === "run.updated" ? runEvent.payload.queueHeld : false, true); | 733 | status === "preparing" ? "cancelled" : "queued", |
| 725 | assert.isUndefined(attemptEvent); | 734 | ); |
| 726 | assert.isUndefined(nodeEvent); | 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 | }).pipe(Effect.provide(layer)); | 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,6 +15,7 @@ import { |
| 15 | ComposerContextId, | 15 | ComposerContextId, |
| 16 | type ChatAttachment, | 16 | type ChatAttachment, |
| 17 | CommandId, | 17 | CommandId, |
| 18 | EnvironmentId, | ||
| 18 | DEFAULT_SERVER_SETTINGS, | 19 | DEFAULT_SERVER_SETTINGS, |
| 19 | GitCommandError, | 20 | GitCommandError, |
| 20 | MessageId, | 21 | MessageId, |
| ... | @@ -56,6 +57,8 @@ import * as EffectOutbox from "./EffectOutbox.ts"; | ... | @@ -56,6 +57,8 @@ import * as EffectOutbox from "./EffectOutbox.ts"; |
| 56 | import * as IdAllocator from "@t3tools/provider-core/server/IdAllocator"; | 57 | import * as IdAllocator from "@t3tools/provider-core/server/IdAllocator"; |
| 57 | import * as ProviderAdapterRegistry from "./ProviderAdapterRegistry.ts"; | 58 | import * as ProviderAdapterRegistry from "./ProviderAdapterRegistry.ts"; |
| 58 | import * as ThreadLaunch from "./ThreadLaunchService.ts"; | 59 | import * as ThreadLaunch from "./ThreadLaunchService.ts"; |
| 60 | import * as OrchestratorMcpService from "../mcp/OrchestratorMcpService.ts"; | ||
| 61 | import * as GitVcsDriver from "../vcs/GitVcsDriver.ts"; | ||
| 59 | import * as ThreadManagement from "./ThreadManagementService.ts"; | 62 | import * as ThreadManagement from "./ThreadManagementService.ts"; |
| 60 | import * as ThreadTitleRegeneration from "./ThreadTitleRegenerationService.ts"; | 63 | import * as ThreadTitleRegeneration from "./ThreadTitleRegenerationService.ts"; |
| 61 | import * as ProviderReplayHarness from "./testkit/ProviderReplayHarness.ts"; | 64 | import * as ProviderReplayHarness from "./testkit/ProviderReplayHarness.ts"; |
| ... | @@ -97,6 +100,8 @@ const adapter = { | ... | @@ -97,6 +100,8 @@ const adapter = { |
| 97 | } as ProviderAdapter.ProviderAdapterV2["Service"]; | 100 | } as ProviderAdapter.ProviderAdapterV2["Service"]; |
| 98 | 101 | ||
| 99 | interface HarnessOptions { | 102 | interface HarnessOptions { |
| 103 | readonly closeTerminal?: TerminalManager.TerminalManager["Service"]["close"]; | ||
| 104 | readonly adapters?: ReadonlyArray<ProviderAdapter.ProviderAdapterV2["Service"]>; | ||
| 100 | readonly managedFolders?: Layer.Layer<ManagedProjectFolders.ManagedProjectFolders>; | 105 | readonly managedFolders?: Layer.Layer<ManagedProjectFolders.ManagedProjectFolders>; |
| 101 | readonly createWorktree?: GitWorkflow.GitWorkflowService["Service"]["createWorktree"]; | 106 | readonly createWorktree?: GitWorkflow.GitWorkflowService["Service"]["createWorktree"]; |
| 102 | readonly fetchRemote?: GitWorkflow.GitWorkflowService["Service"]["fetchRemote"]; | 107 | readonly fetchRemote?: GitWorkflow.GitWorkflowService["Service"]["fetchRemote"]; |
| ... | @@ -112,7 +117,7 @@ interface HarnessOptions { | ... | @@ -112,7 +117,7 @@ interface HarnessOptions { |
| 112 | 117 | ||
| 113 | function makeHarness(options: HarnessOptions = {}) { | 118 | function makeHarness(options: HarnessOptions = {}) { |
| 114 | const layerDatabase = SqlitePersistence.layerMemory; | 119 | const layerDatabase = SqlitePersistence.layerMemory; |
| 115 | const layerRegistry = ProviderAdapterRegistry.layerFromAdapters([adapter]); | 120 | const layerRegistry = ProviderAdapterRegistry.layerFromAdapters(options.adapters ?? [adapter]); |
| 116 | const layerOrchestrator = ProviderReplayHarness.layerWithRegistry( | 121 | const layerOrchestrator = ProviderReplayHarness.layerWithRegistry( |
| 117 | { name: "thread-launch" }, | 122 | { name: "thread-launch" }, |
| 118 | layerRegistry, | 123 | layerRegistry, |
| ... | @@ -143,6 +148,7 @@ function makeHarness(options: HarnessOptions = {}) { | ... | @@ -143,6 +148,7 @@ function makeHarness(options: HarnessOptions = {}) { |
| 143 | const runSetup = vi.fn( | 148 | const runSetup = vi.fn( |
| 144 | options.runSetup ?? (() => Effect.succeed({ status: "no-script" as const })), | 149 | options.runSetup ?? (() => Effect.succeed({ status: "no-script" as const })), |
| 145 | ); | 150 | ); |
| 151 | const closeTerminal = vi.fn(options.closeTerminal ?? (() => Effect.void)); | ||
| 146 | const generateBranchName = vi.fn( | 152 | const generateBranchName = vi.fn( |
| 147 | options.generateBranchName ?? (() => Effect.succeed({ branch: "generated-branch" })), | 153 | options.generateBranchName ?? (() => Effect.succeed({ branch: "generated-branch" })), |
| 148 | ); | 154 | ); |
| ... | @@ -152,7 +158,7 @@ function makeHarness(options: HarnessOptions = {}) { | ... | @@ -152,7 +158,7 @@ function makeHarness(options: HarnessOptions = {}) { |
| 152 | const layerExternalServices = Layer.mergeAll( | 158 | const layerExternalServices = Layer.mergeAll( |
| 153 | WorktreeSetupTracker.layer, | 159 | WorktreeSetupTracker.layer, |
| 154 | Layer.mock(ProjectCloneTracker.ProjectCloneTracker)({ get: () => Effect.succeed(null) }), | 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 | Layer.succeed(ProjectService.ProjectService, { | 162 | Layer.succeed(ProjectService.ProjectService, { |
| 157 | create: () => Effect.die("unused"), | 163 | create: () => Effect.die("unused"), |
| 158 | bootstrap: () => Effect.die("unused"), | 164 | bootstrap: () => Effect.die("unused"), |
| ... | @@ -237,6 +243,7 @@ function makeHarness(options: HarnessOptions = {}) { | ... | @@ -237,6 +243,7 @@ function makeHarness(options: HarnessOptions = {}) { |
| 237 | return { | 243 | return { |
| 238 | layer: Layer.mergeAll( | 244 | layer: Layer.mergeAll( |
| 239 | layerLaunch, | 245 | layerLaunch, |
| 246 | layerRegistry, | ||
| 240 | layerThreadManagement, | 247 | layerThreadManagement, |
| 241 | layerTitleRegeneration, | 248 | layerTitleRegeneration, |
| 242 | layerOutbox, | 249 | layerOutbox, |
| ... | @@ -249,6 +256,7 @@ function makeHarness(options: HarnessOptions = {}) { | ... | @@ -249,6 +256,7 @@ function makeHarness(options: HarnessOptions = {}) { |
| 249 | generateBranchName, | 256 | generateBranchName, |
| 250 | generateThreadTitle, | 257 | generateThreadTitle, |
| 251 | runSetup, | 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,6 +2271,7 @@ it.effect("shared intake preserves durable attachment bytes after a lost launch |
| 2263 | ), | 2271 | ), |
| 2264 | checkWorktreeBase: launches.checkWorktreeBase, | 2272 | checkWorktreeBase: launches.checkWorktreeBase, |
| 2265 | retryPreparation: launches.retryPreparation, | 2273 | retryPreparation: launches.retryPreparation, |
| 2274 | prepareRun: launches.prepareRun, | ||
| 2266 | }), | 2275 | }), |
| 2267 | Effect.flip, | 2276 | Effect.flip, |
| 2268 | ); | 2277 | ); |
| ... | @@ -2705,3 +2714,220 @@ it.effect("replays an accepted message.dispatch after the project disables the i | ... | @@ -2705,3 +2714,220 @@ it.effect("replays an accepted message.dispatch after the project disables the i |
| 2705 | } | 2714 | } |
| 2706 | }).pipe(Effect.provide(Layer.mergeAll(harness.layer, layerFiles))); | 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,6 +28,7 @@ import * as Option from "effect/Option"; |
| 28 | import * as Ref from "effect/Ref"; | 28 | import * as Ref from "effect/Ref"; |
| 29 | import * as Schema from "effect/Schema"; | 29 | import * as Schema from "effect/Schema"; |
| 30 | import * as Scope from "effect/Scope"; | 30 | import * as Scope from "effect/Scope"; |
| 31 | import * as Stream from "effect/Stream"; | ||
| 31 | import { | 32 | import { |
| 32 | buildTemporaryWorktreeBranchName, | 33 | buildTemporaryWorktreeBranchName, |
| 33 | flattenTemporaryWorktreeBranchName, | 34 | flattenTemporaryWorktreeBranchName, |
| ... | @@ -109,7 +110,7 @@ type PreparationInput = Pick< | ... | @@ -109,7 +110,7 @@ type PreparationInput = Pick< |
| 109 | readonly reusedWorktree?: { readonly baseRef: string }; | 110 | readonly reusedWorktree?: { readonly baseRef: string }; |
| 110 | }; | 111 | }; |
| 111 | 112 | ||
| 112 | export interface ThreadLaunchRetryInput { | 113 | export interface ThreadLaunchPreparationInput { |
| 113 | readonly commandId: CommandId; | 114 | readonly commandId: CommandId; |
| 114 | readonly threadId: ThreadId; | 115 | readonly threadId: ThreadId; |
| 115 | readonly runId: RunId; | 116 | readonly runId: RunId; |
| ... | @@ -180,13 +181,14 @@ export class ThreadLaunchService extends Context.Service< | ... | @@ -180,13 +181,14 @@ export class ThreadLaunchService extends Context.Service< |
| 180 | input: Pick<ThreadLaunchInput, "projectId" | "workspaceStrategy">, | 181 | input: Pick<ThreadLaunchInput, "projectId" | "workspaceStrategy">, |
| 181 | ) => Effect.Effect<void, ThreadLaunchBaseRefError>; | 182 | ) => Effect.Effect<void, ThreadLaunchBaseRefError>; |
| 182 | /** Dispatches prepared-run.retry and prepares the run's workspace again. */ | 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 | Orchestrator.OrchestratorV2DispatchResult, | 185 | Orchestrator.OrchestratorV2DispatchResult, |
| 185 | // Dispatches prepared-run.retry, a command the enablement chokepoint | 186 | // Dispatches prepared-run.retry, a command the enablement chokepoint |
| 186 | // never gates; the type stays accurate anyway. | 187 | // never gates; the type stays accurate anyway. |
| 187 | | Orchestrator.OrchestratorV2Error | 188 | | Orchestrator.OrchestratorV2Error |
| 188 | | ThreadManagement.ThreadManagementProviderInstanceDisabledError | 189 | | ThreadManagement.ThreadManagementProviderInstanceDisabledError |
| 189 | >; | 190 | >; |
| 191 | readonly prepareRun: (input: ThreadLaunchPreparationInput) => Effect.Effect<void>; | ||
| 190 | } | 192 | } |
| 191 | >()("t3/orchestration-v2/ThreadLaunchService") {} | 193 | >()("t3/orchestration-v2/ThreadLaunchService") {} |
| 192 | 194 | ||
| ... | @@ -317,6 +319,7 @@ const make = Effect.gen(function* () { | ... | @@ -317,6 +319,7 @@ const make = Effect.gen(function* () { |
| 317 | let createdWorktreePath: string | null = null; | 319 | let createdWorktreePath: string | null = null; |
| 318 | let setupTerminalId: string | null = null; | 320 | let setupTerminalId: string | null = null; |
| 319 | let workspaceRecorded = false; | 321 | let workspaceRecorded = false; |
| 322 | let runReleased = false; | ||
| 320 | if (input.workspaceStrategy.type === "worktree") { | 323 | if (input.workspaceStrategy.type === "worktree") { |
| 321 | yield* setupTracker.begin({ | 324 | yield* setupTracker.begin({ |
| 322 | threadId, | 325 | threadId, |
| ... | @@ -623,7 +626,15 @@ const make = Effect.gen(function* () { | ... | @@ -623,7 +626,15 @@ const make = Effect.gen(function* () { |
| 623 | threadId, | 626 | threadId, |
| 624 | runId, | 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 | yield* setupTracker.stageStatus(threadId, "agent", "done"); | 639 | yield* setupTracker.stageStatus(threadId, "agent", "done"); |
| 629 | yield* awaitAsyncSetup; | 640 | yield* awaitAsyncSetup; |
| ... | @@ -637,14 +648,15 @@ const make = Effect.gen(function* () { | ... | @@ -637,14 +648,15 @@ const make = Effect.gen(function* () { |
| 637 | cancelled ? "cancelled" : "failed", | 648 | cancelled ? "cancelled" : "failed", |
| 638 | cancelled ? null : failureDetail(Cause.squash(cause)), | 649 | cancelled ? null : failureDetail(Cause.squash(cause)), |
| 639 | ); | 650 | ); |
| 640 | // A cancelled setup leaves nothing behind. A failed one keeps a worktree | 651 | if (cancelled && setupTerminalId) |
| 641 | // the thread recorded, so a retry reuses it, and removes one it never | 652 | yield* terminals |
| 642 | // recorded, which a retry would otherwise duplicate. | 653 | .close({ threadId, terminalId: setupTerminalId, deleteHistory: true }) |
| 643 | if (tracked && createdWorktreePath && (cancelled || !workspaceRecorded)) { | 654 | .pipe(Effect.ignore); |
| 644 | if (setupTerminalId) | 655 | if ( |
| 645 | yield* terminals | 656 | tracked && |
| 646 | .close({ threadId, terminalId: setupTerminalId, deleteHistory: true }) | 657 | createdWorktreePath && |
| 647 | .pipe(Effect.ignore); | 658 | ((cancelled && !runReleased) || !workspaceRecorded) |
| 659 | ) { | ||
| 648 | const removedPath = createdWorktreePath; | 660 | const removedPath = createdWorktreePath; |
| 649 | // The thread forgets the worktree only once it is gone; a failed | 661 | // The thread forgets the worktree only once it is gone; a failed |
| 650 | // removal leaves the directory for the user to clean up rather than | 662 | // removal leaves the directory for the user to clean up rather than |
| ... | @@ -734,7 +746,33 @@ const make = Effect.gen(function* () { | ... | @@ -734,7 +746,33 @@ const make = Effect.gen(function* () { |
| 734 | threadId: ThreadId, | 746 | threadId: ThreadId, |
| 735 | runId: RunId | null, | 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 | Effect.onError((cause) => | 776 | Effect.onError((cause) => |
| 739 | failPreparedRun( | 777 | failPreparedRun( |
| 740 | input, | 778 | input, |
| ... | @@ -979,37 +1017,39 @@ const make = Effect.gen(function* () { | ... | @@ -979,37 +1017,39 @@ const make = Effect.gen(function* () { |
| 979 | }, | 1017 | }, |
| 980 | ); | 1018 | ); |
| 981 | 1019 | ||
| 982 | const retryPreparation: ThreadLaunchService["Service"]["retryPreparation"] = Effect.fn( | 1020 | const prepareRun: ThreadLaunchService["Service"]["prepareRun"] = Effect.fn( |
| 983 | "ThreadLaunchService.retryPreparation", | 1021 | "ThreadLaunchService.prepareRun", |
| 984 | )(function* (input) { | 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 | const scheduled = yield* Effect.gen(function* () { | 1023 | const scheduled = yield* Effect.gen(function* () { |
| 996 | const projection = yield* threads.getThreadProjection(input.threadId); | 1024 | const projection = yield* threads.getThreadProjection(input.threadId); |
| 997 | const run = projection.runs.find((candidate) => candidate.id === input.runId); | 1025 | const run = projection.runs.find((candidate) => candidate.id === input.runId); |
| 998 | const workspacePreparation = run?.workspacePreparation; | 1026 | const workspacePreparation = run?.workspacePreparation; |
| 999 | if (run?.status !== "preparing" || workspacePreparation === undefined) return; | 1027 | if (run?.status !== "preparing" || workspacePreparation === undefined) return; |
| 1000 | if (!(yield* reservePreparation(input.commandId))) return; | 1028 | if (!(yield* reservePreparation(input.commandId))) return; |
| 1001 | yield* scheduleRetriedPreparation(input, projection, run, workspacePreparation).pipe( | 1029 | yield* scheduleRunPreparation(input, projection, run, workspacePreparation).pipe( |
| 1002 | Effect.onError(() => releasePreparation(input.commandId)), | 1030 | Effect.onError(() => releasePreparation(input.commandId)), |
| 1003 | ); | 1031 | ); |
| 1004 | }).pipe(Effect.exit); | 1032 | }).pipe(Effect.exit); |
| 1005 | if (Exit.isFailure(scheduled)) { | 1033 | if (Exit.isFailure(scheduled)) { |
| 1006 | yield* failPreparedRun(input, input.threadId, input.runId, Cause.squash(scheduled.cause)); | 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 | return dispatched; | 1048 | return dispatched; |
| 1009 | }); | 1049 | }); |
| 1010 | 1050 | ||
| 1011 | const scheduleRetriedPreparation = ( | 1051 | const scheduleRunPreparation = ( |
| 1012 | input: ThreadLaunchRetryInput, | 1052 | input: ThreadLaunchPreparationInput, |
| 1013 | projection: OrchestrationV2ThreadProjection, | 1053 | projection: OrchestrationV2ThreadProjection, |
| 1014 | run: OrchestrationV2ThreadProjection["runs"][number], | 1054 | run: OrchestrationV2ThreadProjection["runs"][number], |
| 1015 | workspacePreparation: ThreadLaunchWorkspaceStrategy, | 1055 | workspacePreparation: ThreadLaunchWorkspaceStrategy, |
| ... | @@ -1050,7 +1090,7 @@ const make = Effect.gen(function* () { | ... | @@ -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 | export const layer = Layer.effect(ThreadLaunchService, make); | 1096 | export const layer = Layer.effect(ThreadLaunchService, make); |
packages/contracts/src/orchestrationV2.ts+1| ... | @@ -3110,6 +3110,7 @@ export const OrchestrationV2Command = Schema.Union([ | ... | @@ -3110,6 +3110,7 @@ export const OrchestrationV2Command = Schema.Union([ |
| 3110 | modelSelection: ModelSelection, | 3110 | modelSelection: ModelSelection, |
| 3111 | runtimeMode: RuntimeMode, | 3111 | runtimeMode: RuntimeMode, |
| 3112 | interactionMode: ProviderInteractionMode, | 3112 | interactionMode: ProviderInteractionMode, |
| 3113 | workspaceStrategy: Schema.optional(OrchestrationV2ThreadLaunchWorkspaceStrategy), | ||
| 3113 | // Omitted behaves as "settled_only" (no wake while the parent has a live | 3114 | // Omitted behaves as "settled_only" (no wake while the parent has a live |
| 3114 | // run); producers that want fire-and-forget wakes must set "always". | 3115 | // run); producers that want fire-and-forget wakes must set "always". |
| 3115 | completionWake: Schema.optional(Schema.Literals(["always", "settled_only"])), | 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,6 +22,13 @@ const decodeThreadSendInput = Schema.decodeUnknownSync(OrchestratorMcpThreadSend |
| 22 | const decodeThreadWaitInput = Schema.decodeUnknownSync(OrchestratorMcpThreadWaitInput); | 22 | const decodeThreadWaitInput = Schema.decodeUnknownSync(OrchestratorMcpThreadWaitInput); |
| 23 | 23 | ||
| 24 | describe("orchestrator MCP contracts", () => { | 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 | it("decodes cross-provider delegated task requests and durable results", () => { | 32 | it("decodes cross-provider delegated task requests and durable results", () => { |
| 26 | const request = decodeDelegateTaskInput({ | 33 | const request = decodeDelegateTaskInput({ |
| 27 | task: "Inspect the workspace and report the result.", | 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,6 +174,12 @@ export const OrchestratorMcpDelegateTaskInput = Schema.Struct({ |
| 174 | target: Schema.optional(OrchestratorMcpTarget), | 174 | target: Schema.optional(OrchestratorMcpTarget), |
| 175 | title: Schema.optional(OrchestratorMcpTitle), | 175 | title: Schema.optional(OrchestratorMcpTitle), |
| 176 | role: Schema.optional(OrchestratorMcpTaskRole), | 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 | mode: Schema.optional( | 183 | mode: Schema.optional( |
| 178 | Schema.Literals(["async", "wait"]).annotate({ | 184 | Schema.Literals(["async", "wait"]).annotate({ |
| 179 | description: | 185 | description: |
packages/provider-core/src/server/orchestrationInstructions.ts+1-1| ... | @@ -6,7 +6,7 @@ export const T3_CODE_ORCHESTRATION_INSTRUCTIONS = ` | ... | @@ -6,7 +6,7 @@ export const T3_CODE_ORCHESTRATION_INSTRUCTIONS = ` |
| 6 | 6 | ||
| 7 | The \`t3-code\` MCP server provides app-owned orchestration. Treat these concepts distinctly: | 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 | - \`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. | 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 | - 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. | 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 | - \`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. | 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. |