authorgravatar for 62337003+Bil0000@users.noreply.github.comBilal Bakr <62337003+Bil0000@users.noreply.github.com> 2026-10-14 03:39:02+03:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-10-13 19:39:02-05:00
log25629a76a0458bdeb707483722894158af090b41
treed51ab305c1b78d6a1e5994d53af98eef70f5f2c3
parent3feae3a94077317e78b7fd4591454ed68f9d4fd2
signature Signed by PGP key B5690EEEBB952194

feat(server): run delegated tasks in separate worktrees (#19102)


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";
22import * as ScheduledTaskService from "../scheduledTasks/ScheduledTaskService.ts";22import * as ScheduledTaskService from "../scheduledTasks/ScheduledTaskService.ts";
23import * as ServerSettings from "../serverSettings.ts";23import * as ServerSettings from "../serverSettings.ts";
24import * as SecretRequests from "../secrets/SecretRequests.ts";24import * as SecretRequests from "../secrets/SecretRequests.ts";
25import * as ThreadLaunchService from "../orchestration-v2/ThreadLaunchService.ts";
26import * as GitVcsDriver from "../vcs/GitVcsDriver.ts";
25import * as ThreadManagementService from "../orchestration-v2/ThreadManagementService.ts";27import * as ThreadManagementService from "../orchestration-v2/ThreadManagementService.ts";
26import type * as McpInvocationContext from "./McpInvocationContext.ts";28import type * as McpInvocationContext from "./McpInvocationContext.ts";
27import * as OrchestratorMcpService from "./OrchestratorMcpService.ts";29import * as OrchestratorMcpService from "./OrchestratorMcpService.ts";
2830
31const layerOrchestratorService = OrchestratorMcpService.layer.pipe(
32 Layer.provide(Layer.mock(ThreadLaunchService.ThreadLaunchService)({})),
33 Layer.provide(Layer.mock(GitVcsDriver.GitVcsDriver)({})),
34);
35
29const environmentId = EnvironmentId.make("environment-mcp-orchestrator-detail");36const environmentId = EnvironmentId.make("environment-mcp-orchestrator-detail");
30const projectId = ProjectId.make("project-mcp-orchestrator-detail");37const projectId = ProjectId.make("project-mcp-orchestrator-detail");
31const parentThreadId = ThreadId.make("thread-mcp-orchestrator-parent");38const 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;
129136
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;
195202
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;
318325
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;
417424
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";
31import * as ProviderAdapterRegistry from "../orchestration-v2/ProviderAdapterRegistry.ts";31import * as ProviderAdapterRegistry from "../orchestration-v2/ProviderAdapterRegistry.ts";
32import * as ThreadLaunchService from "../orchestration-v2/ThreadLaunchService.ts";
33import * as GitVcsDriver from "../vcs/GitVcsDriver.ts";
32import * as ThreadManagementService from "../orchestration-v2/ThreadManagementService.ts";34import * as ThreadManagementService from "../orchestration-v2/ThreadManagementService.ts";
33import * as ProviderRegistry from "../provider/ProviderRegistry.ts";35import * as ProviderRegistry from "../provider/ProviderRegistry.ts";
34import { buildUnavailableProviderSnapshot } from "../provider/unavailableProviderSnapshot.ts";36import { 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
41import * as OrchestratorMcpService from "./OrchestratorMcpService.ts";43import * as OrchestratorMcpService from "./OrchestratorMcpService.ts";
42import type * as ProviderAdapter from "@t3tools/provider-core/server/ProviderAdapter";44import type * as ProviderAdapter from "@t3tools/provider-core/server/ProviderAdapter";
4345
46const layerOrchestratorService = OrchestratorMcpService.layer.pipe(
47 Layer.provide(Layer.mock(ThreadLaunchService.ThreadLaunchService)({})),
48 Layer.provide(Layer.mock(GitVcsDriver.GitVcsDriver)({})),
49);
50
44describe("OrchestratorMcpService", () => {51describe("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 );
176183
...@@ -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 );
273280
...@@ -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 );
349356
...@@ -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 );
429436
...@@ -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 );
515522
...@@ -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 );
598605
...@@ -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 });
741748
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 );
10271032
...@@ -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 );
10721077
...@@ -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 );
11741177
...@@ -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 );
12701273
...@@ -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 );
13291332
...@@ -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 );
13751378
...@@ -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 );
14801483
...@@ -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 );
16071608
...@@ -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 );
17121713
...@@ -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";
83import * as ThreadManagementService from "../orchestration-v2/ThreadManagementService.ts";84import * as ThreadManagementService from "../orchestration-v2/ThreadManagementService.ts";
85import * as ThreadLaunchService from "../orchestration-v2/ThreadLaunchService.ts";
86import * as GitVcsDriver from "../vcs/GitVcsDriver.ts";
84import { isSnoozed } from "../orchestration-v2/ThreadSettlementService.ts";87import { isSnoozed } from "../orchestration-v2/ThreadSettlementService.ts";
85import * as ProjectService from "../project/ProjectService.ts";88import * as ProjectService from "../project/ProjectService.ts";
86import * as ProviderRegistry from "../provider/ProviderRegistry.ts";89import * 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;
908913
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 command2016 // ThreadManagementService.dispatch is the chokepoint for this command
1997 // type's own enablement check, using the same command id and its2017 // 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; wait2034 // Async delegations wake the parent on every child terminal; wait
2014 // delegations deliver through the blocking tool call, so a wake is2035 // 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 }
20392076
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.Crypto2698 | Crypto.Crypto
2662 | ThreadManagementService.ThreadManagementService2699 | ThreadManagementService.ThreadManagementService
2700 | ThreadLaunchService.ThreadLaunchService
2701 | GitVcsDriver.GitVcsDriver
2663 | ProviderRegistry.ProviderRegistry2702 | ProviderRegistry.ProviderRegistry
2664 | ProviderAdapterRegistry.ProviderAdapterRegistryV22703 | ProviderAdapterRegistry.ProviderAdapterRegistryV2
2665 | ScheduledTaskService.ScheduledTaskService2704 | 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
57import * as CommandReceiptStore from "../orchestration-v2/CommandReceiptStore.ts";57import * as CommandReceiptStore from "../orchestration-v2/CommandReceiptStore.ts";
58import * as EventSink from "../orchestration-v2/EventSink.ts";58import * as EventSink from "../orchestration-v2/EventSink.ts";
59import * as Orchestrator from "../orchestration-v2/Orchestrator.ts";59import * as Orchestrator from "../orchestration-v2/Orchestrator.ts";
60import * as ThreadLaunchService from "../orchestration-v2/ThreadLaunchService.ts";
61import * as GitVcsDriver from "../vcs/GitVcsDriver.ts";
60import * as ThreadManagementService from "../orchestration-v2/ThreadManagementService.ts";62import * as ThreadManagementService from "../orchestration-v2/ThreadManagementService.ts";
61import * as ServerSettings from "../serverSettings.ts";63import * as ServerSettings from "../serverSettings.ts";
62import * as SqlitePersistence from "../persistence/Sqlite.ts";64import * 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";
80import { delegatedTaskRun, hasPendingChildRuns } from "./OrchestratorMcpService.ts";82import { delegatedTaskRun, hasPendingChildRuns } from "./OrchestratorMcpService.ts";
81import * as ProviderAdapter from "@t3tools/provider-core/server/ProviderAdapter";83import * as ProviderAdapter from "@t3tools/provider-core/server/ProviderAdapter";
8284
85const 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 payload90// 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`.
85const declaredFailure = (result: McpSchema.CallToolResult) => {92const 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";
27import * as Orchestrator from "../orchestration-v2/Orchestrator.ts";27import * as Orchestrator from "../orchestration-v2/Orchestrator.ts";
28import * as ProviderAdapter from "@t3tools/provider-core/server/ProviderAdapter";28import * as ProviderAdapter from "@t3tools/provider-core/server/ProviderAdapter";
29import * as ProviderAdapterRegistry from "../orchestration-v2/ProviderAdapterRegistry.ts";29import * as ProviderAdapterRegistry from "../orchestration-v2/ProviderAdapterRegistry.ts";
30import * as ThreadLaunchService from "../orchestration-v2/ThreadLaunchService.ts";
31import * as GitVcsDriver from "../vcs/GitVcsDriver.ts";
30import * as ThreadManagementService from "../orchestration-v2/ThreadManagementService.ts";32import * as ThreadManagementService from "../orchestration-v2/ThreadManagementService.ts";
31import * as ServerSettings from "../serverSettings.ts";33import * as ServerSettings from "../serverSettings.ts";
32import * as ProviderReplayHarness from "../orchestration-v2/testkit/ProviderReplayHarness.ts";34import * 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";
39import type { McpInvocationScope } from "./McpInvocationContext.ts";41import type { McpInvocationScope } from "./McpInvocationContext.ts";
40import * as OrchestratorMcpService from "./OrchestratorMcpService.ts";42import * as OrchestratorMcpService from "./OrchestratorMcpService.ts";
4143
44const layerOrchestratorService = OrchestratorMcpService.layer.pipe(
45 Layer.provide(Layer.mock(ThreadLaunchService.ThreadLaunchService)({})),
46 Layer.provide(Layer.mock(GitVcsDriver.GitVcsDriver)({})),
47);
48
42const driver = ProviderDriverKind.make("claudeAgent");49const driver = ProviderDriverKind.make("claudeAgent");
43const instanceId = ProviderInstanceId.make("claudeAgent");50const instanceId = ProviderInstanceId.make("claudeAgent");
44const modelSelection = { instanceId, model: "claude-test" };51const 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 {
35import * as ServerSecretStore from "../../auth/ServerSecretStore.ts";35import * as ServerSecretStore from "../../auth/ServerSecretStore.ts";
36import * as ServerConfig from "../../config.ts";36import * as ServerConfig from "../../config.ts";
37import * as ProviderAdapterRegistry from "../../orchestration-v2/ProviderAdapterRegistry.ts";37import * as ProviderAdapterRegistry from "../../orchestration-v2/ProviderAdapterRegistry.ts";
38import * as ThreadLaunchService from "../../orchestration-v2/ThreadLaunchService.ts";
39import * as GitVcsDriver from "../../vcs/GitVcsDriver.ts";
38import * as ThreadManagement from "../../orchestration-v2/ThreadManagementService.ts";40import * as ThreadManagement from "../../orchestration-v2/ThreadManagementService.ts";
39import * as ThreadSearch from "../../orchestration-v2/ThreadSearch.ts";41import * as ThreadSearch from "../../orchestration-v2/ThreadSearch.ts";
40import * as PreviewBrowser from "../../preview/PreviewBrowser.ts";42import * 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";
59import { WorktreeToolkit } from "./worktree/tools.ts";61import { WorktreeToolkit } from "./worktree/tools.ts";
60import { DeviceToolkit } from "./device/tools.ts";62import { DeviceToolkit } from "./device/tools.ts";
6163
64const 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 payload69// 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`.
64const declaredFailure = (result: McpSchema.CallToolResult) => {71const 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", {
6565
66export const DelegateTaskTool = Tool.make("delegate_task", {66export 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);
67896793
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});
645645
646it.effect("holds accepted queued work without cancelling its execution state after restart", () => {646it.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});
729753
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";
56import * as IdAllocator from "@t3tools/provider-core/server/IdAllocator";57import * as IdAllocator from "@t3tools/provider-core/server/IdAllocator";
57import * as ProviderAdapterRegistry from "./ProviderAdapterRegistry.ts";58import * as ProviderAdapterRegistry from "./ProviderAdapterRegistry.ts";
58import * as ThreadLaunch from "./ThreadLaunchService.ts";59import * as ThreadLaunch from "./ThreadLaunchService.ts";
60import * as OrchestratorMcpService from "../mcp/OrchestratorMcpService.ts";
61import * as GitVcsDriver from "../vcs/GitVcsDriver.ts";
59import * as ThreadManagement from "./ThreadManagementService.ts";62import * as ThreadManagement from "./ThreadManagementService.ts";
60import * as ThreadTitleRegeneration from "./ThreadTitleRegenerationService.ts";63import * as ThreadTitleRegeneration from "./ThreadTitleRegenerationService.ts";
61import * as ProviderReplayHarness from "./testkit/ProviderReplayHarness.ts";64import * 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"];
98101
99interface HarnessOptions {102interface 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 {
112117
113function makeHarness(options: HarnessOptions = {}) {118function 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}
254262
...@@ -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
2718it.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";
28import * as Ref from "effect/Ref";28import * as Ref from "effect/Ref";
29import * as Schema from "effect/Schema";29import * as Schema from "effect/Schema";
30import * as Scope from "effect/Scope";30import * as Scope from "effect/Scope";
31import * as Stream from "effect/Stream";
31import {32import {
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};
111112
112export interface ThreadLaunchRetryInput {113export 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 chokepoint186 // 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.OrchestratorV2Error188 | Orchestrator.OrchestratorV2Error
188 | ThreadManagement.ThreadManagementProviderInstanceDisabledError189 | ThreadManagement.ThreadManagementProviderInstanceDisabledError
189 >;190 >;
191 readonly prepareRun: (input: ThreadLaunchPreparationInput) => Effect.Effect<void>;
190 }192 }
191>()("t3/orchestration-v2/ThreadLaunchService") {}193>()("t3/orchestration-v2/ThreadLaunchService") {}
192194
...@@ -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 worktree651 if (cancelled && setupTerminalId)
641 // the thread recorded, so a retry reuses it, and removes one it never652 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* terminals656 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 failed661 // The thread forgets the worktree only once it is gone; a failed
650 // removal leaves the directory for the user to clean up rather than662 // 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 );
9811019
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 });
10101050
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 };
10521092
1053 return ThreadLaunchService.of({ launch, checkWorktreeBase, retryPreparation });1093 return ThreadLaunchService.of({ launch, checkWorktreeBase, retryPreparation, prepareRun });
1054});1094});
10551095
1056export const layer = Layer.effect(ThreadLaunchService, make);1096export 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 live3114 // 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
22const decodeThreadWaitInput = Schema.decodeUnknownSync(OrchestratorMcpThreadWaitInput);22const decodeThreadWaitInput = Schema.decodeUnknownSync(OrchestratorMcpThreadWaitInput);
2323
24describe("orchestrator MCP contracts", () => {24describe("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 = `
66
7The \`t3-code\` MCP server provides app-owned orchestration. Treat these concepts distinctly:7The \`t3-code\` MCP server provides app-owned orchestration. Treat these concepts distinctly:
88
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.