Repository navigation
Expand file tree
/
Copy pathRemoteDelegation.ts
More file actions
830 lines (802 loc) · 33 KB
/
Copy pathRemoteDelegation.ts
File metadata and controls
830 lines (802 loc) · 33 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
import {
CommandId,
type EnvironmentId,
NodeId,
OrchestratorMcpFailure,
type OrchestratorMcpDelegateTaskInput,
type OrchestratorMcpTaskCancelResult,
type OrchestratorMcpThreadTimelineItem,
type OrchestrationV2RemoteTaskChild,
type OrchestrationV2Subagent,
type ProjectId,
type ProviderInstanceId,
ProviderDriverKind,
type RunId,
type ThreadId,
} from "@t3tools/contracts";
import * as IdAllocator from "@t3tools/provider-core/server/IdAllocator";
import * as KeyedLock from "@t3tools/shared/KeyedLock";
import * as Context from "effect/Context";
import * as Crypto from "effect/Crypto";
import * as Duration from "effect/Duration";
import * as Effect from "effect/Effect";
import * as Layer from "effect/Layer";
import * as Option from "effect/Option";
import * as Schedule from "effect/Schedule";
import * as Scope from "effect/Scope";
import * as Stream from "effect/Stream";
import type * as Tool from "effect/ai/Tool";
import * as McpInvocationContext from "../mcp/McpInvocationContext.ts";
import { assertNotLinked } from "../mcp/linkOrigin.ts";
import { requestIdentity } from "../mcp/requestIdentity.ts";
import { readCaller } from "../mcp/threadAccess.ts";
import { ProjectToolkit } from "../mcp/toolkits/project/tools.ts";
import {
resolveInteractionMode,
resolveRuntimeMode,
taskPrompt,
} from "../mcp/delegatedTaskModes.ts";
import * as ProjectionStore from "../orchestration-v2/ProjectionStore.ts";
import * as ThreadManagement from "../orchestration-v2/ThreadManagementService.ts";
import * as ServerEnvironment from "../environment/ServerEnvironment.ts";
import * as ProjectService from "../project/ProjectService.ts";
import * as RepositoryIdentityResolver from "../project/RepositoryIdentityResolver.ts";
import { forkParked } from "../serverActivation.ts";
import * as PeerForwarding from "./PeerForwarding.ts";
import * as PeerLinks from "./PeerLinks.ts";
/** How long one follow of a remote task waits on its thread there before checking again. */
const FOLLOW_WAIT_MS = 10 * 60 * 1_000;
/** A linked environment that stops answering is retried, backing off to this. */
const FOLLOW_MAX_BACKOFF = Duration.minutes(5);
/** How a follower retries; tests step it by hand. */
export const FollowRetrySchedule = Context.Reference<Schedule.Schedule<unknown>>(
"t3/peer/RemoteDelegation/FollowRetrySchedule",
{
defaultValue: () =>
Schedule.exponential(Duration.seconds(2)).pipe(
Schedule.modifyDelay(({ duration }) =>
Effect.succeed(Duration.min(duration, FOLLOW_MAX_BACKOFF)),
),
),
},
);
type ThreadScope = McpInvocationContext.McpThreadInvocationScope;
/**
* `delegate_task` to a linked environment. The task's child is an ordinary
* thread there, launched through the link; the parent here records the task
* like a local one, without a child thread, and a follower completes it when
* the thread there ends, so the parent wakes as it would for a local child.
*/
export class RemoteDelegation extends Context.Service<
RemoteDelegation,
{
readonly delegate: (
scope: ThreadScope,
input: OrchestratorMcpDelegateTaskInput & {
readonly target: NonNullable<OrchestratorMcpDelegateTaskInput["target"]> & {
readonly environmentId: EnvironmentId;
};
},
) => Effect.Effect<{ readonly taskId: NodeId }, OrchestratorMcpFailure>;
/**
* Stops the task's thread there, then completes it here as cancelled. A
* task that already has its result keeps it; its thread there is still
* stopped, as cancelling a local task stops follow-up work on its child.
*/
readonly cancel: (
scope: ThreadScope,
task: OrchestrationV2Subagent,
input: {
readonly reason?: string | undefined;
readonly clientRequestId?: string | undefined;
},
) => Effect.Effect<OrchestratorMcpTaskCancelResult, OrchestratorMcpFailure>;
/**
* Follows every open remote task until it ends, including after a
* restart, and a finished one again when a Stop here owes it a stop there.
*/
readonly start: () => Effect.Effect<void, never, Scope.Scope>;
}
>()("t3/peer/RemoteDelegation") {}
/**
* Refusals that following again cannot change: the link was revoked, expired
* or forgotten, or the thread or run there is gone. Anything else (the other
* machine asleep, a dropped route) is worth retrying.
*/
/** The other side refused because the thread there runs above this caller's modes. */
const isModeRefusal = (error: OrchestratorMcpFailure) =>
error.code === "runtime_mode_escalation_denied" ||
error.code === "interaction_mode_escalation_denied";
const isFinal = (error: OrchestratorMcpFailure) =>
error.code === "capability_denied" ||
error.code === "thread_not_found" ||
error.code === "run_not_found";
const make = Effect.gen(function* () {
const followRetry = yield* FollowRetrySchedule;
const forwarding = yield* PeerForwarding.PeerForwarding;
const links = yield* PeerLinks.PeerLinks;
const threads = yield* ThreadManagement.ThreadManagementService;
const projections = yield* ProjectionStore.ProjectionStoreV2;
const projects = yield* ProjectService.ProjectService;
const repositoryIdentities = yield* RepositoryIdentityResolver.RepositoryIdentityResolver;
const crypto = yield* Crypto.Crypto;
const launchLocks = yield* KeyedLock.make<CommandId>();
const hereId = yield* ServerEnvironment.ServerEnvironment.pipe(
Effect.flatMap((environment) => environment.getEnvironmentId),
);
// Followers live as long as this service, so shutting down stops them.
const followers = yield* Scope.Scope;
// One follower runs per open task and holds what it read across a wait of
// up to FOLLOW_WAIT_MS, so it reads its own task, never the parent's whole
// task list: a parent with thousands of tasks made every restart decode
// that list once per open task at the same time and ran out of heap.
const readTask = (parentThreadId: ThreadId, taskId: NodeId) =>
threads
.getThreadRecords(parentThreadId, ["subagents"], { subagentIds: [taskId] })
.pipe(Effect.map(({ thread, subagents }) => ({ thread, task: subagents[0] })));
/** The peer's project with this thread's repository, or the one the caller named. */
const resolveRemoteProject = Effect.fn("RemoteDelegation.resolveRemoteProject")(function* (
scope: ThreadScope,
environmentId: EnvironmentId,
parentProjectId: ProjectId,
requested: ProjectId | undefined,
) {
if (requested !== undefined) return requested;
const here = yield* projects.getById(parentProjectId).pipe(
Effect.map(Option.getOrUndefined),
Effect.orElseSucceed(() => undefined),
);
// The project's own identity is a one-minute cache that reads blank when
// cold, so resolve it here, as the other side's t3_project_list does.
const key =
here === undefined
? undefined
: (yield* repositoryIdentities.resolve(here.workspaceRoot))?.canonicalKey;
if (key === undefined) {
return yield* new OrchestratorMcpFailure({
code: "target_required",
message: "Pass target.projectId: this thread's project has no repository to match there.",
});
}
const matches: Array<{ readonly id: ProjectId; readonly title: string }> = [];
let cursor: number | null = 0;
while (cursor !== null) {
const listed: Tool.Success<typeof ProjectToolkit.tools.t3_project_list> =
yield* forwarding.call(scope, ProjectToolkit.tools.t3_project_list, environmentId, {
cursor,
limit: 100,
});
matches.push(
...listed.projects.filter((project) => project.repositoryIdentity?.canonicalKey === key),
);
cursor = listed.nextCursor;
}
if (matches.length === 1) return matches[0]!.id;
return yield* new OrchestratorMcpFailure({
code: "invalid_request",
message:
matches.length === 0
? `No project there has this repository (${key}). Pass target.projectId; t3_project_list with this environmentId lists them. To bring the repository there, use t3_project_clone and then t3_project_create with this environmentId.`
: `Several projects there have this repository: ${matches.map((project) => `${project.title} (${project.id})`).join(", ")}. Pass target.projectId.`,
});
});
const launchAndRecord = Effect.fn("RemoteDelegation.launchAndRecord")(function* (
scope: ThreadScope,
input: Parameters<RemoteDelegation["Service"]["delegate"]>[1],
identity: string,
commandId: CommandId,
) {
const caller = yield* readCaller().pipe(
Effect.provideService(McpInvocationContext.McpInvocationContext, scope),
Effect.provideService(ThreadManagement.ThreadManagementService, threads),
);
yield* assertNotLinked(caller, "use this environment's links");
const parent = yield* threads.getThreadRecords(scope.thread.threadId, ["runs"]).pipe(
Effect.mapError(
() =>
new OrchestratorMcpFailure({
code: "thread_not_found",
message: "The calling thread was not found.",
}),
),
);
const parentRun = parent.runs
.filter(ThreadManagement.isActiveRun)
.toSorted((left, right) => right.ordinal - left.ordinal)[0];
if (
parentRun === undefined ||
parentRun.rootNodeId === null ||
parentRun.providerInstanceId !== scope.thread.providerInstanceId
) {
return yield* new OrchestratorMcpFailure({
code: "parent_not_active",
message: "Delegated tasks require an active run owned by this MCP provider session.",
});
}
const parentNodeId = parentRun.rootNodeId;
const { target } = input;
// A worktree there would branch from a commit this checkout has and the
// other may not, so a remote task runs in that project's checkout.
if (input.isolation === "worktree") {
return yield* new OrchestratorMcpFailure({
code: "invalid_request",
message:
'A task in a linked environment runs in that project\'s checkout; isolation "worktree" is not supported there. Omit isolation, or use t3_thread_launch with environmentId and a worktree workspaceStrategy.',
});
}
const runtimeMode =
input.runtimeMode === undefined || input.runtimeMode === "inherit"
? undefined
: yield* resolveRuntimeMode(parent.thread.runtimeMode, input.runtimeMode);
const interactionMode =
input.interactionMode === undefined || input.interactionMode === "inherit"
? undefined
: yield* resolveInteractionMode(parent.thread.interactionMode, input.interactionMode);
const recordedTaskId = IdAllocator.derive.delegatedTaskNode({ commandId });
const recordedTask = yield* readTask(scope.thread.threadId, recordedTaskId).pipe(
Effect.map(({ task }) => task),
Effect.mapError(
() =>
new OrchestratorMcpFailure({
code: "orchestration_error",
message: "Unable to read the recorded delegated task.",
}),
),
);
if (recordedTask !== undefined) {
if (recordedTask.remoteChild?.environmentId !== target.environmentId) {
return yield* new OrchestratorMcpFailure({
code: "invalid_request",
message:
"This clientRequestId already belongs to a task in another environment. Retry with the original arguments, or use a new clientRequestId for new work.",
});
}
// The accepted task owns its project and model even if catalogs/defaults changed or the peer is offline.
yield* followOne(scope.thread.threadId, recordedTask.id).pipe(Effect.forkIn(followers));
return { taskId: recordedTask.id };
}
// The provider and model inherit as they do for a task here. Another
// provider there has no model to inherit, so the caller names one.
const inherited = parent.thread.modelSelection;
const instanceId = target.providerInstanceId ?? inherited.instanceId;
const model =
target.model ?? (instanceId === inherited.instanceId ? inherited.model : undefined);
if (model === undefined) {
return yield* new OrchestratorMcpFailure({
code: "target_required",
message: "Pass target.model from orchestrator_capabilities with this environmentId.",
});
}
const options =
target.options ??
(instanceId === inherited.instanceId && model === inherited.model
? inherited.options
: undefined);
const projectId = yield* resolveRemoteProject(
scope,
target.environmentId,
parent.thread.projectId,
target.projectId,
);
const task = taskPrompt(input);
const launched = yield* forwarding.call(
scope,
ProjectToolkit.tools.t3_thread_launch,
target.environmentId,
{
projectId,
title: input.title ?? parent.thread.title,
modelSelection: { instanceId, model, ...(options === undefined ? {} : { options }) },
...(runtimeMode === undefined ? {} : { runtimeMode }),
...(interactionMode === undefined ? {} : { interactionMode }),
message: task,
clientRequestId: `delegate:${identity}`,
// The thread there shows it is this thread's subagent, with a way back.
delegatedFrom: {
environmentId: hereId,
threadId: parent.thread.id,
title: parent.thread.title,
},
},
);
const link = yield* links.get(target.environmentId).pipe(
Effect.map(Option.getOrUndefined),
Effect.orElseSucceed(() => undefined),
);
const remoteChild: OrchestrationV2RemoteTaskChild = {
environmentId: target.environmentId,
threadId: launched.threadId,
label: link?.label ?? target.environmentId,
...(launched.runId === null ? {} : { runId: launched.runId }),
};
const recorded = yield* threads
.dispatch({
type: "delegated_task.remote.request",
commandId,
parentThreadId: scope.thread.threadId,
parentRunId: parentRun.id,
parentNodeId,
task,
...(input.title === undefined ? {} : { title: input.title }),
// The driver runs there; this side only labels the task with it.
driver: ProviderDriverKind.make("remote"),
modelSelection: launched.modelSelection,
remoteChild,
completionWake: input.mode === "wait" ? "settled_only" : "always",
})
.pipe(
// The cause goes to the log; the caller learns only that recording failed.
Effect.tapError((cause) =>
Effect.logWarning("remote-delegation.record-failed", {
parentThreadId: scope.thread.threadId,
cause,
}),
),
Effect.mapError(
() =>
new OrchestratorMcpFailure({
code: "orchestration_error",
message: "Unable to record the delegated task.",
}),
),
// Nothing here follows a thread the task was never recorded for, so stop it there.
// A crash between the launch and this record still leaves it running there
// until a retry with the same clientRequestId records it.
Effect.tapError(() =>
stopThere(followScope(scope.thread, remoteChild), remoteChild, "stop").pipe(
Effect.ignoreCause({ log: true }),
),
),
);
const taskEvent = recorded.storedEvents.find(
(stored) => stored.event.type === "subagent.updated",
);
const taskId =
taskEvent?.event.type === "subagent.updated"
? taskEvent.event.payload.id
: // A replayed request recorded the task the first time.
yield* threads.getThreadRecords(scope.thread.threadId, ["subagents"]).pipe(
Effect.map(
({ subagents }) =>
subagents.find((task) => task.remoteChild?.threadId === launched.threadId)?.id,
),
Effect.orElseSucceed(() => undefined),
);
if (taskId === undefined) {
return yield* new OrchestratorMcpFailure({
code: "orchestration_error",
message: "The delegated task was not recorded.",
});
}
yield* followOne(scope.thread.threadId, taskId).pipe(Effect.forkIn(followers));
return { taskId };
});
const delegate: RemoteDelegation["Service"]["delegate"] = Effect.fn("RemoteDelegation.delegate")(
function* (scope, input) {
const key = input.clientRequestId ?? (yield* crypto.randomUUIDv4().pipe(Effect.orDie));
const identity = yield* requestIdentity(scope, "remote-delegate", key).pipe(
Effect.provideService(Crypto.Crypto, crypto),
);
const commandId = CommandId.make(`command:mcp:remote-delegate:${identity}`);
return yield* launchLocks.withLock(
commandId,
launchAndRecord(scope, input, identity, commandId),
);
},
);
/**
* Each report has a fresh command ID so a failed attempt cannot block its retry.
* Already-settled tasks keep their result; stoppedThere can still acknowledge an owed stop.
*/
const complete = Effect.fn("RemoteDelegation.complete")(function* (
parentThreadId: ThreadId,
taskId: NodeId,
status: "completed" | "failed" | "cancelled" | "interrupted",
result: string,
stoppedThere: "ran-out" | "stopped" | undefined,
stopCount?: number,
) {
const attempt = yield* crypto.randomUUIDv4().pipe(Effect.orDie);
yield* threads
.dispatch({
type: "delegated_task.remote.complete",
commandId: CommandId.make(`command:remote-task-complete:${taskId}:${attempt}`),
parentThreadId,
taskId,
status,
result,
...(stoppedThere === undefined ? {} : { stoppedThere }),
...(stopCount === undefined ? {} : { stopCount }),
})
.pipe(
Effect.catch((error) =>
readTask(parentThreadId, taskId).pipe(
Effect.flatMap(({ task }) => (task?.result != null ? Effect.void : Effect.fail(error))),
),
),
);
});
/**
* How this environment reaches a task's thread there: as the parent
* thread, under one namespace per parent, so a stop sent again after a
* restart or a dropped route is recorded there once.
*/
const followScope = (
parent: { readonly threadId: ThreadId; readonly providerInstanceId: ProviderInstanceId },
remote: OrchestrationV2RemoteTaskChild,
): ThreadScope => ({
environmentId: hereId,
requestNamespace: `remote-task:${parent.threadId}`,
thread: {
threadId: parent.threadId,
providerSessionId: `remote-task:${remote.threadId}`,
providerInstanceId: parent.providerInstanceId,
},
client: undefined,
capabilities: new Set(["orchestration"]),
issuedAt: 0,
});
/**
* A remote Stop also cancels queued messages, watches, and delegated tasks on that thread.
* Each key identifies one stop, so retries do not stop later work again.
*/
const stopThere = Effect.fn("RemoteDelegation.stopThere")(function* (
scope: ThreadScope,
remote: OrchestrationV2RemoteTaskChild,
key: string,
reason?: string,
) {
const identity = yield* requestIdentity(scope, `remote-stop:${remote.threadId}`, key).pipe(
Effect.provideService(Crypto.Crypto, crypto),
);
yield* forwarding
.interruptThread(scope, remote.environmentId, {
threadId: remote.threadId,
stop: true,
...(reason === undefined ? {} : { reason }),
clientRequestId: `stop:${identity}`,
})
.pipe(Effect.asVoid);
});
/**
* The last reply `runId` gave on the task's thread there, which is its
* result, read whole however long it is. A failed run prefers its error
* from the activity view over an earlier reply, without taking another run's.
*/
const lastReply = Effect.fn("RemoteDelegation.lastReply")(function* (
scope: ThreadScope,
remote: OrchestrationV2RemoteTaskChild,
runId: RunId | null,
failed = false,
) {
let reply: OrchestratorMcpThreadTimelineItem | undefined;
let error: OrchestratorMcpThreadTimelineItem | undefined;
let afterPosition: number | undefined;
while (true) {
const read = yield* forwarding.readThread(scope, remote.environmentId, {
threadId: remote.threadId,
view: failed ? "activity" : "messages",
limit: 100,
...(afterPosition === undefined ? {} : { afterPosition }),
});
reply =
read.items.findLast(
(item) => item.type === "assistant_message" && (runId === null || item.runId === runId),
) ?? reply;
if (failed) {
error =
read.items.findLast(
(item) => item.type === "error" && (runId === null || item.runId === runId),
) ?? error;
}
if (!read.hasMore || read.nextPosition === null) break;
afterPosition = read.nextPosition;
}
reply = error ?? reply;
if (reply === undefined) return undefined;
let text = reply.text ?? "";
let textOffset = reply.textTruncated ? (reply.nextTextOffset ?? null) : null;
while (textOffset !== null) {
const rest = yield* forwarding.readThread(scope, remote.environmentId, {
threadId: remote.threadId,
itemId: reply.itemId,
textOffset,
maxCharsPerItem: 50_000,
});
const part = rest.items[0];
if (part === undefined) break;
text += part.text ?? "";
textOffset = part.textTruncated ? (part.nextTextOffset ?? null) : null;
}
return text;
});
/**
* Why following a task can never succeed, if that is what `error` means.
* The far side's message already says what was refused and what to do; a
* link forgotten here only reads as a bad request, so it is named.
*/
const finalReason = Effect.fn("RemoteDelegation.finalReason")(
function* (parentThreadId: ThreadId, taskId: NodeId, error: OrchestratorMcpFailure) {
if (isFinal(error)) return error.message;
if (error.code !== "invalid_request") return undefined;
const { task } = yield* readTask(parentThreadId, taskId);
const environmentId = task?.remoteChild?.environmentId;
if (environmentId === undefined) return error.message;
const link = yield* links.get(environmentId);
return Option.isSome(link) ? undefined : error.message;
},
Effect.orElseSucceed(() => undefined),
);
/** A task's thread there that ended without a reply. */
const noReply = "(The thread in the linked environment ended without a reply.)";
/**
* Missing threads, runs, or usable links terminate the task; transient failures retry with backoff.
* A task ended locally still owes a remote stop, and recording completion must also be retried.
*/
const follow = (parentThreadId: ThreadId, taskId: NodeId): Effect.Effect<void> =>
followOnce(parentThreadId, taskId).pipe(
// `false` means it is still running there: follow again straight away.
Effect.repeat({ while: (done) => !done }),
Effect.catchTags({
OrchestratorMcpFailure: Effect.fn("RemoteDelegation.followFailure")(function* (error) {
const reason = yield* finalReason(parentThreadId, taskId, error);
if (reason === undefined) return yield* error;
// Gone there, or no link to reach it by: nothing left to stop there.
const { task } = yield* readTask(parentThreadId, taskId);
const stopCount = task?.remoteChild?.stopCount;
yield* complete(parentThreadId, taskId, "failed", reason, "stopped", stopCount);
}),
}),
Effect.retry(followRetry),
Effect.ignoreCause({ log: true }),
);
/**
* One wait on the task's thread there; true once the task here is done.
* A task completed here first (cancelled, or its parent stopped) is
* stopped there, and done once that stop is recorded here.
*/
const followOnce = Effect.fn("RemoteDelegation.followOnce")(function* (
parentThreadId: ThreadId,
taskId: NodeId,
) {
const sequence = yield* threads.getThreadEventSequence(parentThreadId);
const { thread, task } = yield* readTask(parentThreadId, taskId);
if (task?.remoteChild === undefined) return true;
const remote = task.remoteChild;
const scope = followScope(
{ threadId: parentThreadId, providerInstanceId: thread.providerInstanceId },
remote,
);
// Ended here, maybe while this follower backed off from an unreachable
// machine or before a restart: it still has to be stopped there. So
// does one its parent here was stopped after, for what followed there.
if (task.result !== null) {
if (
remote.stoppedThere === false ||
((task.status === "cancelled" || task.status === "interrupted") &&
remote.stoppedThere !== true)
) {
const key = remote.stopCount === undefined ? "stop" : `stop-${remote.stopCount}`;
yield* stopThere(scope, remote, key).pipe(
// Its thread there now runs above what this side may touch, say
// because the parent here was lowered since; no retry changes that.
Effect.catchIf(isModeRefusal, (error) =>
Effect.logWarning("A linked environment refused to stop a delegated task", {
taskId,
reason: error.message,
}),
),
);
yield* complete(
parentThreadId,
taskId,
terminalStatus(task.status),
task.result,
"stopped",
remote.stopCount,
);
return yield* settled(parentThreadId, taskId);
}
return true;
}
const endedHere = threads
.streamStoredEventsFrom({
threadId: parentThreadId,
afterSequence: sequence,
eventType: "subagent.updated",
})
.pipe(
Stream.filter(
(stored) =>
stored.event.type === "subagent.updated" &&
stored.event.payload.id === taskId &&
stored.event.payload.result !== null,
),
Stream.runHead,
Effect.flatMap((ended) =>
Option.isSome(ended) ? Effect.succeed({ endedHere: true } as const) : Effect.never,
),
);
const waited = yield* forwarding
.waitForThread(scope, remote.environmentId, {
threadId: remote.threadId,
...(remote.runId === undefined ? {} : { runId: remote.runId }),
timeoutMs: FOLLOW_WAIT_MS,
// A thread there whose run ended may still be waiting on its own
// tasks to wake it for the answer, which a task here waits for too.
// An older peer ignores this and reports the run alone.
untilDelegatedWorkSettled: true,
})
.pipe(Effect.raceFirst(endedHere));
// The next look reads the result and stops it there.
if ("endedHere" in waited) return false;
if (waited.timedOut) return false;
// A task recorded without its run waits on the thread's latest; a
// thread there with none has nothing left to wait for.
if (waited.runId === null) {
const reply = yield* lastReply(scope, remote, null);
yield* complete(
parentThreadId,
taskId,
reply === undefined ? "failed" : "completed",
reply ?? "The thread in the linked environment has no run to follow.",
"ran-out",
);
return yield* settled(parentThreadId, taskId);
}
if (!isTerminal(waited.status)) return false;
const resultRun = waited.resultRun ?? {
runId: remote.runId ?? waited.runId,
status: waited.status,
};
const result = yield* lastReply(scope, remote, resultRun.runId, resultRun.status === "failed");
yield* complete(
parentThreadId,
taskId,
terminalStatus(resultRun.status),
result ?? noReply,
"ran-out",
);
return yield* settled(parentThreadId, taskId);
});
/**
* Whether the task is done here after the follower reported its run ran
* out there. A cancel or Stop that landed while the reply was read keeps
* its result, and still owes a stop there for what may follow the run;
* the next look sends it.
*/
const settled = (parentThreadId: ThreadId, taskId: NodeId) =>
readTask(parentThreadId, taskId).pipe(
Effect.map(({ task }) => task?.remoteChild?.stoppedThere === true),
);
const following = new Map<NodeId, { again: boolean }>();
/**
* `follow`, at most once per task. Asked again while it runs, say by a
* Stop here on a task that already has its result, it looks once more
* before it ends, so that stop is not missed.
*/
const followOne = (parentThreadId: ThreadId, taskId: NodeId) =>
Effect.suspend(() => {
const running = following.get(taskId);
if (running !== undefined) {
running.again = true;
return Effect.void;
}
const state = { again: false };
following.set(taskId, state);
return follow(parentThreadId, taskId).pipe(
// Checked and released in one step, so a request between them is never lost.
Effect.repeat({
while: () => {
if (state.again) {
state.again = false;
return true;
}
following.delete(taskId);
return false;
},
}),
Effect.ensuring(
Effect.sync(() => {
if (following.get(taskId) === state) following.delete(taskId);
}),
),
);
});
const cancel: RemoteDelegation["Service"]["cancel"] = Effect.fn("RemoteDelegation.cancel")(
function* (scope, task, input) {
const remote = task.remoteChild;
if (remote === undefined) {
return yield* new OrchestratorMcpFailure({
code: "task_not_found",
message: `Delegated task ${task.id} is not remote.`,
});
}
const { reason } = input;
const there = followScope(scope.thread, remote);
// A later cancel stops what started there since; a retry of this one, nothing new.
const requestId = input.clientRequestId ?? (yield* crypto.randomUUIDv4().pipe(Effect.orDie));
const key = `cancel:${yield* requestIdentity(scope, "remote-cancel", requestId).pipe(
Effect.provideService(Crypto.Crypto, crypto),
)}`;
if (task.result !== null) {
// A replayed cancel cannot acknowledge a later parent Stop; its follower owns that receipt.
yield* stopThere(there, remote, key, reason).pipe(Effect.ignoreCause({ log: true }));
return {
taskId: task.id,
status:
task.status === "completed" || task.status === "failed" || task.status === "interrupted"
? task.status
: "cancelled",
} satisfies OrchestratorMcpTaskCancelResult;
}
// A link that is gone or a machine that is down still cancels the task
// here; its follower keeps trying to stop it there, across restarts.
const unstopped = yield* stopThere(there, remote, key, reason).pipe(
Effect.as(undefined),
Effect.catch((error) => Effect.succeed(error.message)),
);
const cancelled = reason === undefined ? "Cancelled." : `Cancelled: ${reason}`;
yield* complete(
task.threadId,
task.id,
"cancelled",
unstopped === undefined
? cancelled
: `${cancelled} It could not be stopped in ${remote.label} and may still run there: ${unstopped}`,
unstopped === undefined ? "stopped" : undefined,
remote.stopCount,
).pipe(
Effect.tapError((cause) =>
Effect.logWarning("remote-delegation.cancel-record-failed", { taskId: task.id, cause }),
),
Effect.mapError(
() =>
new OrchestratorMcpFailure({
code: "orchestration_error",
message: "Unable to record the cancellation.",
}),
),
);
return {
taskId: task.id,
status: "cancel_requested",
} satisfies OrchestratorMcpTaskCancelResult;
},
);
const start: RemoteDelegation["Service"]["start"] = () =>
forkParked(
Effect.gen(function* () {
// A finished task's follower has ended, so a Stop here that owes its
// thread there a stop starts it again. Started before the scan below;
// a Stop that slips between the two stays owed for the next start.
yield* Stream.runForEach(threads.streamDomainEvents, (event) =>
event.type === "subagent.updated" && event.payload.remoteChild?.stoppedThere === false
? followOne(event.threadId, event.payload.id).pipe(Effect.forkIn(followers))
: Effect.void,
).pipe(
Effect.ignoreCause({ log: true }),
Effect.forkIn(followers, { startImmediately: true }),
);
const open = yield* projections.getOpenRemoteDelegatedTasks;
yield* Effect.forEach(
open,
({ parentThreadId, taskId }) => followOne(parentThreadId, taskId),
{
concurrency: "unbounded",
discard: true,
},
);
}).pipe(Effect.ignoreCause({ log: true })),
);
return RemoteDelegation.of({ delegate, cancel, start });
});
const isTerminal = (status: string) =>
status === "completed" ||
status === "failed" ||
status === "cancelled" ||
status === "interrupted" ||
status === "rolled_back";
const terminalStatus = (status: string): "completed" | "failed" | "cancelled" | "interrupted" =>
status === "completed" || status === "failed" || status === "interrupted" ? status : "cancelled";
export const layer = Layer.effect(RemoteDelegation, make);