Skip to content
Source
Sign inSign up

ThreadLaunchService.ts

1,295 lines50.2 KB

1import * as WorktreeSetupTracker from "../project/WorktreeSetupTracker.ts";
2import * as ProjectCloneTracker from "../project/ProjectCloneTracker.ts";
3import * as TerminalManager from "../terminal/Manager.ts";
4import { resolveProjectSettings } from "@t3tools/shared/projectSettings";
5import {
6 CommandId,
7 type ChatAttachment,
8 type MessageId,
9 type ModelSelection,
10 type OrchestrationV2Actor,
11 type OrchestrationV2CreationSource,
12 type OrchestrationV2DelegatedFrom,
13 type OrchestrationV2LinkOrigin,
14 type OrchestrationV2ProviderThreadNativeMetadata,
15 type OrchestrationV2ThreadProjection,
16 type ProviderDriverKind,
17 type ProviderInteractionMode,
18 ProjectId,
19 type RunId,
20 type RuntimeMode,
21 type ScheduledTaskId,
22 ThreadId,
23} from "@t3tools/contracts";
24import * as Cause from "effect/Cause";
25import * as Context from "effect/Context";
26import * as DateTime from "effect/DateTime";
27import * as Effect from "effect/Effect";
28import * as Exit from "effect/Exit";
29import * as Fiber from "effect/Fiber";
30import * as Layer from "effect/Layer";
31import * as Option from "effect/Option";
32import * as Ref from "effect/Ref";
33import * as Schema from "effect/Schema";
34import * as Scope from "effect/Scope";
35import * as Stream from "effect/Stream";
36import {
37 buildTemporaryWorktreeBranchName,
38 flattenTemporaryWorktreeBranchName,
39 isTemporaryWorktreeBranch,
40 WORKTREE_BRANCH_PREFIX,
41} from "@t3tools/shared/git";
42​
43import * as GitWorkflow from "../git/GitWorkflowService.ts";
44import * as ProjectService from "../project/ProjectService.ts";
45import * as ProjectSetupScriptRunner from "../project/ProjectSetupScriptRunner.ts";
46import * as ManagedProjectFolders from "../project/ManagedProjectFolders.ts";
47import * as ProviderRegistry from "../provider/ProviderRegistry.ts";
48import * as ServerSettings from "../serverSettings.ts";
49import * as TextGeneration from "../textGeneration/TextGeneration.ts";
50import * as CommandReceiptStore from "./CommandReceiptStore.ts";
51import * as EventSink from "./EventSink.ts";
52import * as IdAllocator from "@t3tools/provider-core/server/IdAllocator";
53import type * as Orchestrator from "./Orchestrator.ts";
54import { makeProviderFailure } from "@t3tools/provider-core/server/failure";
55import { randomUuidV4 } from "@t3tools/provider-core/server/randomUuid";
56import { DispatchModeLimit } from "./DispatchModeLimit.ts";
57import * as ThreadManagement from "./ThreadManagementService.ts";
58​
59export type ThreadLaunchWorkspaceStrategy =
60 | { readonly type: "root"; readonly branch?: string | undefined }
61 | {
62 readonly type: "existing_worktree";
63 readonly worktreePath: string;
64 readonly branch?: string | undefined;
65 }
66 | {
67 readonly type: "worktree";
68 readonly baseRef: string;
69 readonly branch?: string | undefined;
70 readonly startFromOrigin?: boolean | undefined;
71 };
72​
73export interface ThreadLaunchInitialMessage {
74 readonly messageId?: MessageId;
75 readonly scheduledTaskId?: ScheduledTaskId;
76 readonly senderThreadId?: ThreadId;
77 readonly text: string;
78 readonly attachments: ReadonlyArray<ChatAttachment>;
79 readonly context?: import("@t3tools/contracts").OrchestrationMessageContext | undefined;
81​
82export interface ThreadLaunchInput {
83 readonly commandId: CommandId;
84 readonly threadId?: ThreadId;
85 readonly reuseExistingThread?: boolean;
86 readonly projectId: ProjectId;
87 readonly title: string;
88 readonly generateTitle?: boolean;
89 readonly modelSelection: ModelSelection;
90 readonly runtimeMode: RuntimeMode;
91 readonly interactionMode: ProviderInteractionMode;
92 readonly workspaceStrategy: ThreadLaunchWorkspaceStrategy;
93 readonly initialMessage?: ThreadLaunchInitialMessage;
94 readonly importedNativeThread?: {
95 readonly ref: {
96 readonly driver: ProviderDriverKind;
97 readonly nativeId: string;
98 readonly strength: "strong";
99 };
100 readonly metadata?: OrchestrationV2ProviderThreadNativeMetadata;
101 };
102 readonly createdBy: OrchestrationV2Actor;
103 readonly creationSource: OrchestrationV2CreationSource;
104 /** The linked environment this launch is for, if one asked for it. */
105 readonly linkOrigin?: OrchestrationV2LinkOrigin;
106 /** The parent there, when that environment's agent delegated this as a task. */
107 readonly delegatedFrom?: OrchestrationV2DelegatedFrom;
109​
110type PreparationInput = Pick<
111 ThreadLaunchInput,
112 "commandId" | "projectId" | "workspaceStrategy" | "initialMessage"
113> & {
114 readonly reusedWorktree?: { readonly baseRef: string };
115};
116​
117export interface ThreadLaunchPreparationInput {
118 readonly commandId: CommandId;
119 readonly threadId: ThreadId;
120 readonly runId: RunId;
122​
123export interface ThreadLaunchResult {
124 readonly threadId: ThreadId;
125 readonly projection: OrchestrationV2ThreadProjection;
126 /** The launch's last write (the initial message, else the thread claim) was accepted before this call. */
127 readonly resumed: boolean;
128 readonly sequence?: number;
130​
131export class ThreadLaunchError extends Schema.TaggedError<ThreadLaunchError>()(
132 "ThreadLaunchError",
133 {
134 operation: Schema.Literals([
135 "resolve-project",
136 "read-receipt",
137 "generate-metadata",
138 "provision-worktree",
139 "run-setup-script",
140 "create-thread",
141 "update-thread",
142 "dispatch-message",
143 "release-run",
144 "fail-run",
145 ]),
146 commandId: CommandId,
147 projectId: ProjectId,
148 threadId: Schema.optional(ThreadId),
149 cause: Schema.Defect(),
150 },
151) {
152 override get message(): string {
153 return `Thread launch ${this.commandId} failed during ${this.operation}.`;
154 }
156​
157export class ThreadLaunchBaseRefError extends Schema.TaggedError<ThreadLaunchBaseRefError>()(
158 "ThreadLaunchBaseRefError",
159 {
160 projectId: ProjectId,
161 projectTitle: Schema.String,
162 workspaceRoot: Schema.String,
163 baseRef: Schema.String,
164 },
165) {
166 override get message(): string {
167 return `Base ref "${this.baseRef}" does not resolve to a commit in project "${this.projectTitle}" (${this.workspaceRoot}).`;
168 }
170​
171export class ThreadLaunchBranchConflictError extends Schema.TaggedError<ThreadLaunchBranchConflictError>()(
172 "ThreadLaunchBranchConflictError",
173 {
174 projectId: ProjectId,
175 projectTitle: Schema.String,
176 branch: Schema.String,
177 conflictingBranch: Schema.String,
178 },
179) {
180 override get message(): string {
181 return `Branch "${this.branch}" cannot be created in project "${this.projectTitle}": branch "${this.conflictingBranch}" exists.`;
182 }
184​
185export class ThreadLaunchNotRepositoryError extends Schema.TaggedError<ThreadLaunchNotRepositoryError>()(
186 "ThreadLaunchNotRepositoryError",
187 {
188 projectId: ProjectId,
189 projectTitle: Schema.String,
190 workspaceRoot: Schema.String,
191 },
192) {
193 override get message(): string {
194 return `Project "${this.projectTitle}" (${this.workspaceRoot}) is not a Git repository.`;
195 }
197​
198export class ThreadLaunchService extends Context.Service<
199 ThreadLaunchService,
200 {
201 readonly launch: (
202 input: ThreadLaunchInput,
203 ) => Effect.Effect<
204 ThreadLaunchResult,
205 ThreadLaunchError | ThreadManagement.ThreadManagementProviderInstanceDisabledError
206 >;
207 /** Check a new worktree's base ref and branch name before creation for early errors; launch otherwise accepts and reports these failures inside the thread. */
208 readonly checkWorktreeBase: (
209 input: Pick<ThreadLaunchInput, "projectId" | "workspaceStrategy"> & {
210 /** Skips the check when this launch was already accepted, so its retry replays. */
211 readonly commandId?: CommandId;
212 },
213 ) => Effect.Effect<
214 void,
215 ThreadLaunchBaseRefError | ThreadLaunchBranchConflictError | ThreadLaunchNotRepositoryError
216 >;
217 readonly retryPreparation: (
218 input: ThreadLaunchPreparationInput,
219 ) => Effect.Effect<
220 Orchestrator.OrchestratorV2DispatchResult,
221 | Orchestrator.OrchestratorV2Error
222 | ThreadManagement.ThreadManagementProviderInstanceDisabledError
223 >;
224 readonly prepareRun: (input: ThreadLaunchPreparationInput) => Effect.Effect<void>;
225 }
226>()("t3/orchestration-v2/ThreadLaunchService") {}
227​
228const isThreadLaunchError = Schema.is(ThreadLaunchError);
229​
230function failureDetail(error: unknown): string {
231 if (isThreadLaunchError(error)) {
232 const cause = error.cause;
233 const detail = cause instanceof Error ? cause.message : String(cause);
234 return `Workspace preparation failed during ${error.operation.replaceAll("-", " ")}: ${detail}`;
235 }
236 return `Workspace preparation failed: ${error instanceof Error ? error.message : String(error)}`;
238​
239const make = Effect.gen(function* () {
240 const projects = yield* ProjectService.ProjectService;
241 const setupTracker = yield* WorktreeSetupTracker.WorktreeSetupTracker;
242 const cloneTracker = yield* ProjectCloneTracker.ProjectCloneTracker;
243 const terminals = yield* TerminalManager.TerminalManager;
244 const git = yield* GitWorkflow.GitWorkflowService;
245 const setupScripts = yield* ProjectSetupScriptRunner.ProjectSetupScriptRunner;
246 const providerRegistry = yield* ProviderRegistry.ProviderRegistry;
247 const serverSettings = yield* ServerSettings.ServerSettingsService;
248 const textGeneration = yield* TextGeneration.TextGeneration;
249 const receipts = yield* CommandReceiptStore.CommandReceiptStoreV2;
250 const ids = yield* IdAllocator.IdAllocatorV2;
251 const threads = yield* ThreadManagement.ThreadManagementService;
252 const eventSink = yield* EventSink.EventSinkV2;
253 const managedFolders = yield* ManagedProjectFolders.ManagedProjectFolders;
254 // A project-folder launch that names no branch records the folder's checkout, as the composer does.
255 const launchBranch = (strategy: ThreadLaunchWorkspaceStrategy, workspaceRoot: string) =>
256 strategy.type === "root" && strategy.branch === undefined
257 ? git.currentBranch(workspaceRoot).pipe(Effect.orElseSucceed(() => null))
258 : Effect.succeed(strategy.branch ?? null);
259 const preparationScope = yield* Scope.make("sequential");
260 const scheduledLaunches = yield* Ref.make<ReadonlySet<CommandId>>(new Set());
261 yield* Effect.addFinalizer(() => Scope.close(preparationScope, Exit.void));
262​
263 const mapError =
264 (input: PreparationInput, operation: ThreadLaunchError["operation"], threadId?: ThreadId) =>
265 (cause: unknown) =>
266 new ThreadLaunchError({
267 operation,
268 commandId: input.commandId,
269 projectId: input.projectId,
270 ...(threadId === undefined ? {} : { threadId }),
271 cause,
272 });
273​
274 const recordBranchRenameFailure = Effect.fn("ThreadLaunchService.recordBranchRenameFailure")(
275 function* (threadId: ThreadId, runId: RunId, branch: string, cause: Cause.Cause<unknown>) {
276 const error = Cause.squash(cause);
277 const now = yield* DateTime.now;
278 yield* eventSink.write({
279 events: [
280 {
281 id: yield* ids.allocate.event({ threadId }),
282 type: "turn-item.updated",
283 threadId,
284 runId,
285 occurredAt: now,
286 payload: {
287 id: ids.derive.runSignalTurnItem({ runId, signal: "branch-rename" }),
288 threadId,
289 runId,
290 nodeId: null,
291 providerThreadId: null,
292 providerTurnId: null,
293 nativeItemRef: null,
294 parentItemId: null,
295 // EventSink allocates the real position, after the run's existing items.
296 ordinal: 0,
297 status: "completed",
298 title: "Branch not renamed",
299 startedAt: now,
300 completedAt: now,
301 updatedAt: now,
302 type: "system_notice",
303 message: `Could not rename the worktree branch, so it stays ${branch}. ${error instanceof Error ? error.message : String(error)}`,
304 },
305 },
306 ],
307 });
308 },
309 Effect.catchCause((cause) =>
310 Effect.logWarning("Could not record the branch rename failure", { cause }),
311 ),
312 );
313​
314 const readReceipt = (input: ThreadLaunchInput, commandId: CommandId) =>
315 receipts
316 .getByCommandId(commandId)
317 .pipe(Effect.mapError(mapError(input, "read-receipt", input.threadId)));
318​
319 const validateReusableThread = Effect.fn("ThreadLaunchService.validateReusableThread")(function* (
320 input: ThreadLaunchInput,
321 threadId: ThreadId,
322 ) {
323 const projection = yield* threads
324 .getThreadRecords(threadId, ["runs"])
325 .pipe(Effect.mapError(mapError(input, "update-thread", threadId)));
326 if (
327 projection.thread.projectId !== input.projectId ||
328 projection.thread.archivedAt !== null ||
329 projection.thread.deletedAt !== null ||
330 (yield* threads
331 .getMessageCount(threadId)
332 .pipe(Effect.mapError(mapError(input, "update-thread", threadId)))) > 0 ||
333 projection.runs.length > 0
334 ) {
335 return yield* mapError(
336 input,
337 "update-thread",
338 threadId,
339 )("Only an empty active thread in the target project can change workspace during launch.");
340 }
341 });
342​
343 const checkWorktreeBase: ThreadLaunchService["Service"]["checkWorktreeBase"] = Effect.fn(
344 "ThreadLaunchService.checkWorktreeBase",
345 )(function* (input) {
346 const strategy = input.workspaceStrategy;
347 // launch() turns a Scratch worktree request into a root launch.
348 if (strategy.type !== "worktree" || (yield* managedFolders.isScratchProject(input.projectId)))
349 return;
350 if (input.commandId !== undefined) {
351 const receipt = yield* receipts
352 .getByCommandId(input.commandId)
353 .pipe(Effect.orElseSucceed(() => Option.none()));
354 if (Option.isSome(receipt) && receipt.value.status === "accepted") return;
355 }
356 const project = Option.getOrUndefined(
357 yield* projects.getById(input.projectId).pipe(Effect.orElseSucceed(() => Option.none())),
358 );
359 if (project === undefined) return;
360 const cwd = project.workspaceRoot;
361 const repositoryRoot = yield* git
362 .repositoryRoot(cwd)
363 .pipe(Effect.orElseSucceed(() => undefined));
364 if (repositoryRoot === null)
365 return yield* new ThreadLaunchNotRepositoryError({
366 projectId: project.id,
367 projectTitle: project.title,
368 workspaceRoot: cwd,
369 });
370 const { baseRef, branch } = strategy;
371 const baseResolves =
372 // `:/text` is a commit search, which cannot be asked for a commit type.
373 baseRef.startsWith(":") ||
374 (yield* Effect.gen(function* () {
375 // `git worktree add` reads `-` as the previous checkout.
376 if (yield* git.hasCommit({ cwd, refName: baseRef === "-" ? "@{-1}" : baseRef }))
377 return true;
378 if (
379 strategy.startFromOrigin === true &&
380 (yield* git.resolveWorktreeBaseRemote({ cwd, refName: baseRef })) !== null
381 )
382 return true;
383 // git worktree add accepts remote-tracking branches; let Git decide when any matching ref exists.
384 if (yield* git.hasRefNamed({ cwd, refName: baseRef })) return true;
385 return !(yield* git.hasCommit({ cwd, refName: "HEAD" }));
386 }).pipe(Effect.orElseSucceed(() => true)));
387 if (!baseResolves)
388 return yield* new ThreadLaunchBaseRefError({
389 projectId: project.id,
390 projectTitle: project.title,
391 workspaceRoot: project.workspaceRoot,
392 baseRef,
393 });
394 // Provisioning flattens temporary names that collide, so only explicit names are refused.
395 if (branch === undefined || isTemporaryWorktreeBranch(branch)) return;
396 const conflictingBranch = (yield* git
397 .listLocalBranchNames(cwd)
398 .pipe(Effect.orElseSucceed((): ReadonlyArray<string> => []))).find(
399 (existing) => existing.startsWith(`${branch}/`) || branch.startsWith(`${existing}/`),
400 );
401 if (conflictingBranch !== undefined)
402 return yield* new ThreadLaunchBranchConflictError({
403 projectId: project.id,
404 projectTitle: project.title,
405 branch,
406 conflictingBranch,
407 });
408 });
409​
410 const prepareInBackground = Effect.fn("ThreadLaunchService.prepareInBackground")(function* (
411 input: PreparationInput,
412 threadId: ThreadId,
413 runId: RunId | null,
414 ) {
415 const project = yield* projects.getById(input.projectId).pipe(
416 Effect.mapError(mapError(input, "resolve-project", threadId)),
417 Effect.flatMap(
418 Option.match({
419 onNone: () =>
420 Effect.fail(mapError(input, "resolve-project", threadId)("Project no longer exists.")),
421 onSome: Effect.succeed,
422 }),
423 ),
424 );
425​
426 const reused = input.reusedWorktree;
427 const tracked = input.workspaceStrategy.type === "worktree" || reused !== undefined;
428 let createdWorktreePath: string | null = null;
429 let createdBranch: string | null = null;
430 let setupTerminalId: string | null = null;
431 let workspaceRecorded = false;
432 let runReleased = false;
433 // Stop background renames before forgetting worktrees; completed renames record them again.
434 let branchRename: Fiber.Fiber<unknown> | null = null;
435 if (input.workspaceStrategy.type === "worktree") {
436 yield* setupTracker.begin({
437 threadId,
438 branch: input.workspaceStrategy.branch ?? null,
439 baseRef: input.workspaceStrategy.baseRef,
440 stages: ["fetch", "checkout", "setup-script", "agent"],
441 fiber: yield* Effect.fiber,
442 });
443 } else if (reused !== undefined) {
444 yield* setupTracker.begin({
445 threadId,
446 branch: input.workspaceStrategy.branch ?? null,
447 baseRef: reused.baseRef,
448 stages: ["setup-script", "agent"],
449 fiber: yield* Effect.fiber,
450 });
451 }
452 yield* Effect.gen(function* () {
453 const initialMessage = input.initialMessage;
454 const generateBranchNameFor = Effect.fn("ThreadLaunchService.generateBranchNameFor")(
455 function* (cwd: string, message: ThreadLaunchInitialMessage) {
456 const settings = resolveProjectSettings(
457 yield* serverSettings.getSettings,
458 input.projectId,
459 ).settings;
460 const modelSelection =
461 settings.sourceControlWriterModelSelection === null
462 ? settings.textGenerationModelSelection
463 : ServerSettings.resolveSourceControlWriterModelSelection(
464 settings,
465 yield* providerRegistry.getProviders,
466 input.projectId,
467 );
468 return yield* textGeneration
469 .generateBranchName({
470 naming: {
471 mode: settings.branchNamingMode,
472 prefix: settings.branchNamePrefix,
473 instructions: settings.branchNameInstructions,
474 },
475 cwd,
476 message: message.text,
477 attachments: message.attachments,
478 ...(message.context ? { context: message.context } : {}),
479 modelSelection,
480 projectId: input.projectId,
481 })
482 .pipe(
483 Effect.map((result) => ({
484 branch: result.branch,
485 exactName: settings.branchNamingMode === "custom",
486 })),
487 );
488 },
489 );
490​
491 const requestedBranch = input.workspaceStrategy.branch;
492 let branch: string | null;
493 if (input.workspaceStrategy.type === "worktree" && requestedBranch === undefined) {
494 const uuid = yield* randomUuidV4;
495 branch = buildTemporaryWorktreeBranchName(() => uuid.replaceAll("-", ""));
496 } else {
497 branch = yield* launchBranch(input.workspaceStrategy, project.workspaceRoot);
498 }
499 let worktreePath =
500 input.workspaceStrategy.type === "existing_worktree"
501 ? input.workspaceStrategy.worktreePath
502 : null;
503 if (input.workspaceStrategy.type === "worktree") {
504 if (runId !== null) {
505 yield* threads
506 .dispatch({
507 type: "prepared-run.progress",
508 commandId: CommandId.make(`${input.commandId}:progress:worktree`),
509 threadId,
510 runId,
511 phase: "worktree",
512 })
513 .pipe(Effect.mapError(mapError(input, "update-thread", threadId)));
514 }
515 let startRef = input.workspaceStrategy.baseRef;
516 let baseRefName = input.workspaceStrategy.baseRef;
517 const remote =
518 input.workspaceStrategy.startFromOrigin === true
519 ? yield* git
520 .resolveWorktreeBaseRemote({
521 cwd: project.workspaceRoot,
522 refName: input.workspaceStrategy.baseRef,
523 })
524 .pipe(Effect.mapError(mapError(input, "provision-worktree", threadId)))
525 : null;
526 const shouldFetch = remote !== null;
527 yield* setupTracker.stageStatus(threadId, "fetch", shouldFetch ? "running" : "skipped");
528 if (remote !== null) {
529 const remoteBase = yield* git
530 .fetchWorktreeBaseCommit({
531 cwd: project.workspaceRoot,
532 refName: input.workspaceStrategy.baseRef,
533 remote,
534 })
535 .pipe(Effect.mapError(mapError(input, "provision-worktree", threadId)));
536 if (remoteBase !== null) {
537 startRef = remoteBase.commitSha;
538 baseRefName = `refs/remotes/${remoteBase.remoteRefName}`;
539 }
540 }
541 if (shouldFetch) yield* setupTracker.stageStatus(threadId, "fetch", "done");
542 if (
543 branch !== null &&
544 isTemporaryWorktreeBranch(branch) &&
545 (yield* git
546 .hasCommit({
547 cwd: project.workspaceRoot,
548 refName: `refs/heads/${WORKTREE_BRANCH_PREFIX}`,
549 })
550 .pipe(Effect.mapError(mapError(input, "provision-worktree", threadId))))
551 ) {
552 branch = flattenTemporaryWorktreeBranchName(branch);
553 }
554 yield* setupTracker.stageStatus(threadId, "checkout", "running");
555 const worktree = yield* git
556 .createWorktree(
557 {
558 cwd: project.workspaceRoot,
559 refName: startRef,
560 newRefName: branch!,
561 baseRefName,
562 path: null,
563 },
564 {
565 progress: {
566 onWorktreeClaimed: (path) =>
567 Effect.sync(() => {
568 createdWorktreePath = path;
569 createdBranch = branch;
570 }),
571 onCheckoutProgress: (progress) =>
572 setupTracker.stage(threadId, "checkout", { percent: progress.percent }),
573 },
574 },
575 )
576 .pipe(Effect.mapError(mapError(input, "provision-worktree", threadId)));
577 worktreePath = worktree.worktree.path;
578 branch = worktree.worktree.refName;
579 createdWorktreePath = worktreePath;
580 createdBranch = branch;
581 yield* setupTracker.update(threadId, (snapshot) => ({ ...snapshot, worktreePath, branch }));
582 yield* setupTracker.stageStatus(threadId, "checkout", "done");
583 }
584​
585 // Do not rewrite reused worktrees; that could undo the original branch rename.
586 if (reused === undefined) {
587 yield* threads
588 .dispatch({
589 type: "thread.metadata.update",
590 commandId: CommandId.make(`${input.commandId}:workspace`),
591 threadId,
592 branch,
593 worktreePath,
594 })
595 .pipe(Effect.mapError(mapError(input, "update-thread", threadId)));
596 }
597 workspaceRecorded = true;
598​
599 if (
600 reused === undefined &&
601 worktreePath !== null &&
602 branch !== null &&
603 initialMessage !== undefined &&
604 isTemporaryWorktreeBranch(branch)
605 ) {
606 const oldBranch = branch;
607 const worktreeCwd = worktreePath;
608 branchRename = yield* generateBranchNameFor(worktreeCwd, initialMessage).pipe(
609 Effect.flatMap(({ branch: newBranch, exactName }) =>
610 git
611 .renameBranch({
612 cwd: worktreeCwd,
613 oldBranch,
614 newBranch,
615 ...(exactName ? { exactName: true } : {}),
616 })
617 .pipe(
618 // A cancel must see the renamed branch, or its cleanup deletes the old name.
619 Effect.tap((renamed) =>
620 Effect.sync(() => {
621 createdBranch = renamed.branch;
622 }),
623 ),
624 Effect.uninterruptible,
625 ),
626 ),
627 Effect.flatMap((renamed) =>
628 Effect.gen(function* () {
629 yield* threads
630 .dispatch({
631 type: "thread.metadata.update",
632 commandId: CommandId.make(`${input.commandId}:branch-rename`),
633 threadId,
634 branch: renamed.branch,
635 worktreePath: worktreeCwd,
636 expectedBranch: oldBranch,
637 expectedWorktreePath: worktreeCwd,
638 })
639 .pipe(
640 Effect.catchCause((cause) =>
641 Effect.logWarning("Parent thread worktree branch update failed", {
642 threadId,
643 cause,
644 }),
645 ),
646 );
647 // Update parents first so children inherit names; reread each pass for newly updated descendants.
648 const visited = new Set([threadId]);
649 while (true) {
650 const snapshot = yield* threads.getShellSnapshot({ worktreePath: worktreeCwd });
651 const sharing = [...snapshot.threads, ...snapshot.archivedThreads].filter(
652 (thread) => !visited.has(thread.id) && thread.branch === oldBranch,
653 );
654 if (sharing.length === 0) break;
655 for (const thread of sharing) {
656 visited.add(thread.id);
657 yield* threads
658 .dispatch({
659 type: "thread.metadata.update",
660 commandId: CommandId.make(`${input.commandId}:branch-rename:${thread.id}`),
661 threadId: thread.id,
662 branch: renamed.branch,
663 expectedBranch: oldBranch,
664 expectedWorktreePath: worktreeCwd,
665 })
666 .pipe(
667 Effect.catchCause((cause) =>
668 Effect.logWarning("Shared thread worktree branch update failed", {
669 threadId: thread.id,
670 cause,
671 }),
672 ),
673 );
674 }
675 }
676 }),
677 ),
678 Effect.catchCause((cause) =>
679 Effect.logWarning("Thread worktree branch rename failed", {
680 commandId: input.commandId,
681 threadId,
682 oldBranch,
683 cause,
684 }).pipe(
685 Effect.andThen(
686 runId === null || Cause.hasInterruptsOnly(cause)
687 ? Effect.void
688 : recordBranchRenameFailure(threadId, runId, oldBranch, cause),
689 ),
690 ),
691 ),
692 Effect.forkIn(preparationScope),
693 );
694 }
695​
696 if (runId !== null && worktreePath !== null) {
697 yield* threads
698 .dispatch({
699 type: "prepared-run.progress",
700 commandId: CommandId.make(`${input.commandId}:progress:setup`),
701 threadId,
702 runId,
703 phase: "setup",
704 })
705 .pipe(Effect.mapError(mapError(input, "update-thread", threadId)));
706 }
707 yield* setupTracker.stageStatus(threadId, "setup-script", "running");
708 const setup =
709 worktreePath === null
710 ? ({ status: "no-script" } as const)
711 : yield* setupScripts
712 .runForThread({
713 threadId,
714 projectId: input.projectId,
715 projectCwd: project.workspaceRoot,
716 worktreePath,
717 ...(tracked
718 ? {
719 observeCompletion: {
720 onOutputLine: (line: string) =>
721 setupTracker.appendTail(threadId, "setup-script", line),
722 },
723 }
724 : {}),
725 project: {
726 id: project.id,
727 workspaceRoot: project.workspaceRoot,
728 scripts: project.scripts,
729 },
730 })
731 .pipe(Effect.mapError(mapError(input, "run-setup-script", threadId)));
732​
733 let awaitAsyncSetup = Effect.void;
734 if (setup.status === "started") {
735 setupTerminalId = setup.terminalId;
736 yield* setupTracker.update(threadId, (snapshot) => ({
737 ...snapshot,
738 setupScript: {
739 name: setup.scriptName,
740 command: setup.scriptCommand,
741 terminalId: setup.terminalId,
742 },
743 }));
744 if (setup.completion) {
745 const awaitCompletion = Effect.gen(function* () {
746 const completion = yield* setup.completion!;
747 yield* setupTracker.stage(threadId, "setup-script", {
748 status: completion.exitCode === 0 ? "done" : "failed",
749 detail: `exited with ${completion.exitCode ?? "no exit code"}`,
750 });
751 if (completion.exitCode !== 0 && !setup.async)
752 return yield* mapError(
753 input,
754 "run-setup-script",
755 threadId,
756 )(`Setup script exited with ${completion.exitCode ?? "no exit code"}.`);
757 });
758 if (setup.async) {
759 awaitAsyncSetup = awaitCompletion.pipe(
760 Effect.catchCause((cause) =>
761 setupTracker.stage(threadId, "setup-script", {
762 status: "failed",
763 detail: failureDetail(Cause.squash(cause)),
764 }),
765 ),
766 );
767 } else {
768 yield* awaitCompletion;
769 }
770 } else {
771 yield* setupTracker.stageStatus(threadId, "setup-script", "done");
772 }
773 } else {
774 yield* setupTracker.stageStatus(threadId, "setup-script", "skipped");
775 }
776 // The agent must start on the branch name the thread will show.
777 if (branchRename !== null) yield* Fiber.join(branchRename);
778 yield* setupTracker.markUncancellable(threadId);
779 yield* setupTracker.stageStatus(threadId, "agent", "running");
780 if (runId !== null) {
781 yield* threads
782 .dispatch({
783 type: "prepared-run.release",
784 commandId: CommandId.make(`${input.commandId}:release`),
785 threadId,
786 runId,
787 })
788 .pipe(
789 Effect.mapError(mapError(input, "release-run", threadId)),
790 Effect.tap(() =>
791 Effect.sync(() => {
792 runReleased = true;
793 }),
794 ),
795 Effect.uninterruptible,
796 );
797 }
798 yield* setupTracker.stageStatus(threadId, "agent", "done");
799 yield* awaitAsyncSetup;
800 yield* setupTracker.finish(threadId, "done");
801 }).pipe(
802 Effect.onError((cause) =>
803 Effect.gen(function* () {
804 const cancelled = Cause.hasInterruptsOnly(cause);
805 yield* setupTracker.finish(
806 threadId,
807 cancelled ? "cancelled" : "failed",
808 cancelled ? null : failureDetail(Cause.squash(cause)),
809 );
810 if (cancelled && setupTerminalId)
811 yield* terminals
812 .close({ threadId, terminalId: setupTerminalId, deleteHistory: true })
813 .pipe(Effect.ignore);
814 if (
815 tracked &&
816 createdWorktreePath &&
817 ((cancelled && !runReleased) || !workspaceRecorded)
818 ) {
819 if (branchRename !== null) yield* Fiber.interrupt(branchRename);
820 const removedPath = createdWorktreePath;
821 const removedBranch = createdBranch;
822 // Forget worktrees only after removal succeeds; failed removals must not reuse partially written checkouts.
823 yield* git
824 .removeWorktree({ cwd: project.workspaceRoot, path: removedPath, force: true })
825 .pipe(
826 Effect.andThen(
827 removedBranch === null
828 ? Effect.void
829 : git
830 .deleteLocalBranch({
831 cwd: project.workspaceRoot,
832 refName: removedBranch,
833 force: true,
834 })
835 .pipe(Effect.ignoreCause({ log: true })),
836 ),
837 Effect.andThen(
838 threads
839 .dispatch({
840 type: "thread.metadata.update",
841 commandId: CommandId.make(`${input.commandId}:cancel-workspace`),
842 threadId,
843 worktreePath: null,
844 branch: null,
845 })
846 .pipe(Effect.ignore),
847 ),
848 Effect.catchCause((removeCause) =>
849 Effect.logWarning("Failed to remove an abandoned thread worktree", {
850 commandId: input.commandId,
851 threadId,
852 path: removedPath,
853 cause: removeCause,
854 }),
855 ),
856 );
857 }
858 }),
859 ),
860 );
861 });
862​
863 const failPreparedRun = (
864 input: Pick<PreparationInput, "commandId">,
865 threadId: ThreadId,
866 runId: RunId | null,
867 cause: unknown,
868 ) =>
869 runId === null
870 ? Effect.logWarning("Thread workspace preparation failed", {
871 commandId: input.commandId,
872 threadId,
873 cause,
874 })
875 : threads
876 .dispatch({
877 type: "prepared-run.fail",
878 commandId: CommandId.make(`${input.commandId}:fail`),
879 threadId,
880 runId,
881 failure: makeProviderFailure({
882 cause,
883 message: failureDetail(cause),
884 class: "validation_error",
885 retryable: false,
886 }),
887 })
888 .pipe(
889 Effect.catchCause((persistCause) =>
890 Effect.logWarning("Failed to persist thread workspace preparation failure", {
891 commandId: input.commandId,
892 threadId,
893 cause,
894 persistCause,
895 }),
896 ),
897 );
898​
899 const reservePreparation = (commandId: CommandId) =>
900 Ref.modify(scheduledLaunches, (scheduled) => {
901 if (scheduled.has(commandId)) return [false, scheduled] as const;
902 const next = new Set(scheduled);
903 next.add(commandId);
904 return [true, next] as const;
905 });
906​
907 const releasePreparation = (commandId: CommandId) =>
908 Ref.update(scheduledLaunches, (scheduled) => {
909 const next = new Set(scheduled);
910 next.delete(commandId);
911 return next;
912 });
913​
914 const schedulePreparation = Effect.fn("ThreadLaunchService.schedulePreparation")(function* (
915 input: PreparationInput,
916 threadId: ThreadId,
917 runId: RunId | null,
918 ) {
919 const preparation = Effect.gen(function* () {
920 const prepare = prepareInBackground(input, threadId, runId);
921 if (
922 runId === null ||
923 (input.workspaceStrategy.type !== "worktree" && input.reusedWorktree === undefined)
924 ) {
925 return yield* prepare;
926 }
927 const afterSequence = yield* threads.getThreadEventSequence(threadId);
928 const projection = yield* threads.getThreadRecords(threadId, ["runs"]);
929 if (projection.runs.find((run) => run.id === runId)?.status !== "preparing") return;
930 const interruption = threads
931 .streamStoredEventsFrom({ threadId, afterSequence, eventType: "run.updated" })
932 .pipe(
933 Stream.filter(
934 (stored) =>
935 stored.event.type === "run.updated" &&
936 stored.event.payload.id === runId &&
937 (stored.event.payload.status === "interrupted" ||
938 stored.event.payload.status === "cancelled"),
939 ),
940 Stream.runHead,
941 Effect.flatMap((event) => (Option.isSome(event) ? Effect.interrupt : Effect.never)),
942 );
943 yield* Effect.raceFirst(prepare, interruption);
944 });
945 yield* preparation.pipe(
946 Effect.onError((cause) =>
947 failPreparedRun(
948 input,
949 threadId,
950 runId,
951 Cause.hasInterruptsOnly(cause) ? "Worktree setup cancelled." : Cause.squash(cause),
952 ),
953 ),
954 Effect.ignoreCause,
955 Effect.ensuring(releasePreparation(input.commandId)),
956 Effect.forkIn(preparationScope),
957 );
958 });
959​
960 const launch: ThreadLaunchService["Service"]["launch"] = Effect.fn("ThreadLaunchService.launch")(
961 function* (request) {
962 // Scratch has no Git repository. Normalize before folder allocation so
963 // durable preparation and retries use the thread's own Scratch folder.
964 const input: ThreadLaunchInput =
965 request.workspaceStrategy.type === "worktree" &&
966 (yield* managedFolders.isScratchProject(request.projectId))
967 ? { ...request, workspaceStrategy: { type: "root" } }
968 : request;
969 yield* ProjectCloneTracker.rejectCommandsDuringClone(cloneTracker, {
970 type: "thread.create",
971 projectId: input.projectId,
972 }).pipe(Effect.mapError(mapError(input, "resolve-project")));
973 const project = yield* projects.getById(input.projectId).pipe(
974 Effect.mapError(mapError(input, "resolve-project")),
975 Effect.flatMap(
976 Option.match({
977 onNone: () => Effect.fail(mapError(input, "resolve-project")("Project not found.")),
978 onSome: Effect.succeed,
979 }),
980 ),
981 );
982 if (input.reuseExistingThread === true && input.threadId === undefined) {
983 return yield* mapError(
984 input,
985 "update-thread",
986 )("Reusing an existing thread requires a thread id.");
987 }
988​
989 const launchReceipt = yield* readReceipt(input, input.commandId);
990 yield* threads.assertProviderInstanceEnabled({
991 projectId: input.projectId,
992 commandId: input.commandId,
993 instanceId: input.modelSelection.instanceId,
994 });
995 return yield* Effect.gen(function* () {
996 // Recover ids from accepted launch receipts before allocating; retries have no client id and new ids collide with receipts.
997 const reusableLaunchReceipt =
998 input.threadId === undefined &&
999 Option.isSome(launchReceipt) &&
1000 launchReceipt.value.status === "accepted" &&
1001 launchReceipt.value.commandType === "thread.create"
1002 ? launchReceipt.value
1003 : undefined;
1004 const candidateThreadId =
1005 input.threadId ??
1006 reusableLaunchReceipt?.threadId ??
1007 (yield* ids.allocate
1008 .thread({ projectId: input.projectId })
1009 .pipe(Effect.mapError(mapError(input, "create-thread"))));
1010​
1011 if (reusableLaunchReceipt !== undefined) {
1012 const shell = yield* threads
1013 .getThreadShell(candidateThreadId)
1014 .pipe(Effect.mapError(mapError(input, "create-thread", candidateThreadId)));
1015 if (shell === null) {
1016 return yield* mapError(input, "create-thread", candidateThreadId)("Thread not found.");
1018 if (shell.projectId !== input.projectId) {
1019 return yield* mapError(
1020 input,
1021 "resolve-project",
1022 candidateThreadId,
1023 )("Project identity changed.");
1026​
1027 if (input.reuseExistingThread === true && Option.isNone(launchReceipt)) {
1028 yield* validateReusableThread(input, candidateThreadId);
1030​
1031 const workspaceStrategy: ThreadLaunchWorkspaceStrategy =
1032 input.workspaceStrategy.type === "root" && Option.isNone(launchReceipt)
1033 ? Option.match(
1034 yield* managedFolders
1035 .folderForThread({
1036 projectId: input.projectId,
1037 threadId: candidateThreadId,
1038 text: input.initialMessage?.text ?? input.title,
1039 })
1040 .pipe(Effect.mapError(mapError(input, "provision-worktree", candidateThreadId))),
1042 onNone: () => input.workspaceStrategy,
1043 onSome: (worktreePath) => ({ type: "existing_worktree", worktreePath }),
1044 },
1046 : input.workspaceStrategy;
1047 const initialBranch = yield* launchBranch(workspaceStrategy, project.workspaceRoot);
1048 const initialWorktreePath =
1049 workspaceStrategy.type === "existing_worktree" ? workspaceStrategy.worktreePath : null;
1050 const claimDispatch =
1051 input.reuseExistingThread === true
1052 ? threads.dispatch({
1053 type: "thread.metadata.update",
1054 commandId: input.commandId,
1055 threadId: candidateThreadId,
1056 expectedEmpty: true,
1057 })
1058 : threads.dispatch({
1059 type: "thread.create",
1060 commandId: input.commandId,
1061 threadId: candidateThreadId,
1062 projectId: input.projectId,
1063 title: input.title,
1064 modelSelection: input.modelSelection,
1065 runtimeMode: input.runtimeMode,
1066 interactionMode: input.interactionMode,
1067 branch: initialBranch,
1068 worktreePath: initialWorktreePath,
1069 ...(input.importedNativeThread === undefined
1070 ? {}
1071 : { importedNativeThread: input.importedNativeThread }),
1072 ...(input.linkOrigin === undefined ? {} : { linkOrigin: input.linkOrigin }),
1073 ...(input.delegatedFrom === undefined
1074 ? {}
1075 : { delegatedFrom: input.delegatedFrom }),
1076 createdBy: input.createdBy,
1077 creationSource: input.creationSource,
1078 });
1079 const claimed = yield* claimDispatch.pipe(
1080 Effect.mapError(
1081 mapError(
1082 input,
1083 input.reuseExistingThread === true ? "update-thread" : "create-thread",
1084 candidateThreadId,
1085 ),
1086 ),
1087 );
1088 const threadId =
1089 claimed.storedEvents.find((stored) => stored.event.type.startsWith("thread."))?.event
1090 .threadId ?? candidateThreadId;
1091 if (project.id !== input.projectId) {
1092 return yield* mapError(input, "resolve-project", threadId)("Project identity changed.");
1094 let runId: RunId | null = null;
1095 let messageSequence: number | undefined;
1096 let messageWasAlreadyAccepted = false;
1097 if (input.initialMessage !== undefined) {
1098 const messageCommandId = CommandId.make(`${input.commandId}:initial-message`);
1099 const messageReceipt = yield* readReceipt(input, messageCommandId);
1100 messageWasAlreadyAccepted = Option.isSome(messageReceipt);
1101 const messageId =
1102 input.initialMessage.messageId ??
1103 (yield* ids.allocate
1104 .message({ threadId, ordinal: 1 })
1105 .pipe(Effect.mapError(mapError(input, "dispatch-message", threadId))));
1106 const dispatched = yield* threads
1107 .dispatch({
1108 type: "message.dispatch",
1109 commandId: messageCommandId,
1110 threadId,
1111 messageId,
1112 text: input.initialMessage.text,
1113 ...(input.initialMessage.scheduledTaskId === undefined
1114 ? {}
1115 : { scheduledTaskId: input.initialMessage.scheduledTaskId }),
1116 ...(input.initialMessage.senderThreadId === undefined
1117 ? {}
1118 : { senderThreadId: input.initialMessage.senderThreadId }),
1119 attachments: input.initialMessage.attachments,
1120 ...(input.initialMessage.context ? { context: input.initialMessage.context } : {}),
1121 ...(input.generateTitle === true ? { titleSeed: input.title } : {}),
1122 modelSelection: input.modelSelection,
1123 dispatchMode: { type: "defer_start", workspaceStrategy },
1124 createdBy: input.createdBy,
1125 creationSource: input.creationSource,
1126 })
1127 .pipe(
1128 // An agent's launch can land on a thread that a replay, or a concurrent
1129 // launch with the same key, created with broader modes. The orchestrator
1130 // refuses the message under the thread's lock; the user's own clients
1131 // launch into their own drafts, whatever modes those hold.
1132 input.createdBy === "agent"
1133 ? Effect.provideService(DispatchModeLimit, {
1134 runtimeMode: input.runtimeMode,
1135 interactionMode: input.interactionMode,
1136 })
1137 : (effect) => effect,
1138 Effect.mapError(mapError(input, "dispatch-message", threadId)),
1139 );
1140 // A concurrent retry can read no receipt and still replay the dispatch.
1141 messageWasAlreadyAccepted ||= dispatched.replayed === true;
1142 messageSequence = dispatched.sequence;
1143 const runCreated = dispatched.storedEvents.find(
1144 (stored) => stored.event.type === "run.created",
1145 );
1146 runId = runCreated?.event.type === "run.created" ? runCreated.event.payload.id : null;
1147 if (runId === null) {
1148 return yield* mapError(
1149 input,
1150 "dispatch-message",
1151 threadId,
1152 )("Initial message was accepted without a durable run.");
1155​
1156 const projection = yield* threads
1157 .getThreadProjection(threadId)
1158 .pipe(Effect.mapError(mapError(input, "create-thread", threadId)));
1159 const runIsPreparing =
1160 runId !== null &&
1161 projection.runs.some((run) => run.id === runId && run.status === "preparing");
1162 const shouldSchedule = runId === null ? Option.isNone(launchReceipt) : runIsPreparing;
1163 const boundWorktreePath = projection.thread.worktreePath;
1164 const preparationStrategy: ThreadLaunchWorkspaceStrategy =
1165 Option.isSome(launchReceipt) &&
1166 workspaceStrategy.type === "root" &&
1167 boundWorktreePath !== null
1168 ? {
1169 type: "existing_worktree",
1170 worktreePath: boundWorktreePath,
1171 branch: workspaceStrategy.branch,
1173 : workspaceStrategy;
1174 if (shouldSchedule) {
1175 const ownsPreparation = yield* reservePreparation(input.commandId);
1176 if (ownsPreparation) {
1177 yield* Effect.gen(function* () {
1178 const preparationStillRequired =
1179 runId === null
1180 ? true
1181 : yield* threads.getThreadRecords(threadId, ["runs"], { runIds: [runId] }).pipe(
1182 Effect.map((current) =>
1183 current.runs.some((run) => run.id === runId && run.status === "preparing"),
1184 ),
1185 Effect.mapError(mapError(input, "update-thread", threadId)),
1186 );
1187 if (preparationStillRequired) {
1188 yield* schedulePreparation(
1189 { ...input, workspaceStrategy: preparationStrategy },
1190 threadId,
1191 runId,
1192 );
1193 } else {
1194 yield* releasePreparation(input.commandId);
1196 }).pipe(Effect.onError(() => releasePreparation(input.commandId)));
1199​
1200 return {
1201 threadId,
1202 projection,
1203 // An attempt that failed after claiming the thread recorded nothing, so
1204 // only a repeat of the final write counts as resumed.
1205 resumed:
1206 input.initialMessage === undefined
1207 ? Option.isSome(launchReceipt) || claimed.replayed === true
1208 : messageWasAlreadyAccepted,
1209 ...(messageSequence === undefined ? {} : { sequence: messageSequence }),
1210 };
1211 });
1212 },
1213 );
1214​
1215 const prepareRun: ThreadLaunchService["Service"]["prepareRun"] = Effect.fn(
1216 "ThreadLaunchService.prepareRun",
1217 )(function* (input) {
1218 const scheduled = yield* Effect.gen(function* () {
1219 const { thread, runs } = yield* threads.getThreadRecords(input.threadId, ["runs"], {
1220 runIds: [input.runId],
1221 });
1222 const run = runs.find((candidate) => candidate.id === input.runId);
1223 const workspacePreparation = run?.workspacePreparation;
1224 if (run?.status !== "preparing" || workspacePreparation === undefined) return;
1225 if (!(yield* reservePreparation(input.commandId))) return;
1226 yield* scheduleRunPreparation(input, thread, run, workspacePreparation).pipe(
1227 Effect.onError(() => releasePreparation(input.commandId)),
1228 );
1229 }).pipe(Effect.exit);
1230 if (Exit.isFailure(scheduled)) {
1231 yield* failPreparedRun(input, input.threadId, input.runId, Cause.squash(scheduled.cause));
1233 });
1234​
1235 const retryPreparation: ThreadLaunchService["Service"]["retryPreparation"] = Effect.fn(
1236 "ThreadLaunchService.retryPreparation",
1237 )(function* (input) {
1238 const dispatched = yield* threads.dispatch({
1239 type: "prepared-run.retry",
1240 commandId: input.commandId,
1241 threadId: input.threadId,
1242 runId: input.runId,
1243 });
1244 yield* prepareRun(input);
1245 return dispatched;
1246 });
1247​
1248 const scheduleRunPreparation = Effect.fn("ThreadLaunchService.scheduleRunPreparation")(function* (
1249 input: ThreadLaunchPreparationInput,
1250 thread: OrchestrationV2ThreadProjection["thread"],
1251 run: OrchestrationV2ThreadProjection["runs"][number],
1252 workspacePreparation: ThreadLaunchWorkspaceStrategy,
1253 ) {
1254 const { messages } = yield* threads.getThreadRecords(input.threadId, ["messages"], {
1255 messageIds: [run.userMessageId],
1256 });
1257 const message = messages.find((candidate) => candidate.id === run.userMessageId);
1258 const reuse =
1259 workspacePreparation.type === "worktree" &&
1260 thread.worktreePath !== null &&
1261 thread.branch !== null
1262 ? {
1263 strategy: {
1264 type: "existing_worktree" as const,
1265 worktreePath: thread.worktreePath,
1266 branch: thread.branch,
1267 },
1268 reusedWorktree: { baseRef: workspacePreparation.baseRef },
1270 : null;
1271 return yield* schedulePreparation(
1273 commandId: input.commandId,
1274 projectId: thread.projectId,
1275 workspaceStrategy: reuse?.strategy ?? workspacePreparation,
1276 ...(reuse === null ? {} : { reusedWorktree: reuse.reusedWorktree }),
1277 ...(message === undefined
1278 ? {}
1279 : {
1280 initialMessage: {
1281 text: message.text,
1282 attachments: message.attachments,
1283 ...(message.context ? { context: message.context } : {}),
1284 },
1285 }),
1286 },
1287 input.threadId,
1288 run.id,
1289 );
1290 });
1291​
1292 return ThreadLaunchService.of({ launch, checkWorktreeBase, retryPreparation, prepareRun });
1293});
1294​
1295export const layer = Layer.effect(ThreadLaunchService, make);