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 OrchestrationV2ProviderThreadNativeMetadata,
13 type OrchestrationV2ThreadProjection,
14 type ProviderDriverKind,
15 type ProviderInteractionMode,
16 ProjectId,
17 type RunId,
18 type RuntimeMode,
19 type ScheduledTaskId,
20 ThreadId,
21} from "@t3tools/contracts";
22import * as Cause from "effect/Cause";
23import * as Context from "effect/Context";
24import * as Effect from "effect/Effect";
25import * as Exit from "effect/Exit";
26import * as Layer from "effect/Layer";
27import * as Option from "effect/Option";
28import * as Ref from "effect/Ref";
29import * as Schema from "effect/Schema";
30import * as Scope from "effect/Scope";
31import * as Stream from "effect/Stream";
32import {
33 buildTemporaryWorktreeBranchName,
34 flattenTemporaryWorktreeBranchName,
35 isTemporaryWorktreeBranch,
36 WORKTREE_BRANCH_PREFIX,
37} from "@t3tools/shared/git";
38
39import * as GitWorkflow from "../git/GitWorkflowService.ts";
40import * as ProjectService from "../project/ProjectService.ts";
41import * as ProjectSetupScriptRunner from "../project/ProjectSetupScriptRunner.ts";
42import * as ManagedProjectFolders from "../project/ManagedProjectFolders.ts";
43import * as ProviderRegistry from "../provider/ProviderRegistry.ts";
44import * as ServerSettings from "../serverSettings.ts";
45import * as TextGeneration from "../textGeneration/TextGeneration.ts";
46import * as CommandReceiptStore from "./CommandReceiptStore.ts";
47import * as IdAllocator from "@t3tools/provider-core/server/IdAllocator";
48import type * as Orchestrator from "./Orchestrator.ts";
49import { makeProviderFailure } from "@t3tools/provider-core/server/failure";
50import { randomUuidV4 } from "@t3tools/provider-core/server/randomUuid";
51import * as ThreadManagement from "./ThreadManagementService.ts";
52
53export type ThreadLaunchWorkspaceStrategy =
54 | { readonly type: "root"; readonly branch?: string | undefined }
55 | {
56 readonly type: "existing_worktree";
57 readonly worktreePath: string;
58 readonly branch?: string | undefined;
59 }
60 | {
61 readonly type: "worktree";
62 readonly baseRef: string;
63 readonly branch?: string | undefined;
64 readonly startFromOrigin?: boolean | undefined;
65 };
66
67export interface ThreadLaunchInitialMessage {
68 readonly messageId?: MessageId;
69 readonly scheduledTaskId?: ScheduledTaskId;
70 readonly senderThreadId?: ThreadId;
71 readonly text: string;
72 readonly attachments: ReadonlyArray<ChatAttachment>;
73 readonly context?: import("@t3tools/contracts").OrchestrationMessageContext | undefined;
74}
75
76export interface ThreadLaunchInput {
77 readonly commandId: CommandId;
78 readonly threadId?: ThreadId;
79 readonly reuseExistingThread?: boolean;
80 readonly projectId: ProjectId;
81 readonly title: string;
82 readonly generateTitle?: boolean;
83 readonly modelSelection: ModelSelection;
84 readonly runtimeMode: RuntimeMode;
85 readonly interactionMode: ProviderInteractionMode;
86 readonly workspaceStrategy: ThreadLaunchWorkspaceStrategy;
87 readonly initialMessage?: ThreadLaunchInitialMessage;
88 readonly importedNativeThread?: {
89 readonly ref: {
90 readonly driver: ProviderDriverKind;
91 readonly nativeId: string;
92 readonly strength: "strong";
93 };
94 readonly metadata?: OrchestrationV2ProviderThreadNativeMetadata;
95 };
96 readonly createdBy: OrchestrationV2Actor;
97 readonly creationSource: OrchestrationV2CreationSource;
98}
99
100/** What workspace preparation reads from a launch; a retry rebuilds it from the run. */
101type PreparationInput = Pick<
102 ThreadLaunchInput,
103 "commandId" | "projectId" | "workspaceStrategy" | "initialMessage"
104> & {
105 /**
106 * Set when a retry reuses the worktree its failed attempt created and
107 * recorded. Its setup is tracked like a new one, but the thread already
108 * records the workspace, and a branch rename may still be running.
109 */
110 readonly reusedWorktree?: { readonly baseRef: string };
111};
112
113export interface ThreadLaunchPreparationInput {
114 readonly commandId: CommandId;
115 readonly threadId: ThreadId;
116 readonly runId: RunId;
117}
118
119export interface ThreadLaunchResult {
120 readonly threadId: ThreadId;
121 readonly projection: OrchestrationV2ThreadProjection;
122 readonly resumed: boolean;
123}
124
125export class ThreadLaunchError extends Schema.TaggedError<ThreadLaunchError>()(
126 "ThreadLaunchError",
127 {
128 operation: Schema.Literals([
129 "resolve-project",
130 "read-receipt",
131 "generate-metadata",
132 "provision-worktree",
133 "run-setup-script",
134 "create-thread",
135 "update-thread",
136 "dispatch-message",
137 "release-run",
138 "fail-run",
139 ]),
140 commandId: CommandId,
141 projectId: ProjectId,
142 threadId: Schema.optional(ThreadId),
143 cause: Schema.Defect(),
144 },
145) {
146 override get message(): string {
147 return `Thread launch ${this.commandId} failed during ${this.operation}.`;
148 }
149}
150
151/** A new worktree was asked to start from a ref that gives it no commit. */
152export class ThreadLaunchBaseRefError extends Schema.TaggedError<ThreadLaunchBaseRefError>()(
153 "ThreadLaunchBaseRefError",
154 {
155 projectId: ProjectId,
156 projectTitle: Schema.String,
157 workspaceRoot: Schema.String,
158 baseRef: Schema.String,
159 },
160) {
161 override get message(): string {
162 return `Base ref "${this.baseRef}" does not resolve to a commit in project "${this.projectTitle}" (${this.workspaceRoot}).`;
163 }
164}
165
166export class ThreadLaunchService extends Context.Service<
167 ThreadLaunchService,
168 {
169 readonly launch: (
170 input: ThreadLaunchInput,
171 ) => Effect.Effect<
172 ThreadLaunchResult,
173 ThreadLaunchError | ThreadManagement.ThreadManagementProviderInstanceDisabledError
174 >;
175 /**
176 * Fails when a new worktree's base ref cannot give provisioning a commit.
177 * `launch` accepts such a launch and reports the failure inside the
178 * thread; a caller that needs it before a thread exists checks first.
179 */
180 readonly checkWorktreeBase: (
181 input: Pick<ThreadLaunchInput, "projectId" | "workspaceStrategy">,
182 ) => Effect.Effect<void, ThreadLaunchBaseRefError>;
183 /** Dispatches prepared-run.retry and prepares the run's workspace again. */
184 readonly retryPreparation: (input: ThreadLaunchPreparationInput) => Effect.Effect<
185 Orchestrator.OrchestratorV2DispatchResult,
186 // Dispatches prepared-run.retry, a command the enablement chokepoint
187 // never gates; the type stays accurate anyway.
188 | Orchestrator.OrchestratorV2Error
189 | ThreadManagement.ThreadManagementProviderInstanceDisabledError
190 >;
191 readonly prepareRun: (input: ThreadLaunchPreparationInput) => Effect.Effect<void>;
192 }
193>()("t3/orchestration-v2/ThreadLaunchService") {}
194
195const isThreadLaunchError = Schema.is(ThreadLaunchError);
196
197function failureDetail(error: unknown): string {
198 if (isThreadLaunchError(error)) {
199 const cause = error.cause;
200 const detail = cause instanceof Error ? cause.message : String(cause);
201 return `Workspace preparation failed during ${error.operation.replaceAll("-", " ")}: ${detail}`;
202 }
203 return `Workspace preparation failed: ${error instanceof Error ? error.message : String(error)}`;
204}
205
206const make = Effect.gen(function* () {
207 const projects = yield* ProjectService.ProjectService;
208 const setupTracker = yield* WorktreeSetupTracker.WorktreeSetupTracker;
209 const cloneTracker = yield* ProjectCloneTracker.ProjectCloneTracker;
210 const terminals = yield* TerminalManager.TerminalManager;
211 const git = yield* GitWorkflow.GitWorkflowService;
212 const setupScripts = yield* ProjectSetupScriptRunner.ProjectSetupScriptRunner;
213 const providerRegistry = yield* ProviderRegistry.ProviderRegistry;
214 const serverSettings = yield* ServerSettings.ServerSettingsService;
215 const textGeneration = yield* TextGeneration.TextGeneration;
216 const receipts = yield* CommandReceiptStore.CommandReceiptStoreV2;
217 const ids = yield* IdAllocator.IdAllocatorV2;
218 const threads = yield* ThreadManagement.ThreadManagementService;
219 const managedFolders = yield* ManagedProjectFolders.ManagedProjectFolders;
220 const preparationScope = yield* Scope.make("sequential");
221 const scheduledLaunches = yield* Ref.make<ReadonlySet<CommandId>>(new Set());
222 yield* Effect.addFinalizer(() => Scope.close(preparationScope, Exit.void));
223
224 const mapError =
225 (input: PreparationInput, operation: ThreadLaunchError["operation"], threadId?: ThreadId) =>
226 (cause: unknown) =>
227 new ThreadLaunchError({
228 operation,
229 commandId: input.commandId,
230 projectId: input.projectId,
231 ...(threadId === undefined ? {} : { threadId }),
232 cause,
233 });
234
235 const readReceipt = (input: ThreadLaunchInput, commandId: CommandId) =>
236 receipts
237 .getByCommandId(commandId)
238 .pipe(Effect.mapError(mapError(input, "read-receipt", input.threadId)));
239
240 const validateReusableThread = Effect.fn("ThreadLaunchService.validateReusableThread")(function* (
241 input: ThreadLaunchInput,
242 threadId: ThreadId,
243 ) {
244 const projection = yield* threads
245 .getThreadRecords(threadId, ["runs"])
246 .pipe(Effect.mapError(mapError(input, "update-thread", threadId)));
247 if (
248 projection.thread.projectId !== input.projectId ||
249 projection.thread.archivedAt !== null ||
250 projection.thread.deletedAt !== null ||
251 (yield* threads
252 .getMessageCount(threadId)
253 .pipe(Effect.mapError(mapError(input, "update-thread", threadId)))) > 0 ||
254 projection.runs.length > 0
255 ) {
256 return yield* mapError(
257 input,
258 "update-thread",
259 threadId,
260 )("Only an empty active thread in the target project can change workspace during launch.");
261 }
262 });
263
264 const checkWorktreeBase: ThreadLaunchService["Service"]["checkWorktreeBase"] = Effect.fn(
265 "ThreadLaunchService.checkWorktreeBase",
266 )(function* (input) {
267 const strategy = input.workspaceStrategy;
268 // `:/text` is a commit search, which cannot be asked for a commit type.
269 if (strategy.type !== "worktree" || strategy.baseRef.startsWith(":")) return;
270 const { baseRef } = strategy;
271 // Anything this cannot establish is left for the launch to report: a
272 // missing project, a folder that is not a repository, a failing Git.
273 const unresolved = yield* Effect.gen(function* () {
274 const project = Option.getOrUndefined(yield* projects.getById(input.projectId));
275 if (project === undefined) return undefined;
276 const cwd = project.workspaceRoot;
277 // `git worktree add` reads `-` as the previous checkout.
278 if (yield* git.hasCommit({ cwd, refName: baseRef === "-" ? "@{-1}" : baseRef }))
279 return undefined;
280 // Provisioning fetches the base from origin when it can, and runs a
281 // repository with no commits yet without a worktree.
282 if (
283 strategy.startFromOrigin === true &&
284 (yield* git.remoteExists({ cwd, remoteName: "origin" }))
285 )
286 return undefined;
287 // `git worktree add` also starts from a remote-tracking branch of that
288 // name; any ref carrying it is reason enough to let Git decide.
289 if (yield* git.hasRefNamed({ cwd, refName: baseRef })) return undefined;
290 return (yield* git.hasCommit({ cwd, refName: "HEAD" })) ? project : undefined;
291 }).pipe(Effect.orElseSucceed(() => undefined));
292 if (unresolved !== undefined)
293 return yield* new ThreadLaunchBaseRefError({
294 projectId: unresolved.id,
295 projectTitle: unresolved.title,
296 workspaceRoot: unresolved.workspaceRoot,
297 baseRef,
298 });
299 });
300
301 const prepareInBackground = Effect.fn("ThreadLaunchService.prepareInBackground")(function* (
302 input: PreparationInput,
303 threadId: ThreadId,
304 runId: RunId | null,
305 ) {
306 const project = yield* projects.getById(input.projectId).pipe(
307 Effect.mapError(mapError(input, "resolve-project", threadId)),
308 Effect.flatMap(
309 Option.match({
310 onNone: () =>
311 Effect.fail(mapError(input, "resolve-project", threadId)("Project no longer exists.")),
312 onSome: Effect.succeed,
313 }),
314 ),
315 );
316
317 const reused = input.reusedWorktree;
318 const tracked = input.workspaceStrategy.type === "worktree" || reused !== undefined;
319 let createdWorktreePath: string | null = null;
320 let setupTerminalId: string | null = null;
321 let workspaceRecorded = false;
322 let runReleased = false;
323 if (input.workspaceStrategy.type === "worktree") {
324 yield* setupTracker.begin({
325 threadId,
326 branch: input.workspaceStrategy.branch ?? null,
327 baseRef: input.workspaceStrategy.baseRef,
328 stages: ["fetch", "checkout", "setup-script", "agent"],
329 fiber: yield* Effect.fiber,
330 });
331 } else if (reused !== undefined) {
332 yield* setupTracker.begin({
333 threadId,
334 branch: input.workspaceStrategy.branch ?? null,
335 baseRef: reused.baseRef,
336 stages: ["setup-script", "agent"],
337 fiber: yield* Effect.fiber,
338 });
339 }
340 yield* Effect.gen(function* () {
341 const initialMessage = input.initialMessage;
342 const generateBranchNameFor = (cwd: string, message: ThreadLaunchInitialMessage) =>
343 Effect.gen(function* () {
344 const settings = resolveProjectSettings(
345 yield* serverSettings.getSettings,
346 input.projectId,
347 ).settings;
348 const modelSelection =
349 settings.sourceControlWriterModelSelection === null
350 ? settings.textGenerationModelSelection
351 : ServerSettings.resolveSourceControlWriterModelSelection(
352 settings,
353 yield* providerRegistry.getProviders,
354 input.projectId,
355 );
356 return yield* textGeneration
357 .generateBranchName({
358 naming: {
359 mode: settings.branchNamingMode,
360 prefix: settings.branchNamePrefix,
361 instructions: settings.branchNameInstructions,
362 },
363 cwd,
364 message: message.text,
365 attachments: message.attachments,
366 ...(message.context ? { context: message.context } : {}),
367 modelSelection,
368 })
369 .pipe(
370 Effect.map((result) => ({
371 branch: result.branch,
372 exactName: settings.branchNamingMode === "custom",
373 })),
374 );
375 });
376
377 // The server owns worktree naming: without an explicit branch, provision
378 // under a temporary `t3/<hash>` name so the worktree never waits on
379 // name generation, then rename in the background below.
380 const requestedBranch = input.workspaceStrategy.branch;
381 let branch: string | null;
382 if (input.workspaceStrategy.type === "worktree" && requestedBranch === undefined) {
383 const uuid = yield* randomUuidV4;
384 branch = buildTemporaryWorktreeBranchName(() => uuid.replaceAll("-", ""));
385 } else {
386 branch = requestedBranch ?? null;
387 }
388 let worktreePath =
389 input.workspaceStrategy.type === "existing_worktree"
390 ? input.workspaceStrategy.worktreePath
391 : null;
392 if (input.workspaceStrategy.type === "worktree") {
393 if (runId !== null) {
394 yield* threads
395 .dispatch({
396 type: "prepared-run.progress",
397 commandId: CommandId.make(`${input.commandId}:progress:worktree`),
398 threadId,
399 runId,
400 phase: "worktree",
401 })
402 .pipe(Effect.mapError(mapError(input, "update-thread", threadId)));
403 }
404 let startRef = input.workspaceStrategy.baseRef;
405 // "Start from origin" is a stored default; repos without the requested
406 // remote branch fall back to the local base branch.
407 const startFromOrigin =
408 input.workspaceStrategy.startFromOrigin === true &&
409 (yield* git
410 .remoteExists({ cwd: project.workspaceRoot, remoteName: "origin" })
411 .pipe(Effect.mapError(mapError(input, "provision-worktree", threadId))));
412 yield* setupTracker.stageStatus(threadId, "fetch", startFromOrigin ? "running" : "skipped");
413 if (startFromOrigin) {
414 yield* git
415 .fetchRemote({
416 cwd: project.workspaceRoot,
417 remoteName: "origin",
418 refName: input.workspaceStrategy.baseRef,
419 })
420 .pipe(Effect.mapError(mapError(input, "provision-worktree", threadId)));
421 const remoteBaseExists = yield* git
422 .remoteBranchExists({
423 cwd: project.workspaceRoot,
424 refName: input.workspaceStrategy.baseRef,
425 remoteName: "origin",
426 })
427 .pipe(Effect.mapError(mapError(input, "provision-worktree", threadId)));
428 if (remoteBaseExists) {
429 startRef = yield* git
430 .resolveRemoteTrackingCommit({
431 cwd: project.workspaceRoot,
432 refName: input.workspaceStrategy.baseRef,
433 fallbackRemoteName: "origin",
434 })
435 .pipe(
436 Effect.map((resolved) => resolved.commitSha),
437 Effect.mapError(mapError(input, "provision-worktree", threadId)),
438 );
439 }
440 }
441 if (startFromOrigin) yield* setupTracker.stageStatus(threadId, "fetch", "done");
442 if (
443 branch !== null &&
444 isTemporaryWorktreeBranch(branch) &&
445 (yield* git
446 .hasCommit({
447 cwd: project.workspaceRoot,
448 refName: `refs/heads/${WORKTREE_BRANCH_PREFIX}`,
449 })
450 .pipe(Effect.mapError(mapError(input, "provision-worktree", threadId))))
451 ) {
452 branch = flattenTemporaryWorktreeBranchName(branch);
453 }
454 yield* setupTracker.stageStatus(threadId, "checkout", "running");
455 const worktree = yield* git
456 .createWorktree(
457 {
458 cwd: project.workspaceRoot,
459 refName: startRef,
460 newRefName: branch!,
461 baseRefName: input.workspaceStrategy.baseRef,
462 path: null,
463 },
464 {
465 progress: {
466 onWorktreeClaimed: (path) =>
467 Effect.sync(() => {
468 createdWorktreePath = path;
469 }),
470 onCheckoutProgress: (progress) =>
471 setupTracker.stage(threadId, "checkout", { percent: progress.percent }),
472 },
473 },
474 )
475 .pipe(Effect.mapError(mapError(input, "provision-worktree", threadId)));
476 worktreePath = worktree.worktree.path;
477 branch = worktree.worktree.refName;
478 createdWorktreePath = worktreePath;
479 yield* setupTracker.update(threadId, (snapshot) => ({ ...snapshot, worktreePath, branch }));
480 yield* setupTracker.stageStatus(threadId, "checkout", "done");
481 }
482
483 // A reused worktree is already recorded, and rewriting it could undo
484 // the first attempt's branch rename.
485 if (reused === undefined) {
486 yield* threads
487 .dispatch({
488 type: "thread.metadata.update",
489 commandId: CommandId.make(`${input.commandId}:workspace`),
490 threadId,
491 branch,
492 worktreePath,
493 })
494 .pipe(Effect.mapError(mapError(input, "update-thread", threadId)));
495 }
496 workspaceRecorded = true;
497
498 // Rename temporary branches (server-invented above, or sent by clients
499 // that name worktrees themselves) in the background so generation latency
500 // never delays provisioning or the provider turn. The temporary name
501 // simply sticks if generation or the rename fails.
502 if (
503 reused === undefined &&
504 worktreePath !== null &&
505 branch !== null &&
506 initialMessage !== undefined &&
507 isTemporaryWorktreeBranch(branch)
508 ) {
509 const oldBranch = branch;
510 const worktreeCwd = worktreePath;
511 yield* generateBranchNameFor(worktreeCwd, initialMessage).pipe(
512 Effect.flatMap(({ branch: newBranch, exactName }) =>
513 git.renameBranch({
514 cwd: worktreeCwd,
515 oldBranch,
516 newBranch,
517 ...(exactName ? { exactName: true } : {}),
518 }),
519 ),
520 Effect.flatMap((renamed) =>
521 threads.dispatch({
522 type: "thread.metadata.update",
523 commandId: CommandId.make(`${input.commandId}:branch-rename`),
524 threadId,
525 branch: renamed.branch,
526 worktreePath: worktreeCwd,
527 }),
528 ),
529 Effect.catchCause((cause) =>
530 Effect.logWarning("Thread worktree branch rename failed", {
531 commandId: input.commandId,
532 threadId,
533 oldBranch,
534 cause,
535 }),
536 ),
537 Effect.forkIn(preparationScope),
538 );
539 }
540
541 const cwd = worktreePath ?? project.workspaceRoot;
542 if (runId !== null) {
543 yield* threads
544 .dispatch({
545 type: "prepared-run.progress",
546 commandId: CommandId.make(`${input.commandId}:progress:setup`),
547 threadId,
548 runId,
549 phase: "setup",
550 })
551 .pipe(Effect.mapError(mapError(input, "update-thread", threadId)));
552 }
553 yield* setupTracker.stageStatus(threadId, "setup-script", "running");
554 const setup = yield* setupScripts
555 .runForThread({
556 threadId,
557 projectId: input.projectId,
558 projectCwd: project.workspaceRoot,
559 worktreePath: cwd,
560 ...(tracked
561 ? {
562 observeCompletion: {
563 onOutputLine: (line: string) =>
564 setupTracker.appendTail(threadId, "setup-script", line),
565 },
566 }
567 : {}),
568 project: {
569 id: project.id,
570 workspaceRoot: project.workspaceRoot,
571 scripts: project.scripts,
572 },
573 })
574 .pipe(Effect.mapError(mapError(input, "run-setup-script", threadId)));
575
576 let awaitAsyncSetup = Effect.void;
577 if (setup.status === "started") {
578 setupTerminalId = setup.terminalId;
579 yield* setupTracker.update(threadId, (snapshot) => ({
580 ...snapshot,
581 setupScript: {
582 name: setup.scriptName,
583 command: setup.scriptCommand,
584 terminalId: setup.terminalId,
585 },
586 }));
587 if (setup.completion) {
588 const awaitCompletion = Effect.gen(function* () {
589 const completion = yield* setup.completion!;
590 yield* setupTracker.stage(threadId, "setup-script", {
591 status: completion.exitCode === 0 ? "done" : "failed",
592 detail: `exited with ${completion.exitCode ?? "no exit code"}`,
593 });
594 if (completion.exitCode !== 0 && !setup.async)
595 return yield* mapError(
596 input,
597 "run-setup-script",
598 threadId,
599 )(`Setup script exited with ${completion.exitCode ?? "no exit code"}.`);
600 });
601 if (setup.async) {
602 awaitAsyncSetup = awaitCompletion.pipe(
603 Effect.catchCause((cause) =>
604 setupTracker.stage(threadId, "setup-script", {
605 status: "failed",
606 detail: failureDetail(Cause.squash(cause)),
607 }),
608 ),
609 );
610 } else {
611 yield* awaitCompletion;
612 }
613 } else {
614 yield* setupTracker.stageStatus(threadId, "setup-script", "done");
615 }
616 } else {
617 yield* setupTracker.stageStatus(threadId, "setup-script", "skipped");
618 }
619 yield* setupTracker.markUncancellable(threadId);
620 yield* setupTracker.stageStatus(threadId, "agent", "running");
621 if (runId !== null) {
622 yield* threads
623 .dispatch({
624 type: "prepared-run.release",
625 commandId: CommandId.make(`${input.commandId}:release`),
626 threadId,
627 runId,
628 })
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 );
638 }
639 yield* setupTracker.stageStatus(threadId, "agent", "done");
640 yield* awaitAsyncSetup;
641 yield* setupTracker.finish(threadId, "done");
642 }).pipe(
643 Effect.onError((cause) =>
644 Effect.gen(function* () {
645 const cancelled = Cause.hasInterruptsOnly(cause);
646 yield* setupTracker.finish(
647 threadId,
648 cancelled ? "cancelled" : "failed",
649 cancelled ? null : failureDetail(Cause.squash(cause)),
650 );
651 if (cancelled && setupTerminalId)
652 yield* terminals
653 .close({ threadId, terminalId: setupTerminalId, deleteHistory: true })
654 .pipe(Effect.ignore);
655 if (
656 tracked &&
657 createdWorktreePath &&
658 ((cancelled && !runReleased) || !workspaceRecorded)
659 ) {
660 const removedPath = createdWorktreePath;
661 // The thread forgets the worktree only once it is gone; a failed
662 // removal leaves the directory for the user to clean up rather than
663 // reusing a checkout that may be half written.
664 yield* git
665 .removeWorktree({ cwd: project.workspaceRoot, path: removedPath, force: true })
666 .pipe(
667 Effect.andThen(
668 threads
669 .dispatch({
670 type: "thread.metadata.update",
671 commandId: CommandId.make(`${input.commandId}:cancel-workspace`),
672 threadId,
673 worktreePath: null,
674 branch: null,
675 })
676 .pipe(Effect.ignore),
677 ),
678 Effect.catchCause((removeCause) =>
679 Effect.logWarning("Failed to remove an abandoned thread worktree", {
680 commandId: input.commandId,
681 threadId,
682 path: removedPath,
683 cause: removeCause,
684 }),
685 ),
686 );
687 }
688 }),
689 ),
690 );
691 });
692
693 const failPreparedRun = (
694 input: Pick<PreparationInput, "commandId">,
695 threadId: ThreadId,
696 runId: RunId | null,
697 cause: unknown,
698 ) =>
699 runId === null
700 ? Effect.logWarning("Thread workspace preparation failed", {
701 commandId: input.commandId,
702 threadId,
703 cause,
704 })
705 : threads
706 .dispatch({
707 type: "prepared-run.fail",
708 commandId: CommandId.make(`${input.commandId}:fail`),
709 threadId,
710 runId,
711 failure: makeProviderFailure({
712 cause,
713 message: failureDetail(cause),
714 class: "validation_error",
715 retryable: false,
716 }),
717 })
718 .pipe(
719 Effect.catchCause((persistCause) =>
720 Effect.logWarning("Failed to persist thread workspace preparation failure", {
721 commandId: input.commandId,
722 threadId,
723 cause,
724 persistCause,
725 }),
726 ),
727 );
728
729 const reservePreparation = (commandId: CommandId) =>
730 Ref.modify(scheduledLaunches, (scheduled) => {
731 if (scheduled.has(commandId)) return [false, scheduled] as const;
732 const next = new Set(scheduled);
733 next.add(commandId);
734 return [true, next] as const;
735 });
736
737 const releasePreparation = (commandId: CommandId) =>
738 Ref.update(scheduledLaunches, (scheduled) => {
739 const next = new Set(scheduled);
740 next.delete(commandId);
741 return next;
742 });
743
744 const schedulePreparation = Effect.fn("ThreadLaunchService.schedulePreparation")(function* (
745 input: PreparationInput,
746 threadId: ThreadId,
747 runId: RunId | null,
748 ) {
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(
776 Effect.onError((cause) =>
777 failPreparedRun(
778 input,
779 threadId,
780 runId,
781 Cause.hasInterruptsOnly(cause) ? "Worktree setup cancelled." : Cause.squash(cause),
782 ),
783 ),
784 Effect.ignoreCause,
785 Effect.ensuring(releasePreparation(input.commandId)),
786 Effect.forkIn(preparationScope),
787 );
788 });
789
790 const launch: ThreadLaunchService["Service"]["launch"] = Effect.fn("ThreadLaunchService.launch")(
791 function* (input) {
792 yield* ProjectCloneTracker.rejectCommandsDuringClone(cloneTracker, {
793 type: "thread.create",
794 projectId: input.projectId,
795 }).pipe(Effect.mapError(mapError(input, "resolve-project")));
796 const project = yield* projects.getById(input.projectId).pipe(
797 Effect.mapError(mapError(input, "resolve-project")),
798 Effect.flatMap(
799 Option.match({
800 onNone: () => Effect.fail(mapError(input, "resolve-project")("Project not found.")),
801 onSome: Effect.succeed,
802 }),
803 ),
804 );
805 if (input.reuseExistingThread === true && input.threadId === undefined) {
806 return yield* mapError(
807 input,
808 "update-thread",
809 )("Reusing an existing thread requires a thread id.");
810 }
811
812 const launchReceipt = yield* readReceipt(input, input.commandId);
813 yield* threads.assertProviderInstanceEnabled({
814 projectId: input.projectId,
815 commandId: input.commandId,
816 instanceId: input.modelSelection.instanceId,
817 });
818 return yield* Effect.gen(function* () {
819 // A retried launch has no client-supplied id to replay against, so
820 // recover the thread id its accepted create was recorded under before
821 // allocating another one; a fresh id would only collide with the
822 // recorded receipt.
823 const reusableLaunchReceipt =
824 input.threadId === undefined &&
825 Option.isSome(launchReceipt) &&
826 launchReceipt.value.status === "accepted" &&
827 launchReceipt.value.commandType === "thread.create"
828 ? launchReceipt.value
829 : undefined;
830 const candidateThreadId =
831 input.threadId ??
832 reusableLaunchReceipt?.threadId ??
833 (yield* ids.allocate
834 .thread({ projectId: input.projectId })
835 .pipe(Effect.mapError(mapError(input, "create-thread"))));
836
837 if (reusableLaunchReceipt !== undefined) {
838 const shell = yield* threads
839 .getThreadShell(candidateThreadId)
840 .pipe(Effect.mapError(mapError(input, "create-thread", candidateThreadId)));
841 if (shell === null) {
842 return yield* mapError(input, "create-thread", candidateThreadId)("Thread not found.");
843 }
844 if (shell.projectId !== input.projectId) {
845 return yield* mapError(
846 input,
847 "resolve-project",
848 candidateThreadId,
849 )("Project identity changed.");
850 }
851 }
852
853 if (input.reuseExistingThread === true && Option.isNone(launchReceipt)) {
854 yield* validateReusableThread(input, candidateThreadId);
855 }
856
857 // A Scratch thread launched at the project root runs in a folder of its
858 // own. Only the first attempt claims one; a retry replays its create.
859 const workspaceStrategy: ThreadLaunchWorkspaceStrategy =
860 input.workspaceStrategy.type === "root" && Option.isNone(launchReceipt)
861 ? Option.match(
862 yield* managedFolders
863 .folderForThread({
864 projectId: input.projectId,
865 threadId: candidateThreadId,
866 text: input.initialMessage?.text ?? input.title,
867 })
868 .pipe(Effect.mapError(mapError(input, "provision-worktree", candidateThreadId))),
869 {
870 onNone: () => input.workspaceStrategy,
871 onSome: (worktreePath) => ({ type: "existing_worktree", worktreePath }),
872 },
873 )
874 : input.workspaceStrategy;
875 const initialBranch = workspaceStrategy.branch ?? null;
876 const initialWorktreePath =
877 workspaceStrategy.type === "existing_worktree" ? workspaceStrategy.worktreePath : null;
878 const claimDispatch =
879 input.reuseExistingThread === true
880 ? threads.dispatch({
881 type: "thread.metadata.update",
882 commandId: input.commandId,
883 threadId: candidateThreadId,
884 expectedEmpty: true,
885 })
886 : threads.dispatch({
887 type: "thread.create",
888 commandId: input.commandId,
889 threadId: candidateThreadId,
890 projectId: input.projectId,
891 title: input.title,
892 modelSelection: input.modelSelection,
893 runtimeMode: input.runtimeMode,
894 interactionMode: input.interactionMode,
895 branch: initialBranch,
896 worktreePath: initialWorktreePath,
897 ...(input.importedNativeThread === undefined
898 ? {}
899 : { importedNativeThread: input.importedNativeThread }),
900 createdBy: input.createdBy,
901 creationSource: input.creationSource,
902 });
903 const claimed = yield* claimDispatch.pipe(
904 Effect.mapError(
905 mapError(
906 input,
907 input.reuseExistingThread === true ? "update-thread" : "create-thread",
908 candidateThreadId,
909 ),
910 ),
911 );
912 const threadId =
913 claimed.storedEvents.find((stored) => stored.event.type.startsWith("thread."))?.event
914 .threadId ?? candidateThreadId;
915 if (project.id !== input.projectId) {
916 return yield* mapError(input, "resolve-project", threadId)("Project identity changed.");
917 }
918
919 let runId: RunId | null = null;
920 let messageWasAlreadyAccepted = false;
921 if (input.initialMessage !== undefined) {
922 const messageCommandId = CommandId.make(`${input.commandId}:initial-message`);
923 const messageReceipt = yield* readReceipt(input, messageCommandId);
924 messageWasAlreadyAccepted = Option.isSome(messageReceipt);
925 const messageId =
926 input.initialMessage.messageId ??
927 (yield* ids.allocate
928 .message({ threadId, ordinal: 1 })
929 .pipe(Effect.mapError(mapError(input, "dispatch-message", threadId))));
930 const dispatched = yield* threads
931 .dispatch({
932 type: "message.dispatch",
933 commandId: messageCommandId,
934 threadId,
935 messageId,
936 text: input.initialMessage.text,
937 ...(input.initialMessage.scheduledTaskId === undefined
938 ? {}
939 : { scheduledTaskId: input.initialMessage.scheduledTaskId }),
940 ...(input.initialMessage.senderThreadId === undefined
941 ? {}
942 : { senderThreadId: input.initialMessage.senderThreadId }),
943 attachments: input.initialMessage.attachments,
944 ...(input.initialMessage.context ? { context: input.initialMessage.context } : {}),
945 ...(input.generateTitle === true ? { titleSeed: input.title } : {}),
946 modelSelection: input.modelSelection,
947 dispatchMode: { type: "defer_start", workspaceStrategy },
948 createdBy: input.createdBy,
949 creationSource: input.creationSource,
950 })
951 .pipe(Effect.mapError(mapError(input, "dispatch-message", threadId)));
952 const runCreated = dispatched.storedEvents.find(
953 (stored) => stored.event.type === "run.created",
954 );
955 runId = runCreated?.event.type === "run.created" ? runCreated.event.payload.id : null;
956 if (runId === null) {
957 return yield* mapError(
958 input,
959 "dispatch-message",
960 threadId,
961 )("Initial message was accepted without a durable run.");
962 }
963 }
964
965 const projection = yield* threads
966 .getThreadProjection(threadId)
967 .pipe(Effect.mapError(mapError(input, "create-thread", threadId)));
968 const runIsPreparing =
969 runId !== null &&
970 projection.runs.some((run) => run.id === runId && run.status === "preparing");
971 const shouldSchedule = runId === null ? Option.isNone(launchReceipt) : runIsPreparing;
972 // A retried root launch prepares the folder its first attempt bound, so
973 // a Scratch thread keeps its own. Other root launches bind no folder.
974 const boundWorktreePath = projection.thread.worktreePath;
975 const preparationStrategy: ThreadLaunchWorkspaceStrategy =
976 Option.isSome(launchReceipt) &&
977 workspaceStrategy.type === "root" &&
978 boundWorktreePath !== null
979 ? {
980 type: "existing_worktree",
981 worktreePath: boundWorktreePath,
982 branch: workspaceStrategy.branch,
983 }
984 : workspaceStrategy;
985 if (shouldSchedule) {
986 const ownsPreparation = yield* reservePreparation(input.commandId);
987 if (ownsPreparation) {
988 yield* Effect.gen(function* () {
989 const preparationStillRequired =
990 runId === null
991 ? true
992 : yield* threads.getThreadRecords(threadId, ["runs"], { runIds: [runId] }).pipe(
993 Effect.map((current) =>
994 current.runs.some((run) => run.id === runId && run.status === "preparing"),
995 ),
996 Effect.mapError(mapError(input, "update-thread", threadId)),
997 );
998 if (preparationStillRequired) {
999 yield* schedulePreparation(
1000 { ...input, workspaceStrategy: preparationStrategy },
1001 threadId,
1002 runId,
1003 );
1004 } else {
1005 yield* releasePreparation(input.commandId);
1006 }
1007 }).pipe(Effect.onError(() => releasePreparation(input.commandId)));
1008 }
1009 }
1010
1011 return {
1012 threadId,
1013 projection,
1014 resumed: Option.isSome(launchReceipt) || messageWasAlreadyAccepted,
1015 };
1016 });
1017 },
1018 );
1019
1020 const prepareRun: ThreadLaunchService["Service"]["prepareRun"] = Effect.fn(
1021 "ThreadLaunchService.prepareRun",
1022 )(function* (input) {
1023 const scheduled = yield* Effect.gen(function* () {
1024 const projection = yield* threads.getThreadProjection(input.threadId);
1025 const run = projection.runs.find((candidate) => candidate.id === input.runId);
1026 const workspacePreparation = run?.workspacePreparation;
1027 if (run?.status !== "preparing" || workspacePreparation === undefined) return;
1028 if (!(yield* reservePreparation(input.commandId))) return;
1029 yield* scheduleRunPreparation(input, projection, run, workspacePreparation).pipe(
1030 Effect.onError(() => releasePreparation(input.commandId)),
1031 );
1032 }).pipe(Effect.exit);
1033 if (Exit.isFailure(scheduled)) {
1034 yield* failPreparedRun(input, input.threadId, input.runId, Cause.squash(scheduled.cause));
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);
1048 return dispatched;
1049 });
1050
1051 const scheduleRunPreparation = (
1052 input: ThreadLaunchPreparationInput,
1053 projection: OrchestrationV2ThreadProjection,
1054 run: OrchestrationV2ThreadProjection["runs"][number],
1055 workspacePreparation: ThreadLaunchWorkspaceStrategy,
1056 ) => {
1057 const message = projection.messages.find((candidate) => candidate.id === run.userMessageId);
1058 // A worktree the failed attempt already created is reused, not created again.
1059 const reuse =
1060 workspacePreparation.type === "worktree" &&
1061 projection.thread.worktreePath !== null &&
1062 projection.thread.branch !== null
1063 ? {
1064 strategy: {
1065 type: "existing_worktree" as const,
1066 worktreePath: projection.thread.worktreePath,
1067 branch: projection.thread.branch,
1068 },
1069 reusedWorktree: { baseRef: workspacePreparation.baseRef },
1070 }
1071 : null;
1072 return schedulePreparation(
1073 {
1074 commandId: input.commandId,
1075 projectId: projection.thread.projectId,
1076 workspaceStrategy: reuse?.strategy ?? workspacePreparation,
1077 ...(reuse === null ? {} : { reusedWorktree: reuse.reusedWorktree }),
1078 ...(message === undefined
1079 ? {}
1080 : {
1081 initialMessage: {
1082 text: message.text,
1083 attachments: message.attachments,
1084 ...(message.context ? { context: message.context } : {}),
1085 },
1086 }),
1087 },
1088 input.threadId,
1089 run.id,
1090 );
1091 };
1092
1093 return ThreadLaunchService.of({ launch, checkWorktreeBase, retryPreparation, prepareRun });
1094});
1095
1096export const layer = Layer.effect(ThreadLaunchService, make);