diff --git a/apps/server/src/auth/RpcAuthorization.ts b/apps/server/src/auth/RpcAuthorization.ts index ea1ddd659d30c..3dad25463385a 100644 --- a/apps/server/src/auth/RpcAuthorization.ts +++ b/apps/server/src/auth/RpcAuthorization.ts @@ -40,6 +40,7 @@ type WsRpcMethod = RpcGroup.Rpcs["_tag"]; export const RPC_REQUIRED_SCOPES = { [WS_METHODS.peerLinksList]: AuthAccessReadScope, ...CLIENT_GUARDED_RPC_SCOPES, + [WS_METHODS.threadHandoffOptions]: AuthOrchestrationReadScope, [ORCHESTRATION_V2_WS_METHODS.dispatchCommand]: AuthOrchestrationOperateScope, [ORCHESTRATION_V2_WS_METHODS.getWorkflowScript]: AuthOrchestrationReadScope, [ORCHESTRATION_V2_WS_METHODS.stopWorkflow]: AuthOrchestrationOperateScope, diff --git a/apps/server/src/environment/ServerEnvironment.ts b/apps/server/src/environment/ServerEnvironment.ts index 0d97709aed5d9..d55d75608c7da 100644 --- a/apps/server/src/environment/ServerEnvironment.ts +++ b/apps/server/src/environment/ServerEnvironment.ts @@ -220,6 +220,7 @@ export const make = Effect.gen(function* () { mcpModeLimitHeader: true, linkFence: true, threadImport: true, + threadHandoff: true, connectionProbe: true, attachmentUploads: true, questionAttachments: true, diff --git a/apps/server/src/mcp/McpHttpServer.ts b/apps/server/src/mcp/McpHttpServer.ts index 922a0d93ddbe4..9ce0b23e5b0f8 100644 --- a/apps/server/src/mcp/McpHttpServer.ts +++ b/apps/server/src/mcp/McpHttpServer.ts @@ -802,6 +802,7 @@ const HANDOFF_TOOLS: ReadonlySet = new Set([ "create_threads", "t3_thread_launch", "t3_thread_import", + "t3_thread_handoff", "t3_thread_send", "t3_thread_send_attachments", "t3_thread_fork", diff --git a/apps/server/src/mcp/OrchestratorMcpService.ts b/apps/server/src/mcp/OrchestratorMcpService.ts index 7ec05b26b9b09..1d4a31cfe380a 100644 --- a/apps/server/src/mcp/OrchestratorMcpService.ts +++ b/apps/server/src/mcp/OrchestratorMcpService.ts @@ -778,6 +778,7 @@ function threadDetail( interactionMode: projection.thread.interactionMode, linkedPullRequest: projection.thread.linkedPullRequest ?? null, pullRequests: threadPullRequests(projection.thread.pullRequests), + handoff: projection.thread.handoff ?? null, titleRegeneration: projection.thread.titleRegeneration === undefined || projection.thread.titleRegeneration === null diff --git a/apps/server/src/mcp/toolkits/project/handlers.ts b/apps/server/src/mcp/toolkits/project/handlers.ts index 5da1dc3cbc10f..04b7d7819ab05 100644 --- a/apps/server/src/mcp/toolkits/project/handlers.ts +++ b/apps/server/src/mcp/toolkits/project/handlers.ts @@ -9,6 +9,7 @@ import * as Effect from "effect/Effect"; import * as FileSystem from "effect/FileSystem"; import * as Option from "effect/Option"; import * as ThreadImport from "../../../orchestration-v2/ThreadImportService.ts"; +import * as HandoffImport from "../../../peer/handoff/HandoffImport.ts"; import * as ThreadLaunch from "../../../orchestration-v2/ThreadLaunchService.ts"; import * as ThreadMessageIntake from "../../../orchestration-v2/ThreadMessageIntake.ts"; import * as Claims from "../../../orchestration-v2/AttachmentClaims.ts"; @@ -62,14 +63,36 @@ export const layer = McpToolAccess.toLayer(ProjectToolkit, { (input) => input, (input, modes) => Effect.gen(function* () { - const imports = yield* ThreadImport.ThreadImportService; - return yield* imports.importThread({ ...input, ...modes }).pipe( + const handoff = yield* Effect.serviceOption(HandoffImport.HandoffImport); + const imports = yield* Effect.serviceOption(ThreadImport.ThreadImportService); + if (Option.isNone(handoff) && (input.bundle !== undefined || Option.isNone(imports))) + return yield* new OrchestratorMcpFailure({ + code: "invalid_request", + message: "This server cannot receive handoff bundles.", + }); + const accept = Option.isSome(handoff) + ? handoff.value.importThread({ ...input, ...modes }) + : Option.isSome(imports) + ? imports.value.importThread({ ...input, ...modes }) + : Effect.die("unreachable import service"); + return yield* accept.pipe( Effect.mapError( (error) => new OrchestratorMcpFailure({ code: - error._tag === "ThreadImportError" ? "orchestration_error" : "invalid_request", - message: error.message, + error._tag === "ThreadImportError" || error._tag === "HandoffImportOperationError" + ? "orchestration_error" + : "invalid_request", + message: + error._tag === "HandoffImportGitRefusedError" + ? error.cause.message + : error.message, + ...(error._tag === "HandoffImportRefusedError" + ? { handoffNotAccepted: error.notAccepted } + : error._tag === "HandoffImportProviderDisabledError" || + error._tag === "HandoffImportGitRefusedError" + ? { handoffNotAccepted: { source: error.source, projectId: error.projectId } } + : {}), }), ), ); @@ -228,11 +251,7 @@ export const layer = McpToolAccess.toLayer(ProjectToolkit, { t3_project_list: McpToolAccess.reads((input) => Effect.gen(function* () { const projects = yield* access; - const snapshot = yield* projects.snapshot.pipe(Effect.mapError(unavailable)); - const rows = snapshot.projects.filter((project) => project.deletedAt === null); - const start = input.cursor ?? 0, - end = start + (input.limit ?? 20); - return { projects: rows.slice(start, end), nextCursor: end < rows.length ? end : null }; + return yield* projects.list(input).pipe(Effect.mapError(unavailable)); }), ), t3_project_read: McpToolAccess.reads((input) => diff --git a/apps/server/src/mcp/toolkits/project/tools.ts b/apps/server/src/mcp/toolkits/project/tools.ts index 9db1ceedcdc5e..d6ab5634966f7 100644 --- a/apps/server/src/mcp/toolkits/project/tools.ts +++ b/apps/server/src/mcp/toolkits/project/tools.ts @@ -24,12 +24,12 @@ import { import * as FileSystem from "effect/FileSystem"; import * as ServerConfig from "../../../config.ts"; import * as ServerSettings from "../../../serverSettings.ts"; -import * as ThreadImportService from "../../../orchestration-v2/ThreadImportService.ts"; import * as ThreadLaunchService from "../../../orchestration-v2/ThreadLaunchService.ts"; import * as Crypto from "effect/Crypto"; import * as Schema from "effect/Schema"; import { Tool, Toolkit } from "effect/ai"; import * as ProjectService from "../../../project/ProjectService.ts"; +import * as RepositoryIdentityResolver from "../../../project/RepositoryIdentityResolver.ts"; import * as ManagedProjectFolders from "../../../project/ManagedProjectFolders.ts"; import * as ThreadManagementService from "../../../orchestration-v2/ThreadManagementService.ts"; import * as SourceControlRepositoryService from "../../../sourceControl/SourceControlRepositoryService.ts"; @@ -49,12 +49,22 @@ const shared = { }; const ProjectListTool = Tool.make("t3_project_list", { ...shared, + dependencies: [ + McpInvocationContext.McpInvocationContext, + ThreadManagementService.ThreadManagementService, + ProjectService.ProjectService, + RepositoryIdentityResolver.RepositoryIdentityResolver, + ], description: "List registered projects locally or in a linked environment with environmentId. Pages use the current project snapshot and may shift between calls.", parameters: Schema.Struct({ environmentId: OrchestratorMcpEnvironmentTarget, cursor: Schema.optional(NonNegativeInt), limit: Schema.optional(Schema.Int.check(Schema.isBetween({ minimum: 1, maximum: 100 }))), + resolveRepositoryIdentity: Schema.optional(Schema.Boolean).annotate({ + description: + "Wait for repository identities on this page when matching a handoff destination.", + }), }), success: Schema.Struct({ projects: Schema.Array(Project), @@ -65,6 +75,11 @@ const ProjectListTool = Tool.make("t3_project_list", { .annotate(Tool.Destructive, false); const ProjectReadTool = Tool.make("t3_project_read", { ...shared, + dependencies: [ + McpInvocationContext.McpInvocationContext, + ThreadManagementService.ThreadManagementService, + ProjectService.ProjectService, + ], description: "Read a registered project locally or in a linked environment with environmentId, including its workspace and saved scripts.", parameters: Schema.Struct({ @@ -183,7 +198,10 @@ const ThreadImportTool = Tool.make("t3_thread_import", { environmentId: OrchestratorMcpEnvironmentTarget, }), success: OrchestratorMcpThreadImportResult, - dependencies: [...shared.dependencies, ThreadImportService.ThreadImportService], + dependencies: [ + McpInvocationContext.McpInvocationContext, + ThreadManagementService.ThreadManagementService, + ], }) .annotate(Tool.Destructive, true) .annotate(Tool.OpenWorld, true); diff --git a/apps/server/src/mcp/toolkits/thread/handlers.ts b/apps/server/src/mcp/toolkits/thread/handlers.ts index e1bf858ca8fb6..0d9af7bd60d01 100644 --- a/apps/server/src/mcp/toolkits/thread/handlers.ts +++ b/apps/server/src/mcp/toolkits/thread/handlers.ts @@ -8,8 +8,11 @@ import { type OrchestrationV2Command, } from "@t3tools/contracts"; import * as Effect from "effect/Effect"; +import * as Option from "effect/Option"; import { modelSelectionCommandType } from "@t3tools/shared/model"; +import * as ThreadHandoff from "../../../peer/handoff/ThreadHandoff.ts"; +import * as McpInvocationContext from "../../McpInvocationContext.ts"; import * as McpToolAccess from "../../McpToolAccess.ts"; import { dispatchFailure, @@ -75,6 +78,27 @@ const writesThread =

McpToolAccess.writesThreads((params: P) => [params.threadId], handle); export const layer = McpToolAccess.toLayer(ThreadToolkit, { + t3_thread_handoff: writesThread((input) => + Effect.gen(function* () { + const scope = yield* McpInvocationContext.McpInvocationContext; + const handoff = yield* Effect.serviceOption(ThreadHandoff.ThreadHandoff); + if (Option.isNone(handoff)) + return yield* new OrchestratorMcpFailure({ + code: "invalid_request", + message: "This server does not support thread handoff.", + }); + return yield* handoff.value.start({ ...input, callerThreadId: scope.thread?.threadId }).pipe( + Effect.mapError( + (cause) => + new OrchestratorMcpFailure({ + code: cause.reason === "failed" ? "orchestration_error" : "invalid_request", + message: cause.message, + }), + ), + ); + }), + ), + run_scheduled_task_now: McpToolAccess.writesEnvironment((input) => Effect.gen(function* () { const scheduler = yield* ScheduledTasks.ScheduledTaskService; diff --git a/apps/server/src/mcp/toolkits/thread/tools.ts b/apps/server/src/mcp/toolkits/thread/tools.ts index 6b605dc9b5e7b..daf550f311f6b 100644 --- a/apps/server/src/mcp/toolkits/thread/tools.ts +++ b/apps/server/src/mcp/toolkits/thread/tools.ts @@ -1,5 +1,7 @@ import { OrchestratorMcpEnvironmentTarget, + OrchestratorMcpThreadHandoffInput, + OrchestratorMcpThreadHandoffResult, ScheduledTaskId, ScheduledTask, OrchestrationSearchThreadsInput, @@ -308,7 +310,23 @@ const ScheduledTaskRunTool = Tool.make("run_scheduled_task_now", { .annotate(Tool.Destructive, true) .annotate(Tool.OpenWorld, true); +const ThreadHandoffTool = Tool.make("t3_thread_handoff", { + description: + "Move a thread's conversation and Git work to a linked environment. Omit threadId to move your own thread after its active turn fully finishes. New instructions cancel a pending move. Pass projectId from that environment or omit it to match the repository. Read current handoff metadata with t3_thread_read. Retry a departing move with its handoffId and saved environmentId/projectId, omitting continuationPrompt; retries preserve the original request. The same ID after departure returns its saved destination.", + parameters: OrchestratorMcpThreadHandoffInput, + success: OrchestratorMcpThreadHandoffResult, + failure: OrchestratorMcpFailure, + failureMode: "return" as const, + dependencies: [ + McpInvocationContext.McpInvocationContext, + ThreadManagementService.ThreadManagementService, + ], +}) + .annotate(Tool.Destructive, true) + .annotate(Tool.OpenWorld, true); + export const ThreadToolkit = Toolkit.make( + ThreadHandoffTool, ScheduledTaskRunTool, ThreadSearchTool, ThreadForkTool, diff --git a/apps/server/src/mcp/toolkits/worktree/registration.test.ts b/apps/server/src/mcp/toolkits/worktree/registration.test.ts index 25558d2ad3ccc..eddafdb028492 100644 --- a/apps/server/src/mcp/toolkits/worktree/registration.test.ts +++ b/apps/server/src/mcp/toolkits/worktree/registration.test.ts @@ -16,6 +16,7 @@ import * as GitWorkflowService from "../../../git/GitWorkflowService.ts"; import * as ProviderAdapterRegistry from "../../../orchestration-v2/ProviderAdapterRegistry.ts"; import * as ThreadManagementService from "../../../orchestration-v2/ThreadManagementService.ts"; import * as ProjectService from "../../../project/ProjectService.ts"; +import * as RepositoryIdentityResolver from "../../../project/RepositoryIdentityResolver.ts"; import * as PullRequestReviewGuideService from "../../../pullRequest/PullRequestReviewGuideService.ts"; import * as ProjectSetupScriptRunner from "../../../project/ProjectSetupScriptRunner.ts"; import * as ProviderRegistry from "../../../provider/ProviderRegistry.ts"; @@ -47,6 +48,7 @@ const layerStubServices = Layer.mergeAll( Layer.mock(ScheduledTaskService.ScheduledTaskService)({}), Layer.mock(SecretRequests.SecretRequests)({}), Layer.mock(ProjectService.ProjectService)({}), + Layer.mock(RepositoryIdentityResolver.RepositoryIdentityResolver)({}), Layer.mock(PullRequestReviewGuideService.PullRequestReviewGuideService)({}), ServerSettings.layerTest({}), Layer.mock(GitWorkflowService.GitWorkflowService)({}), diff --git a/apps/server/src/observability/RpcInstrumentation.ts b/apps/server/src/observability/RpcInstrumentation.ts index ae255dfbdeaaf..351b3e5bbddc5 100644 --- a/apps/server/src/observability/RpcInstrumentation.ts +++ b/apps/server/src/observability/RpcInstrumentation.ts @@ -14,6 +14,9 @@ type WsRpcMethod = RpcGroup.Rpcs["_tag"]; * even where they differ from the method prefix. */ const RPC_AGGREGATES = { + [WS_METHODS.threadHandoffOptions]: "threadHandoff", + [WS_METHODS.threadHandoffStart]: "threadHandoff", + [WS_METHODS.threadHandoffCancel]: "threadHandoff", [WS_METHODS.peerLinksList]: "peerLinks", [WS_METHODS.peerLinksLink]: "peerLinks", [WS_METHODS.peerLinksUnlink]: "peerLinks", diff --git a/apps/server/src/orchestration-v2/Orchestrator.ts b/apps/server/src/orchestration-v2/Orchestrator.ts index 1722532a61f75..9168fcef09e7f 100644 --- a/apps/server/src/orchestration-v2/Orchestrator.ts +++ b/apps/server/src/orchestration-v2/Orchestrator.ts @@ -451,6 +451,7 @@ function commandThreadId(command: OrchestrationV2ServerCommand): ThreadId { case "thread.active.reorder": case "thread.visit": case "thread.mark-unread": + case "thread.handoff.update": case "thread.metadata.update": case "thread.pull-request.link": case "thread.pull-request.unlink": @@ -1289,6 +1290,8 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio if ( projection.thread.archivedAt !== null || projection.thread.deletedAt !== null || + (projection.thread.handoff != null && + ["pending", "departing", "departed"].includes(projection.thread.handoff.state)) || projection.runs.some(isBlockingRun) || projection.runs.some((run) => run.status === "queued" && run.queueHeld === true) ) { @@ -4611,6 +4614,35 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio ) => Effect.gen(function* () { let projection = yield* getProjectionWithPendingEvents(command.threadId, events); + const handoff = projection.thread.handoff; + if (handoff?.state === "departing" || handoff?.state === "departed") { + return yield* new OrchestratorDispatchError({ + commandId: command.commandId, + commandType: command.type, + cause: `Thread handoff is ${handoff.state}; continue in the destination thread.`, + }); + } + if ( + handoff?.state === "pending" && + command.notification === undefined && + command.delegatedCompletion === undefined && + command.scheduledTaskId === undefined && + command.restartContinuationOfRunId === undefined && + (command.createdBy === "user" || command.creationSource === "mcp") + ) { + // The command lock orders this after the request, even when the clock has not advanced. + yield* emit( + events, + command, + )({ + type: "thread.metadata-updated", + threadId: command.threadId, + occurredAt: yield* DateTime.now, + payload: { ...projection.thread, handoff: null }, + }); + projection = yield* getProjectionWithPendingEvents(command.threadId, events); + } + if (command.manualContinuationOfRunId !== undefined) { const source = projection.runs.find((run) => run.id === command.manualContinuationOfRunId); const limited = latestRootProviderFailure(source ?? null, projection.turnItems); @@ -7725,6 +7757,26 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio .pipe( Effect.mapError(() => new OrchestratorProjectionError({ threadId: command.threadId })), ); + if ( + projection.thread.handoff?.state === "departing" || + projection.thread.handoff?.state === "departed" + ) + return yield* new OrchestratorDispatchError({ + commandId: command.commandId, + commandType: command.type, + cause: "Thread handoff has fenced provider work.", + }); + if (projection.thread.handoff?.state === "pending") { + yield* emit( + events, + command, + )({ + type: "thread.metadata-updated", + threadId: command.threadId, + occurredAt: yield* DateTime.now, + payload: { ...projection.thread, handoff: null }, + }); + } if (projection.thread.archivedAt !== null || projection.thread.deletedAt !== null) { return yield* new OrchestratorDispatchError({ commandId: command.commandId, @@ -8116,7 +8168,7 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio }); const loadProjectionForCommand = ( - command: OrchestrationV2Command, + command: OrchestrationV2ServerCommand, fields: ReadonlyArray, filter?: ProjectionRecordFilter, ) => @@ -8218,6 +8270,15 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio ["runs", "attempts", "nodes", "providerThreads", "turnItems"], { turnItemTypes: ["command_execution"], turnItemRunId: command.runId }, ); + if ( + projection.thread.handoff?.state === "departing" || + projection.thread.handoff?.state === "departed" + ) + return yield* new OrchestratorDispatchError({ + commandId: command.commandId, + commandType: command.type, + cause: "Thread handoff has fenced provider work.", + }); const state = preparedRunState(command, projection); if (state === null) { return yield* new OrchestratorDispatchError({ @@ -10973,6 +11034,29 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio "orchestration_v2.thread_id": commandThreadId(command), }); + // These paths can start provider work or restore source files without a new message. + if ( + [ + "prepared-run.retry", + "checkpoint.rollback", + "delegated_task.request", + "thread.fork", + "thread.merge_back", + "provider.switch", + ].includes(command.type) + ) { + const threadId = + command.type === "thread.fork" ? command.sourceThreadId : commandThreadId(command); + const thread = yield* projectionStore + .getThread(threadId) + .pipe(Effect.mapError((cause) => new OrchestratorProjectionError({ threadId, cause }))); + if (thread.handoff?.state === "departing" || thread.handoff?.state === "departed") + return yield* new OrchestratorDispatchError({ + commandId: command.commandId, + commandType: command.type, + cause: "Thread handoff has fenced source work.", + }); + } const events = yield* Ref.make>([]); const effects = yield* Ref.make>([]); let cancelUnsettledEffects: @@ -11138,6 +11222,67 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio }), ); } + case "thread.handoff.update": { + const projection = yield* loadProjectionForCommand(command, ["runs"]); + const current = projection.thread.handoff; + const next = command.handoff; + const expected = command.expected; + const live = projection.runs.some(isBlockingRun); + const queued = projection.runs.some((run) => run.status === "queued"); + const ownActive = command.callerThreadId === command.threadId && live; + const matches = + expected === null + ? current == null + : current?.state === expected.state && current.handoffId === expected.handoffId; + const initial = current == null || current.state === "failed"; + const sameMove = + current == null || + current.state === "failed" || + next === null || + (current.handoffId === next.handoffId && + current.environmentId === next.environmentId && + current.projectId === next.projectId && + current.label === next.label); + const eligible = + projection.thread.deletedAt === null && + projection.thread.archivedAt === null && + projection.thread.lineage.relationshipToParent !== "subagent"; + const allowed = + matches && + sameMove && + (next === null + ? current?.state === "pending" + : next.state === "pending" + ? eligible && initial && ownActive && next.requestCommandId === command.commandId + : next.state === "departing" + ? eligible && + !live && + ((initial && !queued) || + (current?.state === "pending" && current.handoffId === next.handoffId)) + : (next.state === "departed" || next.state === "failed") && + current?.state === "departing" && + current.handoffId === next.handoffId); + if (!allowed) + return yield* new OrchestratorDispatchError({ + commandId: command.commandId, + commandType: command.type, + cause: + "The thread handoff changed or the thread still has work; retry from its current state.", + }); + yield* emit( + events, + command, + )({ + type: "thread.metadata-updated", + threadId: command.threadId, + occurredAt: yield* DateTime.now, + payload: { + ...projection.thread, + handoff: next, + }, + }); + break; + } case "thread.archive": case "thread.unarchive": case "thread.settle": @@ -11394,7 +11539,17 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio }), ), ); - if (command.type === "queue.resume") { + if ( + command.type === "queue.resume" || + ((command.type === "message.dispatch" || + command.type === "queued-message.promote-to-steer") && + storedEvents.some( + ({ event }) => + event.type === "thread.metadata-updated" && event.payload.handoff === null, + )) || + (command.type === "thread.handoff.update" && + (command.handoff === null || command.handoff.state === "failed")) + ) { yield* mapDispatchError(command)(startNextQueuedRun(command.threadId)); } return { @@ -11527,7 +11682,16 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio detail: committed.receipt.error ?? "Previously rejected.", }); } - if (command.type === "queue.resume") { + if ( + command.type === "queue.resume" || + ((command.type === "message.dispatch" || + command.type === "queued-message.promote-to-steer") && + plan.events.some( + (event) => event.type === "thread.metadata-updated" && event.payload.handoff === null, + )) || + (command.type === "thread.handoff.update" && + (command.handoff === null || command.handoff.state === "failed")) + ) { yield* mapDispatchError(command)(startNextQueuedRun(command.threadId)); } if (command.type === "notification.delivery.accept") { diff --git a/apps/server/src/orchestration-v2/ProjectionStore.test.ts b/apps/server/src/orchestration-v2/ProjectionStore.test.ts index 74377160a8204..6e73bbe85a0c7 100644 --- a/apps/server/src/orchestration-v2/ProjectionStore.test.ts +++ b/apps/server/src/orchestration-v2/ProjectionStore.test.ts @@ -183,6 +183,108 @@ const addRolledBackRecoveryCandidate = Effect.fn("addRolledBackRecoveryCandidate return threadId; }); +it.effect.each([ + ["memory", ProjectionStore.layerMemory], + ["SQL", layerTest], +] as const)( + "%s message tails page eligible history with bounded text and stable timestamp ties", + ([, layer]) => + Effect.gen(function* () { + const store = yield* ProjectionStore.ProjectionStoreV2; + const threadId = yield* addRolledBackRecoveryCandidate("message-tail"); + const otherThreadId = yield* addRolledBackRecoveryCandidate("message-tail:other"); + const runId = (yield* store.getThreadProjection(threadId)).runs[0]!.id; + const now = yield* DateTime.now; + const eligible = Array.from({ length: 16 }, (_, index) => ({ + id: MessageId.make(`message:tail:${String((index + 8) % 16).padStart(2, "0")}`), + threadId, + runId, + role: index % 2 === 0 ? ("user" as const) : ("assistant" as const), + text: + index === 14 + ? "x".repeat(200_001) + : index === 15 + ? "😀".repeat(100_001) + : `text ${index}`, + createdAt: index < 8 ? DateTime.subtract(now, { milliseconds: 1 }) : now, + })); + const latest = eligible.at(-1)!; + const excluded = [ + { ...latest, id: MessageId.make("message:tail:system"), role: "system" as const }, + { ...latest, id: MessageId.make("message:tail:run"), runId: RunId.make("run:other") }, + { ...latest, id: MessageId.make("message:tail:thread"), threadId: otherThreadId }, + ].map((message) => ({ ...message, text: "excluded" })); + const omitted = { ...latest, id: MessageId.make("message:tail:omitted"), text: "omitted" }; + // Arrival order differs from chronology and the cursor's same-timestamp id order. + for (const message of [...eligible.toReversed(), ...excluded, omitted]) { + yield* store.apply({ + id: EventId.make(`event:${message.id}`), + type: "message.updated", + threadId: message.threadId, + runId: message.runId, + occurredAt: now, + payload: { + ...message, + createdBy: "user", + creationSource: "web", + nodeId: null, + attachments: [], + streaming: false, + updatedAt: now, + }, + }); + } + const filter = { + messageIds: [...eligible, ...excluded].map((message) => message.id), + messageRoles: ["user", "assistant"], + messageRunIds: [runId], + } satisfies ProjectionStore.ProjectionRecordFilter; + let before: NonNullable["before"]; + const collected: Array = []; + for (const end of [16, 11, 6, 1, 0]) { + const { messages } = yield* store.getThreadRecords(threadId, ["messages"], { + ...filter, + messageTail: { limit: 5, maxTextLength: 200_000, ...(before ? { before } : {}) }, + }); + const expected = eligible.slice(Math.max(0, end - 5), end); + assert.deepEqual( + messages.map(({ id, text }) => ({ id, text })), + expected.map(({ id, text }) => ({ id, text: text.slice(0, 200_000) })), + ); + collected.unshift(...messages.map((message) => message.id)); + const first = messages[0]; + if (first) before = { createdAt: first.createdAt, messageId: first.id }; + } + assert.deepEqual( + collected, + eligible.map((message) => message.id), + ); + const unicode = yield* store.getThreadRecords(threadId, ["messages"], { + ...filter, + messageTail: { limit: 1, maxTextLength: 3 }, + }); + assert.deepEqual( + unicode.messages.map(({ id, text }) => ({ id, text })), + [{ id: latest.id, text: latest.text.slice(0, 3) }], + ); + assert.isEmpty( + (yield* store.getThreadRecords(threadId, ["messages"], { + ...filter, + messageRunIds: [RunId.make("run:missing")], + messageTail: { limit: 5, maxTextLength: 3 }, + })).messages, + ); + const full = yield* store.getThreadRecords(threadId, ["messages"], filter); + assert.equal(full.messages.length, eligible.length); + for (const expected of eligible) { + assert.equal( + full.messages.find((message) => message.id === expected.id)?.text, + expected.text, + ); + } + }).pipe(Effect.provide(layer)), +); + it.effect.each([ ["memory", ProjectionStore.layerMemory], ["SQL", layerTest], diff --git a/apps/server/src/orchestration-v2/ProjectionStore.ts b/apps/server/src/orchestration-v2/ProjectionStore.ts index ac8511bc02940..0cee2a9bf8395 100644 --- a/apps/server/src/orchestration-v2/ProjectionStore.ts +++ b/apps/server/src/orchestration-v2/ProjectionStore.ts @@ -288,6 +288,12 @@ export interface ProjectionRuntimeResponseContext { } export interface ProjectionRecordFilter { + readonly messageTail?: { + readonly limit: number; + readonly maxTextLength: number; + readonly before?: { readonly createdAt: DateTime.Utc; readonly messageId: MessageId }; + readonly throughRunId?: RunId; + }; readonly messageRoles?: ReadonlyArray; readonly turnItemRunId?: RunId; readonly messageIds?: ReadonlyArray; @@ -1508,6 +1514,7 @@ export function threadShellFromProjection( interactionMode: projection.thread.interactionMode, branch: projection.thread.branch, worktreePath: projection.thread.worktreePath, + handoff: projection.thread.handoff, pullRequests: threadPullRequestsOf(projection.thread), ...(projection.thread.linkedPullRequest === undefined ? {} @@ -1768,6 +1775,7 @@ function shellFromState(input: { interactionMode: input.state.thread.interactionMode, branch: input.state.thread.branch, worktreePath: input.state.thread.worktreePath, + handoff: input.state.thread.handoff, pullRequests: threadPullRequestsOf(input.state.thread), ...(input.state.thread.linkedPullRequest === undefined ? {} @@ -3213,14 +3221,52 @@ export const layer: Layer.Layer = matchesNothing(filter?.messageIds, filter?.messageRoles, filter?.messageRunIds)) ? Effect.succeed([]) : window === undefined - ? sql` - SELECT payload_json FROM orchestration_v2_projection_messages - WHERE thread_id = ${threadId} - ${filter?.messageIds === undefined ? sql`` : sql`AND message_id IN (SELECT value FROM json_each(${encodeIdList(filter.messageIds)}))`} - ${filter?.messageRoles === undefined ? sql`` : sql`AND role IN (SELECT value FROM json_each(${encodeIdList(filter.messageRoles)}))`} - ${filter?.messageRunIds === undefined ? sql`` : sql`AND run_id IN (SELECT value FROM json_each(${encodeIdList(filter.messageRunIds)}))`} - ORDER BY created_at ASC, message_id ASC - ` + ? filter?.messageTail === undefined + ? sql` + SELECT payload_json FROM orchestration_v2_projection_messages + WHERE thread_id = ${threadId} + ${filter?.messageIds === undefined ? sql`` : sql`AND message_id IN (SELECT value FROM json_each(${encodeIdList(filter.messageIds)}))`} + ${filter?.messageRoles === undefined ? sql`` : sql`AND role IN (SELECT value FROM json_each(${encodeIdList(filter.messageRoles)}))`} + ${filter?.messageRunIds === undefined ? sql`` : sql`AND run_id IN (SELECT value FROM json_each(${encodeIdList(filter.messageRunIds)}))`} + ORDER BY created_at ASC, message_id ASC + ` + : sql` + SELECT payload_json FROM ( + SELECT json_set(payload_json, '$.text', substr(json_extract(payload_json, '$.text'), 1, ${filter.messageTail.maxTextLength})) AS payload_json + FROM orchestration_v2_projection_messages + WHERE thread_id = ${threadId} + ${ + filter.messageTail.throughRunId === undefined + ? sql`` + : sql`AND EXISTS ( + SELECT 1 FROM orchestration_v2_projection_runs AS boundary + WHERE boundary.thread_id = ${threadId} AND boundary.run_id = ${filter.messageTail.throughRunId} + AND ( + (orchestration_v2_projection_messages.run_id IS NULL AND json_extract(${threadRow.payload_json}, '$.historyOrigin') = 'v1_import') + OR EXISTS ( + SELECT 1 FROM orchestration_v2_projection_runs AS source_run + WHERE source_run.thread_id = ${threadId} + AND source_run.run_id = orchestration_v2_projection_messages.run_id + AND source_run.ordinal <= boundary.ordinal + ) + ) + )` + } + ${ + filter.messageTail.before === undefined + ? sql`` + : sql`AND ( + created_at < ${DateTime.formatIso(filter.messageTail.before.createdAt)} OR + (created_at = ${DateTime.formatIso(filter.messageTail.before.createdAt)} AND message_id < ${filter.messageTail.before.messageId}) + )` + } + ${filter.messageIds === undefined ? sql`` : sql`AND message_id IN (SELECT value FROM json_each(${encodeIdList(filter.messageIds)}))`} + ${filter.messageRoles === undefined ? sql`` : sql`AND role IN (SELECT value FROM json_each(${encodeIdList(filter.messageRoles)}))`} + ${filter.messageRunIds === undefined ? sql`` : sql`AND run_id IN (SELECT value FROM json_each(${encodeIdList(filter.messageRunIds)}))`} + ORDER BY created_at DESC, message_id DESC + LIMIT ${filter.messageTail.limit} + ) ORDER BY json_extract(payload_json, '$.createdAt') ASC, json_extract(payload_json, '$.id') ASC + ` : sql` SELECT payload_json FROM orchestration_v2_projection_messages AS message WHERE message.thread_id = ${threadId} @@ -3358,7 +3404,12 @@ export const layer: Layer.Layer = decodeRows(decodeContextTransferPayload, threadId)(contextTransferRows), ]); const messageOrdinals = new Map(); - if (fields !== undefined && fields.includes("messages") && messages.length > 1) { + if ( + filter?.messageTail === undefined && + fields !== undefined && + fields.includes("messages") && + messages.length > 1 + ) { const rows = yield* sql<{ message_id: string; ordinal: number }>` SELECT json_extract(payload_json, '$.messageId') AS message_id, MIN(ordinal) AS ordinal FROM orchestration_v2_projection_turn_items @@ -3367,7 +3418,14 @@ export const layer: Layer.Layer = `; for (const row of rows) messageOrdinals.set(row.message_id, row.ordinal); } - const orderedMessages = sortMessagesByTurnItemOrder(messages, turnItems, messageOrdinals); + const orderedMessages = + filter?.messageTail === undefined + ? sortMessagesByTurnItemOrder(messages, turnItems, messageOrdinals) + : // SQLite substr counts code points; the wire limits and replay store count UTF-16 units. + messages.map((message) => ({ + ...message, + text: message.text.slice(0, filter.messageTail!.maxTextLength), + })); const projection = { thread, runs, @@ -4820,6 +4878,7 @@ export const layer: Layer.Layer = const rows = yield* sql<{ readonly ready: number }>` SELECT 1 AS ready FROM orchestration_v2_projection_threads AS thread WHERE thread.thread_id = ${threadId} AND thread.archived_at IS NULL AND thread.deleted_at IS NULL + AND COALESCE(json_extract(thread.payload_json, '$.handoff.state'), '') NOT IN ('pending', 'departing', 'departed') AND EXISTS (SELECT 1 FROM orchestration_v2_projection_runs WHERE thread_id = ${threadId} AND status = 'queued') AND NOT EXISTS (SELECT 1 FROM orchestration_v2_projection_runs @@ -6438,13 +6497,53 @@ export const layerMemory: Layer.Layer = Layer.effect( return yield* new ProjectionStoreThreadNotFoundError({ threadId }); const selected = { ...projection, - messages: projection.messages.filter( - (row) => - (filter?.messageRunIds === undefined || - (row.runId !== null && filter.messageRunIds.includes(row.runId))) && - (filter?.messageIds === undefined || filter.messageIds.includes(row.id)) && - (filter?.messageRoles === undefined || filter.messageRoles.includes(row.role)), - ), + messages: (() => { + const throughRunId = filter?.messageTail?.throughRunId; + const boundary = + throughRunId === undefined + ? undefined + : projection.runs.find((run) => run.id === throughRunId); + const runOrdinals = new Map( + boundary === undefined ? [] : projection.runs.map((run) => [run.id, run.ordinal]), + ); + const messages = projection.messages.filter( + (row) => + (throughRunId === undefined || + (boundary !== undefined && + isTurnItemAtOrBeforeRun({ + historyOrigin: projection.thread.historyOrigin, + itemRunId: row.runId, + runOrdinalById: runOrdinals, + sourceRunOrdinal: boundary.ordinal, + }))) && + (filter?.messageRunIds === undefined || + (row.runId !== null && filter.messageRunIds.includes(row.runId))) && + (filter?.messageIds === undefined || filter.messageIds.includes(row.id)) && + (filter?.messageRoles === undefined || filter.messageRoles.includes(row.role)), + ); + return filter?.messageTail === undefined + ? messages + : messages + .filter((message) => { + const before = filter.messageTail?.before; + if (before === undefined) return true; + const time = + DateTime.toEpochMillis(message.createdAt) - + DateTime.toEpochMillis(before.createdAt); + return time < 0 || (time === 0 && message.id < before.messageId); + }) + .toSorted( + (left, right) => + DateTime.toEpochMillis(left.createdAt) - + DateTime.toEpochMillis(right.createdAt) || + (left.id < right.id ? -1 : left.id > right.id ? 1 : 0), + ) + .slice(-filter.messageTail.limit) + .map((message) => ({ + ...message, + text: message.text.slice(0, filter.messageTail!.maxTextLength), + })); + })(), runs: filter?.runIds === undefined ? projection.runs @@ -6635,6 +6734,10 @@ export const layerMemory: Layer.Layer = Layer.effect( return ( projection.thread.archivedAt === null && projection.thread.deletedAt === null && + !( + projection.thread.handoff != null && + ["pending", "departing", "departed"].includes(projection.thread.handoff.state) + ) && projection.runs.some((run) => run.status === "queued") && !projection.runs.some( (run) => diff --git a/apps/server/src/orchestration-v2/ThreadForkService.ts b/apps/server/src/orchestration-v2/ThreadForkService.ts index b652ea1b14bee..0022edd332f67 100644 --- a/apps/server/src/orchestration-v2/ThreadForkService.ts +++ b/apps/server/src/orchestration-v2/ThreadForkService.ts @@ -83,6 +83,7 @@ export const layer: Layer.Layer = Layer.succeed( } const targetThread: OrchestrationV2AppThread = { ...input.sourceProjection.thread, + handoff: null, // PR context follows the conversation; watching remains the source thread's responsibility. pullRequests: input.sourceProjection.thread.pullRequests?.map( ({ watch: _watch, ...link }) => link, diff --git a/apps/server/src/orchestration-v2/ThreadImportService.ts b/apps/server/src/orchestration-v2/ThreadImportService.ts index be097941ccc54..604fb6d0de6d3 100644 --- a/apps/server/src/orchestration-v2/ThreadImportService.ts +++ b/apps/server/src/orchestration-v2/ThreadImportService.ts @@ -66,6 +66,19 @@ export class ThreadImportDeletedError extends Schema.TaggedError Effect.Effect; + readonly lookup: ( + input: Pick, + ) => Effect.Effect< + OrchestratorMcpThreadImportResult | null, + ThreadImportError | ThreadImportProjectMismatchError | ThreadImportDeletedError + >; + readonly withAcceptanceLock: ( + source: OrchestratorMcpThreadImportInput["source"], + effect: Effect.Effect, + ) => Effect.Effect; readonly importThread: ( input: OrchestratorMcpThreadImportInput & { readonly runtimeMode: RuntimeMode; @@ -91,23 +104,54 @@ const make = Effect.gen(function* () { const crypto = yield* Crypto.Crypto; const imports = yield* KeyedLock.make(); + const acceptance = yield* KeyedLock.make(); + const sourceKey = (source: OrchestratorMcpThreadImportInput["source"]) => + JSON.stringify([source.environmentId, source.threadId, source.handoffId]); + const identity = Effect.fnUntraced(function* ( + source: OrchestratorMcpThreadImportInput["source"], + ) { + const digest = yield* crypto + .digest("SHA-256", new TextEncoder().encode(sourceKey(source))) + .pipe(Effect.orDie); + return ThreadId.make(`thread:import:${Hex.encode(digest)}`); + }); + const lookup: ThreadImportService["Service"]["lookup"] = Effect.fn("ThreadImportService.lookup")( + function* (input) { + const threadId = yield* identity(input.source); + const existing = yield* threads + .getThreadShell(threadId) + .pipe( + Effect.mapError((cause) => new ThreadImportError({ threadId, operation: "read", cause })), + ); + if (existing === null) return null; + if (existing.projectId !== input.projectId) + return yield* new ThreadImportProjectMismatchError({ + threadId, + existingProjectId: existing.projectId, + }); + if (existing.deletedAt !== null) return yield* new ThreadImportDeletedError({ threadId }); + const projection = yield* threads + .getThreadRecords(threadId, ["messages"], { + messageIds: [MessageId.make(`${threadId}:continuation`)], + }) + .pipe( + Effect.mapError((cause) => new ThreadImportError({ threadId, operation: "read", cause })), + ); + return { + threadId, + projectId: existing.projectId, + created: false, + runId: + projection.messages.find( + (message) => message.id === MessageId.make(`${threadId}:continuation`), + )?.runId ?? null, + }; + }, + ); const importThread: ThreadImportService["Service"]["importThread"] = Effect.fn( "ThreadImportService.importThread", )(function* (input) { - const digest = yield* crypto - .digest( - "SHA-256", - new TextEncoder().encode( - // @effect-diagnostics-next-line preferSchemaOverJson:off -- Hash a fixed tuple of source identity strings without delimiter ambiguity. - JSON.stringify([ - input.source.environmentId, - input.source.threadId, - input.source.handoffId, - ]), - ), - ) - .pipe(Effect.orDie); - const threadId = ThreadId.make(`thread:import:${Hex.encode(digest)}`); + const threadId = yield* identity(input.source); const commandId = CommandId.make(`${threadId}:create`); const messageId = MessageId.make(`${threadId}:continuation`); const wrap = (operation: ThreadImportError["operation"]) => (cause: unknown) => @@ -197,7 +241,12 @@ const make = Effect.gen(function* () { }), ); }); - return ThreadImportService.of({ importThread }); + return ThreadImportService.of({ + identity, + importThread, + lookup, + withAcceptanceLock: (source, effect) => acceptance.withLock(sourceKey(source), effect), + }); }); export const layer = Layer.effect(ThreadImportService, make); diff --git a/apps/server/src/orchestration-v2/ThreadLaunchService.test.ts b/apps/server/src/orchestration-v2/ThreadLaunchService.test.ts index 8eff6248ba431..1b984b22dd0a0 100644 --- a/apps/server/src/orchestration-v2/ThreadLaunchService.test.ts +++ b/apps/server/src/orchestration-v2/ThreadLaunchService.test.ts @@ -53,6 +53,7 @@ import * as SqlitePersistence from "../persistence/Sqlite.ts"; import * as ProjectStore from "./ProjectStore.ts"; import * as ProjectionStore from "./ProjectionStore.ts"; import * as ProjectService from "../project/ProjectService.ts"; +import * as RepositoryIdentityResolver from "../project/RepositoryIdentityResolver.ts"; import * as ProjectSetupScriptRunner from "../project/ProjectSetupScriptRunner.ts"; import * as ManagedProjectFolders from "../project/ManagedProjectFolders.ts"; import * as ProviderRegistryMock from "../provider/testUtils/providerRegistryMock.ts"; @@ -199,6 +200,7 @@ function makeHarness(options: HarnessOptions = {}) { : Option.none(), ), getByWorkspaceRoot: () => Effect.succeed(Option.some(project)), + list: () => Effect.die("unused"), snapshot: Effect.die("unused"), getShell: () => Effect.die("unused"), listShells: () => Effect.die("unused"), @@ -2065,6 +2067,7 @@ it.effect("replays MCP launches durably within each caller's request namespace", ), Layer.mock(GitVcsDriver.GitVcsDriver)({}), Layer.mock(ThreadImportService.ThreadImportService)({}), + Layer.mock(RepositoryIdentityResolver.RepositoryIdentityResolver)({}), Layer.mock(SourceControlRepositories.SourceControlRepositoryService)({}), ); const layerToolkit = McpHttpServer.toolkitRegistration( diff --git a/apps/server/src/peer/PeerForwarding.test.ts b/apps/server/src/peer/PeerForwarding.test.ts index 907d73d44dbc0..6094fe1e6915a 100644 --- a/apps/server/src/peer/PeerForwarding.test.ts +++ b/apps/server/src/peer/PeerForwarding.test.ts @@ -256,6 +256,7 @@ const makeEnvironment = Effect.fnUntraced(function* (id: EnvironmentId) { }), Layer.mock(SecretRequests.SecretRequests)({}), Layer.mock(ThreadSearch.ThreadSearch)({}), + Layer.mock(RepositoryIdentityResolver.RepositoryIdentityResolver)({}), ); const base = Layer.mergeAll( threads, diff --git a/apps/server/src/peer/PeerMcpClient.ts b/apps/server/src/peer/PeerMcpClient.ts index 8c65437ed76f5..fecbe6f7a2d35 100644 --- a/apps/server/src/peer/PeerMcpClient.ts +++ b/apps/server/src/peer/PeerMcpClient.ts @@ -63,6 +63,7 @@ const isMcpFailure = Schema.is(OrchestratorMcpFailure); const Refusal = Schema.Struct({ code: OrchestratorMcpFailure.fields.code, message: Schema.String, + handoffNotAccepted: OrchestratorMcpFailure.fields.handoffNotAccepted, }); const decodeRefusal = Schema.decodeUnknownOption(Refusal); const decodeTextRefusal = Schema.decodeUnknownOption(Schema.fromJsonString(Refusal)); @@ -154,7 +155,7 @@ const make = Effect.gen(function* () { const refusal = decodeRefusal(decoded.value.error.data); if (Option.isSome(refusal)) return yield* new OrchestratorMcpFailure({ - code: refusal.value.code, + ...refusal.value, message: refusal.value.message.slice(0, 1000), }); return yield* new OrchestratorMcpFailure({ @@ -278,7 +279,7 @@ const make = Effect.gen(function* () { : decodeRefusal(result.structuredContent); return yield* Option.isSome(refusal) ? new OrchestratorMcpFailure({ - code: refusal.value.code, + ...refusal.value, message: refusal.value.message.slice(0, 1000), }) : new OrchestratorMcpFailure({ diff --git a/apps/server/src/peer/handoff/HandoffImport.ts b/apps/server/src/peer/handoff/HandoffImport.ts new file mode 100644 index 0000000000000..80b252c3597a3 --- /dev/null +++ b/apps/server/src/peer/handoff/HandoffImport.ts @@ -0,0 +1,387 @@ +import { + OrchestratorMcpThreadImportInput, + type OrchestratorMcpThreadImportResult, + type OrchestrationV2LinkOrigin, + type ProviderInteractionMode, + ProjectId, + type RuntimeMode, +} from "@t3tools/contracts"; +import * as Context from "effect/Context"; +import * as Effect from "effect/Effect"; +import * as Exit from "effect/Exit"; +import * as FileSystem from "effect/FileSystem"; +import * as Layer from "effect/Layer"; +import * as Option from "effect/Option"; +import * as Path from "effect/Path"; +import * as Schema from "effect/Schema"; +import * as HostProcess from "@t3tools/shared/HostProcess"; + +import { deletePendingAttachment } from "../../assets/AttachmentUpload.ts"; +import { + parseThreadSegmentFromAttachmentId, + resolveAttachmentPathById, +} from "../../attachmentStore.ts"; +import * as ServerConfig from "../../config.ts"; +import * as ThreadImportService from "../../orchestration-v2/ThreadImportService.ts"; +import * as ThreadManagement from "../../orchestration-v2/ThreadManagementService.ts"; +import * as ProjectService from "../../project/ProjectService.ts"; +import * as RepositoryIdentityResolver from "../../project/RepositoryIdentityResolver.ts"; +import * as ServerSettings from "../../serverSettings.ts"; +import * as GitVcsDriver from "../../vcs/GitVcsDriver.ts"; +import * as VcsProcess from "../../vcs/VcsProcess.ts"; +import { resolveWorktreesDirectory } from "../../worktreesDirectory.ts"; +import * as HandoffGit from "./HandoffGit.ts"; + +export class HandoffImportRefusedError extends Schema.TaggedError()( + "HandoffImportRefusedError", + { + reason: Schema.Literals([ + "invalid-bundle", + "missing-upload", + "project", + "repository", + "worktree-location", + ]), + notAccepted: Schema.optional( + Schema.Struct({ + source: OrchestratorMcpThreadImportInput.fields.source, + projectId: ProjectId, + }), + ), + cause: Schema.optional(Schema.Defect()), + }, +) { + override get message(): string { + switch (this.reason) { + case "invalid-bundle": + return "Pass a valid handoff bundle and omit worktreePath and branch."; + case "missing-upload": + return "The bundle upload was not found. Upload it again."; + case "project": + return "The destination project was not found."; + case "repository": + return "The bundle belongs to a different repository than this project."; + case "worktree-location": + return "The worktree location is not usable. Fix it in Settings → Storage."; + } + } +} +export class HandoffImportOperationError extends Schema.TaggedError()( + "HandoffImportOperationError", + { + operation: Schema.Literals(["read-project", "prepare-worktree"]), + cause: Schema.Defect(), + }, +) { + override get message(): string { + return `The handoff failed during ${this.operation}.`; + } +} +type Input = OrchestratorMcpThreadImportInput & { + readonly runtimeMode: RuntimeMode; + readonly interactionMode: ProviderInteractionMode; + readonly linkOrigin: OrchestrationV2LinkOrigin | undefined; +}; +type ImportError = Effect.Error< + ReturnType +>; +export class HandoffImportProviderDisabledError extends Schema.TaggedError()( + "HandoffImportProviderDisabledError", + { + projectId: ThreadManagement.ThreadManagementProviderInstanceDisabledError.fields.projectId, + instanceId: ThreadManagement.ThreadManagementProviderInstanceDisabledError.fields.instanceId, + source: OrchestratorMcpThreadImportInput.fields.source, + cause: Schema.Defect(), + }, +) { + override get message(): string { + return `Provider instance "${this.instanceId}" is disabled for project ${this.projectId}.`; + } +} +export class HandoffImportGitRefusedError extends Schema.TaggedError()( + "HandoffImportGitRefusedError", + { + source: OrchestratorMcpThreadImportInput.fields.source, + projectId: ProjectId, + cause: Schema.Union([ + HandoffGit.HandoffNotARepositoryError, + HandoffGit.HandoffTooLargeError, + HandoffGit.HandoffMissingBaseError, + HandoffGit.HandoffBranchCheckedOutError, + HandoffGit.HandoffBranchDivergedError, + HandoffGit.HandoffGitStepError, + ]), + }, +) { + override get message(): string { + return "The destination refused the handoff Git setup."; + } +} +export class HandoffImport extends Context.Service< + HandoffImport, + { + readonly importThread: ( + input: Input, + ) => Effect.Effect< + OrchestratorMcpThreadImportResult, + | ImportError + | HandoffImportRefusedError + | HandoffImportOperationError + | HandoffImportProviderDisabledError + | HandoffImportGitRefusedError + >; + } +>()("t3/peer/handoff/HandoffImport") {} + +const decode = Schema.decodeUnknownEffect(OrchestratorMcpThreadImportInput); +const make = Effect.gen(function* () { + const imports = yield* ThreadImportService.ThreadImportService; + const projects = yield* ProjectService.ProjectService; + const identities = yield* RepositoryIdentityResolver.RepositoryIdentityResolver; + const config = yield* ServerConfig.ServerConfig; + const settings = yield* ServerSettings.ServerSettingsService; + const path = yield* Path.Path; + const home = yield* HostProcess.HomeDirectory; + const fs = yield* FileSystem.FileSystem; + const git = yield* HandoffGit.HandoffGit; + const driver = yield* GitVcsDriver.GitVcsDriver; + const processes = yield* VcsProcess.VcsProcess; + + const importThread: HandoffImport["Service"]["importThread"] = Effect.fn( + "HandoffImport.importThread", + )(function* (input) { + yield* decode(input).pipe( + Effect.mapError(() => new HandoffImportRefusedError({ reason: "invalid-bundle" })), + ); + return yield* imports.withAcceptanceLock( + input.source, + Effect.scoped( + Effect.gen(function* () { + // Accepted identity wins before either the upload or a now-edited destination workspace is read. + const accepted = yield* imports.lookup(input); + if (accepted !== null) return accepted; + const bundle = input.bundle; + if (bundle === undefined) return yield* imports.importThread(input); + if ( + input.worktreePath !== undefined || + input.branch !== undefined || + parseThreadSegmentFromAttachmentId(bundle.attachmentId) !== "pending" + ) + return yield* new HandoffImportRefusedError({ reason: "invalid-bundle" }); + if (bundle.branch !== null) { + const validBranch = yield* processes + .run({ + operation: "HandoffImport.validateBranch", + command: "git", + cwd: config.cwd, + args: ["check-ref-format", `refs/heads/${bundle.branch}`], + allowNonZeroExit: true, + }) + .pipe( + Effect.mapError( + (cause) => + new HandoffImportOperationError({ operation: "prepare-worktree", cause }), + ), + ); + if (validBranch.exitCode !== 0) + return yield* new HandoffImportRefusedError({ reason: "invalid-bundle" }); + } + const project = yield* projects + .getById(input.projectId) + .pipe( + Effect.mapError( + (cause) => new HandoffImportOperationError({ operation: "read-project", cause }), + ), + ); + if (Option.isNone(project)) + return yield* new HandoffImportRefusedError({ reason: "project" }); + const repository = bundle.repository; + if (repository === undefined) + return yield* new HandoffImportRefusedError({ reason: "repository" }); + const actual = yield* identities.resolve(project.value.workspaceRoot, { refresh: true }); + if (repository.canonicalKey !== null) { + if (actual?.canonicalKey !== repository.canonicalKey) + return yield* new HandoffImportRefusedError({ reason: "repository" }); + } else { + // A local clone has no canonical remote identity. Only its actual local origin can establish the match. + let root = project.value.workspaceRoot; + let matched = false; + for (let depth = 0; depth < 8 && !matched; depth++) { + root = yield* fs.realPath(root).pipe(Effect.orElseSucceed(() => root)); + if (root === repository.sourceRoot) { + matched = true; + break; + } + const origin = yield* processes + .run({ + operation: "HandoffImport.localOrigin", + command: "git", + cwd: root, + args: ["remote", "get-url", "origin"], + allowNonZeroExit: true, + env: { GIT_DIR: undefined, GIT_WORK_TREE: undefined, GIT_COMMON_DIR: undefined }, + }) + .pipe( + Effect.mapError( + (cause) => + new HandoffImportOperationError({ operation: "read-project", cause }), + ), + ); + if (origin.exitCode !== 0) break; + const raw = origin.stdout.trim(); + if (raw.startsWith("file://")) { + const localUrl = yield* Effect.try(() => new URL(raw)).pipe( + Effect.mapError(() => new HandoffImportRefusedError({ reason: "repository" })), + ); + if (localUrl.hostname !== "" && localUrl.hostname !== "localhost") break; + root = yield* Effect.try(() => decodeURIComponent(localUrl.pathname)).pipe( + Effect.mapError(() => new HandoffImportRefusedError({ reason: "repository" })), + ); + } else if (path.isAbsolute(raw) || !/^[a-z][a-z0-9+.-]*:|^[^/]+:/i.test(raw)) + root = path.resolve(root, raw); + else break; + } + if (actual !== null || !matched) + return yield* new HandoffImportRefusedError({ reason: "repository" }); + } + const bundlePath = resolveAttachmentPathById({ + attachmentsDir: config.attachmentsDir, + attachmentId: bundle.attachmentId, + }); + if ( + bundlePath === null || + !(yield* fs + .exists(bundlePath) + .pipe( + Effect.mapError( + (cause) => + new HandoffImportOperationError({ operation: "prepare-worktree", cause }), + ), + )) + ) + return yield* new HandoffImportRefusedError({ reason: "missing-upload" }); + const current = yield* settings.getSettings.pipe( + Effect.mapError( + (cause) => new HandoffImportOperationError({ operation: "prepare-worktree", cause }), + ), + ); + const parent = resolveWorktreesDirectory( + current.worktreesDirectory, + config.worktreesDir, + path, + home, + ); + if (parent === null) + return yield* new HandoffImportRefusedError({ reason: "worktree-location" }); + const directory = path.join(parent, path.basename(project.value.workspaceRoot)); + yield* fs + .makeDirectory(directory, { recursive: true }) + .pipe( + Effect.mapError( + (cause) => + new HandoffImportOperationError({ operation: "prepare-worktree", cause }), + ), + ); + // The import digest includes the source environment, so equally named moves cannot share a path. + const importId = yield* imports.identity(input.source); + const worktreePath = path.join( + directory, + `handoff-${importId.slice("thread:import:".length)}`, + ); + const lease = yield* git + .apply({ + repoRoot: project.value.workspaceRoot, + worktreePath, + handoffId: input.source.handoffId, + bundlePath, + branch: bundle.branch, + tip: bundle.tip, + snapshot: bundle.snapshot, + }) + .pipe( + Effect.mapError( + (cause) => + new HandoffImportGitRefusedError({ + source: input.source, + projectId: input.projectId, + cause, + }), + ), + ); + yield* driver + .initializeSubmodules(worktreePath, { submodules: current.worktreeSubmodules }) + .pipe( + Effect.mapError( + (cause) => + new HandoffImportOperationError({ operation: "prepare-worktree", cause }), + ), + ); + // Cancellation must never undo a workspace whose conversation already committed. + return yield* Effect.uninterruptible( + Effect.gen(function* () { + const raced = yield* imports.lookup(input).pipe(Effect.exit); + if (Exit.isFailure(raced)) { + yield* lease.commit; + return yield* Effect.failCause(raced.cause); + } + if (raced.value !== null) { + yield* lease.commit; + return raced.value; + } + const result = yield* imports + .importThread({ + ...input, + branch: lease.branch ?? undefined, + worktreePath, + }) + .pipe(Effect.exit); + if (Exit.isFailure(result)) { + const persisted = yield* imports.lookup(input).pipe(Effect.exit); + if (Exit.isSuccess(persisted) && persisted.value !== null) { + yield* lease.commit; + return persisted.value; + } + // A failed read cannot establish absence after a potentially committed transaction. + if (Exit.isFailure(persisted)) { + yield* lease.commit; + return yield* Effect.failCause(persisted.cause); + } + return yield* Effect.failCause(result.cause); + } + yield* lease.commit; + yield* deletePendingAttachment(bundle.attachmentId).pipe( + Effect.provideService(ServerConfig.ServerConfig, config), + Effect.provideService(FileSystem.FileSystem, fs), + Effect.ignore, + ); + return result.value; + }), + ); + }), + ).pipe( + Effect.catchTags({ + // Native refusals follow the absence lookup; scoped Git rollback finishes before proof escapes. + HandoffImportRefusedError: (cause) => + Effect.fail( + new HandoffImportRefusedError({ + reason: cause.reason, + notAccepted: { source: input.source, projectId: input.projectId }, + cause, + }), + ), + ThreadManagementProviderInstanceDisabledError: (cause) => + Effect.fail( + new HandoffImportProviderDisabledError({ + source: input.source, + projectId: cause.projectId, + instanceId: cause.instanceId, + cause, + }), + ), + }), + ), + ); + }); + return HandoffImport.of({ importThread }); +}); +export const layer = Layer.effect(HandoffImport, make); diff --git a/apps/server/src/peer/handoff/ThreadHandoff.test.ts b/apps/server/src/peer/handoff/ThreadHandoff.test.ts new file mode 100644 index 0000000000000..a6e959f3be971 --- /dev/null +++ b/apps/server/src/peer/handoff/ThreadHandoff.test.ts @@ -0,0 +1,2048 @@ +import * as NodeCrypto from "@effect/platform-node/NodeCrypto"; +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { assert, expect, it, vi } from "@effect/vitest"; +import * as Cause from "effect/Cause"; +import { + CommandId, + EnvironmentId, + MessageId, + OrchestratorMcpFailure, + OrchestratorMcpThreadImportInput, + ProjectId, + ProviderDriverKind, + ProviderInstanceId, + ProviderThreadId, + ProviderTurnId, + type ExecutionEnvironmentDescriptor, + type OrchestrationV2Command, + ThreadId, +} from "@t3tools/contracts"; +import type * as ProviderAdapter from "@t3tools/provider-core/server/ProviderAdapter"; +import * as Context from "effect/Context"; +import * as DateTime from "effect/DateTime"; +import * as Deferred from "effect/Deferred"; +import * as Fiber from "effect/Fiber"; +import * as Effect from "effect/Effect"; +import * as Exit from "effect/Exit"; +import * as FileSystem from "effect/FileSystem"; +import * as Layer from "effect/Layer"; +import * as Option from "effect/Option"; +import * as PubSub from "effect/PubSub"; +import * as Queue from "effect/Queue"; +import * as Schema from "effect/Schema"; +import * as Scope from "effect/Scope"; +import * as Stream from "effect/Stream"; +import * as Toolkit from "effect/ai/Toolkit"; +import * as HttpClient from "effect/http/HttpClient"; +import * as HttpClientResponse from "effect/http/HttpClientResponse"; +import { TestClock } from "effect/testing"; + +import { resolveAttachmentRelativePath } from "../../attachmentPaths.ts"; +import * as AttachmentStore from "../../attachmentStore.ts"; +import * as ServerConfig from "../../config.ts"; +import * as ServerEnvironment from "../../environment/ServerEnvironment.ts"; +import * as McpInvocationContext from "../../mcp/McpInvocationContext.ts"; +import * as McpToolAccess from "../../mcp/McpToolAccess.ts"; +import * as OrchestratorMcpService from "../../mcp/OrchestratorMcpService.ts"; +import * as OrchestratorHandlers from "../../mcp/toolkits/orchestrator/handlers.ts"; +import { OrchestratorToolkit } from "../../mcp/toolkits/orchestrator/tools.ts"; +import * as ProjectHandlers from "../../mcp/toolkits/project/handlers.ts"; +import type { CallerLimits } from "../../mcp/threadAccess.ts"; +import { ProjectToolkit } from "../../mcp/toolkits/project/tools.ts"; +import { CodexProviderCapabilitiesV2 } from "../../orchestration-v2/Adapters/CodexAdapterV2.ts"; +import * as CommandReceiptStore from "../../orchestration-v2/CommandReceiptStore.ts"; +import * as EffectOutbox from "../../orchestration-v2/EffectOutbox.ts"; +import * as EffectWorker from "../../orchestration-v2/EffectWorker.ts"; +import * as ProjectStore from "../../orchestration-v2/ProjectStore.ts"; +import * as ProjectionStore from "../../orchestration-v2/ProjectionStore.ts"; +import * as ThreadImportService from "../../orchestration-v2/ThreadImportService.ts"; +import * as ThreadLaunchService from "../../orchestration-v2/ThreadLaunchService.ts"; +import * as ThreadManagement from "../../orchestration-v2/ThreadManagementService.ts"; +import * as ProviderAdapterRegistry from "../../orchestration-v2/ProviderAdapterRegistry.ts"; +import * as ProviderReplayHarness from "../../orchestration-v2/testkit/ProviderReplayHarness.ts"; +import { layerProjectService } from "../../orchestration-v2/runtimeLayer.ts"; +import * as Sqlite from "../../persistence/Sqlite.ts"; +import * as ProjectService from "../../project/ProjectService.ts"; +import * as ProjectEnrichment from "../../project/ProjectEnrichmentService.ts"; +import * as ProjectFaviconResolver from "../../project/ProjectFaviconResolver.ts"; +import * as RepositoryIdentityResolver from "../../project/RepositoryIdentityResolver.ts"; +import * as ProviderRegistry from "../../provider/ProviderRegistry.ts"; +import * as ScheduledTaskService from "../../scheduledTasks/ScheduledTaskService.ts"; +import * as SecretRequests from "../../secrets/SecretRequests.ts"; +import * as ServerSettings from "../../serverSettings.ts"; +import * as GitVcsDriver from "../../vcs/GitVcsDriver.ts"; +import * as VcsProcess from "../../vcs/VcsProcess.ts"; +import * as WorkspacePaths from "../../workspace/WorkspacePaths.ts"; +import * as PeerForwarding from "../PeerForwarding.ts"; +import * as PeerLinks from "../PeerLinks.ts"; +import * as PeerMcpClient from "../PeerMcpClient.ts"; +import * as HandoffGit from "./HandoffGit.ts"; +import * as HandoffImport from "./HandoffImport.ts"; +import * as ThreadHandoff from "./ThreadHandoff.ts"; + +const decodeFailure = Schema.decodeUnknownEffect(OrchestratorMcpFailure); +const modelSelection = { instanceId: ProviderInstanceId.make("codex"), model: "gpt-5.4" }; +const sourceId = EnvironmentId.make("source"); +const destinationId = EnvironmentId.make("destination"); +const history = [ + { role: "user" as const, text: "Investigate the login race.", createdAt: "2026-10-01T10:00:00Z" }, + { + role: "assistant" as const, + text: "The cookie arrives after the redirect.", + createdAt: "2026-10-01T10:01:00Z", + }, + ...Array.from({ length: 10 }, (_, index) => ({ + role: "user" as const, + text: `Additional investigation detail ${index + 1}.`, + createdAt: `2026-10-01T10:${String(index + 2).padStart(2, "0")}:00Z`, + })), +]; +const decodeImport = Schema.decodeUnknownEffect(OrchestratorMcpThreadImportInput); +const descriptor = (environmentId: EnvironmentId): ExecutionEnvironmentDescriptor => ({ + environmentId, + label: environmentId, + platform: { os: "linux", arch: "x64" }, + serverVersion: "test", + capabilities: { + repositoryIdentity: true, + mcpModeLimitHeader: true, + linkFence: true, + threadImport: true, + threadHandoff: true, + }, +}); +const git = Effect.fnUntraced(function* (cwd: string, args: ReadonlyArray) { + return (yield* (yield* VcsProcess.VcsProcess).run({ + operation: "ThreadHandoff.test", + command: "git", + cwd, + args, + env: { + GIT_DIR: undefined, + GIT_WORK_TREE: undefined, + GIT_INDEX_FILE: undefined, + GIT_OPTIONAL_LOCKS: "0", + GIT_AUTHOR_NAME: "Test", + GIT_AUTHOR_EMAIL: "test@example.com", + GIT_COMMITTER_NAME: "Test", + GIT_COMMITTER_EMAIL: "test@example.com", + }, + })).stdout.trim(); +}); +const checkout = Effect.fnUntraced(function* (cwd: string) { + return { + head: yield* git(cwd, ["rev-parse", "HEAD"]), + staged: yield* git(cwd, ["diff", "--cached"]), + status: yield* git(cwd, ["status", "--porcelain"]), + refs: yield* git(cwd, ["show-ref"]), + }; +}); + +const makeEnvironment = Effect.fnUntraced(function* ( + id: EnvironmentId, + cwd: string, + coldProjectMetadata = false, +) { + const turns: Array = []; + const openedSessions: Array = []; + const ensuredThreads: Array = []; + const forkedThreads: Array = []; + const started = yield* Queue.unbounded<{ + turn: ProviderAdapter.ProviderAdapterV2TurnInput; + finish: Effect.Effect; + }>(); + const driver = ProviderDriverKind.make("codex"); + const instanceId = modelSelection.instanceId; + const adapter: ProviderAdapter.ProviderAdapterV2["Service"] = { + instanceId, + driver, + getCapabilities: () => Effect.succeed(CodexProviderCapabilitiesV2), + planSelectionTransition: () => Effect.succeed({ type: "apply_on_next_turn" }), + openSession: (input) => + Effect.gen(function* () { + openedSessions.push(input); + const now = yield* DateTime.now; + const events = yield* PubSub.unbounded(); + return { + instanceId, + driver, + providerSessionId: input.providerSessionId, + providerSession: { + id: input.providerSessionId, + driver, + providerInstanceId: instanceId, + status: "ready", + cwd: input.runtimePolicy.cwd ?? cwd, + model: modelSelection.model, + capabilities: CodexProviderCapabilitiesV2, + createdAt: now, + updatedAt: now, + lastError: null, + }, + events: Stream.fromPubSub(events), + ensureThread: (threadInput) => + Effect.sync(() => { + ensuredThreads.push(threadInput); + const { threadId } = threadInput; + return { + id: ProviderThreadId.make(`provider:${threadId}`), + driver, + providerInstanceId: instanceId, + providerSessionId: input.providerSessionId, + appThreadId: threadId, + ownerNodeId: null, + nativeThreadRef: { driver, nativeId: threadId, strength: "strong" }, + nativeConversationHeadRef: null, + status: "idle", + firstRunOrdinal: null, + lastRunOrdinal: null, + handoffIds: [], + forkedFrom: null, + createdAt: now, + updatedAt: now, + }; + }), + resumeThread: ({ providerThread }) => Effect.succeed(providerThread), + startTurn: (turn) => + Effect.gen(function* () { + turns.push(turn); + const providerTurn = { + id: ProviderTurnId.make(`turn:${turn.attemptId}`), + providerThreadId: turn.providerThread.id, + nodeId: turn.rootNodeId, + runAttemptId: turn.attemptId, + nativeTurnRef: { driver, nativeId: turn.attemptId, strength: "strong" as const }, + ordinal: turn.providerTurnOrdinal, + startedAt: now, + completedAt: null, + }; + yield* PubSub.publish(events, { + type: "provider_turn.updated", + driver, + providerTurn: { ...providerTurn, status: "running" }, + }); + yield* Queue.offer(started, { + turn, + finish: PubSub.publishAll(events, [ + { + type: "provider_turn.updated", + driver, + providerTurn: { ...providerTurn, status: "completed", completedAt: now }, + }, + { + type: "turn.terminal", + driver, + providerThreadId: turn.providerThread.id, + providerTurnId: providerTurn.id, + runOrdinal: turn.runOrdinal, + status: "completed", + failure: null, + threadDisposition: "reusable", + }, + ]).pipe(Effect.asVoid), + }); + }), + steerTurn: () => Effect.void, + interruptTurn: () => Effect.void, + respondToRuntimeRequest: () => Effect.die("unexpected runtime request"), + readThreadSnapshot: () => Effect.die("unexpected snapshot"), + rollbackThread: () => Effect.die("unexpected rollback"), + forkThread: (fork) => + Effect.sync(() => { + forkedThreads.push(fork); + return { + ...fork.sourceProviderThread, + id: ProviderThreadId.make(`provider:${fork.targetThreadId}`), + appThreadId: fork.targetThreadId, + providerSessionId: input.providerSessionId, + nativeThreadRef: { + driver, + nativeId: fork.targetThreadId, + strength: "strong" as const, + }, + firstRunOrdinal: null, + lastRunOrdinal: null, + forkedFrom: { + providerThreadId: fork.sourceProviderThread.id, + ...(fork.providerTurnId === undefined + ? {} + : { providerTurnId: fork.providerTurnId }), + }, + }; + }), + }; + }), + }; + const database = Sqlite.layerMemory; + const settings = ServerSettings.layerTest(); + const config = ServerConfig.layerTest(cwd, { prefix: `t3-handoff-${id}-` }); + const replay = ProviderReplayHarness.layerWithRegistry( + { name: id }, + ProviderAdapterRegistry.layerSingle(adapter), + { databaseLayer: database, runEffectWorker: false }, + ); + const stores = Layer.mergeAll( + ProjectStore.layer, + ProjectionStore.layer, + CommandReceiptStore.layer, + EffectOutbox.layer, + ).pipe(Layer.provide(database)); + const threads = ThreadManagement.layer.pipe( + Layer.provide(Layer.mergeAll(replay, stores, settings)), + ); + const projects = layerProjectService.pipe( + Layer.provide( + (coldProjectMetadata + ? Layer.mock(ProjectEnrichment.ProjectEnrichmentService)({ + invalidate: () => Effect.void, + // Keep optional metadata cold so background completion cannot hide discovery failures. + getAvailable: () => + Effect.succeed({ + repositoryIdentity: null, + repositoryIdentityResolved: false, + faviconPath: null, + }), + }) + : ProjectEnrichment.layer + ).pipe( + Layer.provide( + Layer.mergeAll( + RepositoryIdentityResolver.layer, + Layer.mock(ProjectFaviconResolver.ProjectFaviconResolver)({ + resolvePath: () => Effect.succeed(null), + }), + ), + ), + ), + ), + Layer.provide(WorkspacePaths.layer), + Layer.provide(database), + Layer.provide(settings), + Layer.provide(config), + ); + const core = yield* Layer.mergeAll( + threads, + replay, + stores, + projects, + settings, + config, + GitVcsDriver.layer.pipe(Layer.provide(config)), + RepositoryIdentityResolver.layer, + ).pipe(Layer.build); + const imports = Context.get( + yield* ThreadImportService.layer.pipe(Layer.provide(Layer.succeedContext(core)), Layer.build), + ThreadImportService.ThreadImportService, + ); + const projectId = ProjectId.make(`project:${id}`); + const projectService = Context.get(core, ProjectService.ProjectService); + yield* projectService.create({ + commandId: CommandId.make(`create:${id}`), + projectId, + title: id, + workspaceRoot: cwd, + }); + const management = Context.get(core, ThreadManagement.ThreadManagementService); + const worker = Context.get(core, EffectWorker.OrchestrationEffectWorkerV2); + const runState = (threadId: ThreadId, status: "running" | "completed", runId: string) => + management.streamStoredEventsFrom({ threadId, eventType: "run.updated" }).pipe( + Stream.filter( + ({ event }) => + event.type === "run.updated" && + event.payload.id === runId && + (event.payload.status === status || + (status === "completed" && event.payload.status === "waiting")), + ), + Stream.runHead, + ); + const nextTurn = Effect.gen(function* () { + yield* worker.drain(); + const current = yield* Queue.take(started); + yield* runState(current.turn.threadId, "running", current.turn.runId); + const terminal = current.finish.pipe( + Effect.andThen(runState(current.turn.threadId, "completed", current.turn.runId)), + Effect.asVoid, + ); + return { ...current, terminal, finish: terminal.pipe(Effect.andThen(worker.drain())) }; + }); + return { + core, + imports, + projectId, + projects: projectService, + management, + worker, + turns, + openedSessions, + ensuredThreads, + forkedThreads, + nextTurn, + settings: Context.get(core, ServerSettings.ServerSettingsService), + }; +}); + +const makeFixture = Effect.fnUntraced(function* (coldProjectMetadata = false) { + yield* TestClock.setTime(Date.parse("2026-10-19T12:00:00.000Z")); + const fs = yield* FileSystem.FileSystem; + const root = yield* fs.makeTempDirectoryScoped({ prefix: "t3-handoff-test-" }); + const sourceRepo = `${root}/source`; + const receiverRepo = `${root}/receiver`; + yield* git(root, ["init", "--quiet", "--initial-branch=main", sourceRepo]); + yield* fs.writeFileString(`${sourceRepo}/app.txt`, "base\n"); + yield* git(sourceRepo, ["add", "."]); + yield* git(sourceRepo, ["commit", "--quiet", "-m", "base"]); + yield* git(root, ["clone", "--quiet", sourceRepo, receiverRepo]); + yield* git(sourceRepo, ["checkout", "--quiet", "-b", "fix/login"]); + yield* fs.writeFileString(`${sourceRepo}/app.txt`, "committed\n"); + yield* git(sourceRepo, ["commit", "--quiet", "-am", "unpushed"]); + yield* fs.writeFileString(`${sourceRepo}/app.txt`, "staged\n"); + yield* git(sourceRepo, ["add", "app.txt"]); + yield* fs.writeFileString(`${sourceRepo}/app.txt`, "working\n"); + yield* fs.writeFileString(`${sourceRepo}/notes.txt`, "findings\n"); + yield* fs.writeFileString(`${receiverRepo}/app.txt`, "receiver staging\n"); + yield* git(receiverRepo, ["add", "app.txt"]); + if (coldProjectMetadata) { + const origin = "https://example.com/team/handoff.git"; + yield* git(sourceRepo, ["remote", "add", "origin", origin]); + yield* git(receiverRepo, ["remote", "set-url", "origin", origin]); + } + const source = yield* makeEnvironment(sourceId, sourceRepo); + const receiver = yield* makeEnvironment(destinationId, receiverRepo, coldProjectMetadata); + const seeded = yield* source.imports.importThread({ + source: { environmentId: sourceId, threadId: ThreadId.make("seed"), handoffId: "seed" }, + projectId: source.projectId, + worktreePath: sourceRepo, + title: "Login race", + modelSelection, + runtimeMode: "approval-required", + interactionMode: "plan", + linkOrigin: undefined, + messages: history, + }); + const threadId = seeded.threadId; + const remote = { + descriptor: descriptor(destinationId), + loseReply: false, + beforeImportFailure: null as OrchestratorMcpFailure | null, + refuseImport: false, + discardUploads: false, + afterApply: Effect.void, + afterAcceptance: Effect.void, + access: "full-access" as "full-access" | "read-only", + }; + const payloads: Array> = []; + const applyEntries: Array[0]> = []; + const applied: Array[0]> = []; + const packed: string[] = []; + const realGit = Context.get(yield* Layer.build(HandoffGit.layer), HandoffGit.HandoffGit); + const receiverGit = { + ...realGit, + apply: (input: Parameters[0]) => + Effect.sync(() => applyEntries.push(input)).pipe( + Effect.andThen(realGit.apply(input)), + Effect.tap(() => + Effect.gen(function* () { + applied.push(input); + yield* remote.afterApply; + if (remote.refuseImport) + yield* receiver.settings + .updateSettings({ + providerInstances: { + [modelSelection.instanceId]: { + driver: ProviderDriverKind.make("codex"), + enabled: false, + }, + }, + }) + .pipe(Effect.orDie); + }), + ), + ), + }; + const receiverFsCalls: string[] = []; + const receiving = yield* HandoffImport.layer.pipe( + Layer.provide( + Layer.succeed(FileSystem.FileSystem, { + ...fs, + exists: (path) => + Effect.sync(() => receiverFsCalls.push(`exists:${path}`)).pipe( + Effect.andThen(fs.exists(path)), + ), + realPath: (path) => + Effect.sync(() => receiverFsCalls.push(`realPath:${path}`)).pipe( + Effect.andThen(fs.realPath(path)), + ), + makeDirectory: (path, options) => + Effect.sync(() => receiverFsCalls.push(`mkdir:${path}`)).pipe( + Effect.andThen(fs.makeDirectory(path, options)), + ), + }), + ), + Layer.provide(Layer.succeed(HandoffGit.HandoffGit, receiverGit)), + Layer.provide( + Layer.succeed(ThreadImportService.ThreadImportService, { + ...receiver.imports, + importThread: (input) => + receiver.imports.importThread(input).pipe(Effect.tap(() => remote.afterAcceptance)), + }), + ), + Layer.provideMerge(Layer.succeedContext(receiver.core)), + Layer.build, + ); + const handlers = yield* McpToolAccess.HandlersLayer.layer(ProjectHandlers.layer).pipe( + Layer.provide(Layer.succeedContext(receiving)), + Layer.build, + ); + const toolkit = yield* Toolkit.make( + ProjectToolkit.tools.t3_thread_import, + ProjectToolkit.tools.t3_project_list, + ProjectToolkit.tools.t3_project_read, + ).pipe(Effect.provide(handlers)); + const attachmentId = AttachmentStore.createPendingAttachmentId("bundle"); + const config = Context.get(receiver.core, ServerConfig.ServerConfig); + const bundlePath = resolveAttachmentRelativePath({ + attachmentsDir: config.attachmentsDir, + relativePath: `${attachmentId}.bundle`, + })!; + const replies: Array = []; + const receiverScope = (limits: CallerLimits): McpInvocationContext.McpInvocationScope => ({ + environmentId: destinationId, + requestNamespace: "linked-source", + thread: undefined, + client: { + sessionId: "link-source", + label: "source", + access: remote.access, + linked: true, + narrowedTo: limits, + }, + capabilities: new Set(["orchestration"]), + issuedAt: 0, + }); + const receive = ( + tool: "t3_thread_import" | "t3_project_list" | "t3_project_read", + input: Record, + limits: CallerLimits = { runtimeMode: "approval-required", interactionMode: "plan" }, + ) => + toolkit + .handle(tool, input) + .pipe( + Effect.flatMap(Stream.runLast), + Effect.provide(receiving), + Effect.provideService(McpInvocationContext.McpInvocationContext, receiverScope(limits)), + Effect.map(Option.getOrThrow), + ); + const peer: PeerMcpClient.PeerMcpClient["Service"] = { + call: (input) => + Effect.gen(function* () { + if (input.tool === "t3_attachment_prepare_upload") + // oxlint-disable-next-line t3code/no-inline-schema-compile -- Each forwarded tool supplies its own result schema. + return yield* Schema.decodeUnknownEffect(input.success)({ + attachmentId, + relativeUrl: "/upload", + expiresAt: Date.parse("2099-01-01T00:00:00Z"), + }).pipe(Effect.orDie); + if ( + input.tool !== "t3_project_list" && + input.tool !== "t3_project_read" && + input.tool !== "t3_thread_import" + ) + return yield* Effect.die(`Unexpected forwarded tool: ${input.tool}`); + if (input.tool === "t3_thread_import") payloads.push(structuredClone(input.arguments)); + if (input.tool === "t3_thread_import" && remote.beforeImportFailure !== null) + return yield* remote.beforeImportFailure; + const reply = yield* receive(input.tool, input.arguments, input.limits).pipe(Effect.orDie); + if (input.tool === "t3_thread_import") replies.push(reply.encodedResult); + if (reply.isFailure) + return yield* Effect.flatMap( + decodeFailure(reply.encodedResult).pipe(Effect.orDie), + Effect.fail, + ); + if (input.tool === "t3_thread_import" && remote.loseReply) + return yield* new OrchestratorMcpFailure({ + code: "orchestration_error", + message: "Connection closed after acceptance.", + }); + // oxlint-disable-next-line t3code/no-inline-schema-compile -- Each forwarded tool supplies its own result schema. + return yield* Schema.decodeUnknownEffect(input.success)(reply.encodedResult).pipe( + Effect.orDie, + ); + }), + }; + const now = yield* DateTime.now; + const link = { + environmentId: destinationId, + label: "destination", + urls: ["http://peer.test"], + access: "full-access" as const, + linkedAt: now, + expiresAt: DateTime.add(now, { days: 1 }), + lastReachedAt: now, + lastError: null, + }; + const uploads: Uint8Array[] = []; + const http = HttpClient.make((request) => + Effect.gen(function* () { + if (request.method === "GET") + return HttpClientResponse.fromWeb(request, Response.json(remote.descriptor)); + assert.equal(request.url, "http://peer.test/upload"); + assert.equal(request.body._tag, "Uint8Array"); + if (request.body._tag !== "Uint8Array") return yield* Effect.die("expected bundle bytes"); + yield* fs + .makeDirectory(bundlePath.slice(0, bundlePath.lastIndexOf("/")), { recursive: true }) + .pipe(Effect.orDie); + uploads.push(request.body.body.slice()); + if (!remote.discardUploads) + yield* fs.writeFile(bundlePath, request.body.body).pipe(Effect.orDie); + return HttpClientResponse.fromWeb(request, new Response(null, { status: 204 })); + }), + ); + const sourceDependencies = Layer.mergeAll( + Layer.succeedContext(source.core), + Layer.succeed(ServerEnvironment.ServerEnvironment, { + getEnvironmentId: Effect.succeed(sourceId), + getDescriptor: Effect.succeed(descriptor(sourceId)), + }), + Layer.succeed(PeerMcpClient.PeerMcpClient, peer), + Layer.mock(PeerLinks.PeerLinks)({ + list: Effect.succeed([{ ...link, status: "reachable" }]), + get: () => Effect.succeed(Option.some(link)), + resolve: () => Effect.succeed({ link, url: link.urls[0]!, token: "test" }), + recordOutcome: () => Effect.void, + }), + Layer.succeed(HttpClient.HttpClient, http), + Layer.succeed(HandoffGit.HandoffGit, { + ...realGit, + pack: (input) => + Effect.sync(() => packed.push(input.handoffId)).pipe(Effect.andThen(realGit.pack(input))), + }), + ); + const handoffLayer = ThreadHandoff.layer.pipe( + Layer.provide(PeerForwarding.layer), + Layer.provide(sourceDependencies), + ); + const handoffScope = yield* Effect.acquireRelease(Scope.make(), (scope) => + Scope.close(scope, Exit.void), + ); + const handoff = Context.get( + yield* Layer.buildWithScope(handoffLayer, handoffScope), + ThreadHandoff.ThreadHandoff, + ); + const disposeHandoff = Scope.close(handoffScope, Exit.void); + const start = { threadId, environmentId: destinationId, projectId: receiver.projectId }; + const own = { + callerThreadId: threadId, + environmentId: destinationId, + projectId: receiver.projectId, + }; + const read = () => source.management.getThreadProjection(threadId); + const send = ( + id: string, + dispatchMode: Extract["dispatchMode"] = { + type: "start_immediately", + }, + attachments: Extract["attachments"] = [], + ) => + source.management.dispatch({ + type: "message.dispatch", + commandId: CommandId.make(`send:${id}`), + threadId, + messageId: MessageId.make(`message:${id}`), + text: id, + attachments, + dispatchMode, + createdBy: "user", + creationSource: "web", + }); + return { + source, + receiver, + threadId, + root, + sourceRepo, + receiverRepo, + remote, + payloads, + replies, + uploads, + receive, + receiving: Context.get(receiving, HandoffImport.HandoffImport), + receiverFsCalls, + applyEntries, + applied, + packed, + handoff, + handoffLayer, + disposeHandoff, + start, + own, + read, + send, + bundlePath, + }; +}); +const fixture = makeFixture(); +const native = Layer.mergeAll(VcsProcess.layer, NodeCrypto.layer).pipe( + Layer.provideMerge(NodeServices.layer), +); + +const assertFenced = Effect.fnUntraced(function* ( + send: ReturnType, +) { + const result = yield* send.pipe(Effect.result); + assert.equal(result._tag, "Failure", "a fenced source must refuse the native command"); + if (result._tag !== "Failure") return; + const error = result.failure; + assert.oneOf(error._tag, ["OrchestratorDispatchError", "OrchestratorCommandRejectedError"]); + if ( + error._tag === "OrchestratorDispatchError" || + error._tag === "OrchestratorCommandRejectedError" + ) + assert.match(String(error.cause), /handoff|moving|moved|departing|departed/i); +}); + +it.effect( + "moves native history and Git work once, stamps the receiver, and seals source sends", + () => + Effect.gen(function* () { + const f = yield* fixture; + const fs = yield* FileSystem.FileSystem; + const attachmentId = AttachmentStore.createAttachmentId(f.threadId, "txt")!; + const sourceConfig = Context.get(f.source.core, ServerConfig.ServerConfig); + const attachmentPath = resolveAttachmentRelativePath({ + attachmentsDir: sourceConfig.attachmentsDir, + relativePath: `${attachmentId}.txt`, + })!; + yield* fs.writeFileString(attachmentPath, "local attachment"); + yield* f.send("last source turn", { type: "start_immediately" }, [ + { + type: "file", + id: attachmentId, + name: "local.txt", + mimeType: "text/plain", + sizeBytes: 16, + }, + ]); + const sourceTurn = yield* f.source.nextTurn; + assert.equal(sourceTurn.turn.message.attachments.length, 1); + yield* sourceTurn.finish; + yield* TestClock.adjust("1 millis"); + yield* f.send("failed workspace preparation", { + type: "defer_start", + workspaceStrategy: { type: "root" }, + }); + const preparation = (yield* f.read()).runs.at(-1)!; + yield* f.source.management.dispatch({ + type: "prepared-run.fail", + commandId: CommandId.make("fail-preparation"), + threadId: f.threadId, + runId: preparation.id, + failure: { + class: "validation_error", + message: "Setup failed", + code: null, + retryable: false, + }, + }); + yield* f.source.worker.drain(); + const relatedThreadId = ThreadId.make("related-fork"); + yield* f.source.management.dispatch({ + type: "thread.fork", + commandId: CommandId.make("related-fork"), + sourceThreadId: f.threadId, + targetThreadId: relatedThreadId, + sourcePoint: { type: "run", runId: sourceTurn.turn.runId }, + createdBy: "user", + creationSource: "web", + }); + const sourceBefore = yield* f.read(); + assert.isNotEmpty(sourceBefore.providerSessions); + const before = yield* checkout(f.sourceRepo); + const receiverBefore = yield* checkout(f.receiverRepo); + const moved = yield* f.handoff.start(f.start); + assert.equal(moved.state, "departed"); + if (moved.state !== "departed") return yield* Effect.die("handoff did not depart"); + assert.deepInclude((yield* f.read()).thread.handoff, { + state: "departed", + threadId: moved.threadId, + handoffId: moved.handoffId, + }); + yield* assertFenced(f.send("must not run here")); + const checkpoint = sourceBefore.checkpoints.find( + (item) => item.runId === sourceTurn.turn.runId, + )!; + assert.isDefined(checkpoint); + const fenced = yield* f.read(); + const commands: OrchestrationV2Command[] = [ + { + type: "prepared-run.retry", + commandId: CommandId.make("fenced-retry"), + threadId: f.threadId, + runId: preparation.id, + }, + { + type: "checkpoint.rollback", + commandId: CommandId.make("fenced-rollback"), + threadId: f.threadId, + checkpointId: checkpoint.id, + scopeId: checkpoint.scopeId, + restoreFiles: true, + }, + { + type: "delegated_task.request", + commandId: CommandId.make("fenced-delegate"), + parentThreadId: f.threadId, + parentRunId: sourceTurn.turn.runId, + parentNodeId: sourceTurn.turn.rootNodeId, + task: "Continue investigation", + modelSelection, + runtimeMode: "approval-required", + interactionMode: "plan", + createdBy: "agent", + creationSource: "mcp", + }, + { + type: "provider.switch", + commandId: CommandId.make("fenced-provider"), + threadId: f.threadId, + modelSelection: { ...modelSelection, model: "gpt-5.4-mini" }, + }, + { + type: "thread.merge_back", + commandId: CommandId.make("fenced-merge"), + sourceThreadId: relatedThreadId, + targetThreadId: f.threadId, + sourcePoint: { type: "latest_stable" }, + createdBy: "user", + creationSource: "web", + }, + { + type: "thread.fork", + commandId: CommandId.make("fenced-fork"), + sourceThreadId: f.threadId, + targetThreadId: ThreadId.make("forbidden-fork"), + sourcePoint: { type: "run", runId: sourceTurn.turn.runId }, + createdBy: "user", + creationSource: "web", + }, + ]; + const outbox = Context.get(f.source.core, EffectOutbox.EffectOutboxV2); + for (const command of commands) { + yield* assertFenced(f.source.management.dispatch(command)); + assert.isEmpty(yield* outbox.listByCommandId(command.commandId)); + assert.deepEqual(yield* f.read(), fenced); + } + yield* f.source.worker.drain(); + assert.equal(f.source.turns.length, 1); + assert.isNull(yield* f.source.management.getThreadShell(ThreadId.make("forbidden-fork"))); + + const turn = yield* f.receiver.nextTurn; + const imported = yield* f.receiver.management.getThreadProjection(moved.threadId); + assert.deepEqual( + imported.messages + .filter((message) => message.runId === null) + .map(({ role, text, createdAt }) => ({ + role, + text, + createdAt: DateTime.formatIso(createdAt), + })), + sourceBefore.messages + .filter((message) => message.role === "user" || message.role === "assistant") + .map(({ role, text, createdAt }) => ({ + role, + text, + createdAt: DateTime.formatIso(createdAt), + })), + ); + for (const message of sourceBefore.messages) + assert.include(turn.turn.message.text, message.text); + const continuation = imported.messages.find((message) => message.runId === turn.turn.runId); + assert.isDefined(continuation); + assert.isNotEmpty(continuation!.text.trim()); + assert.include(turn.turn.message.text, `User message:\n${continuation!.text}`); + assert.equal(imported.runs.length, 1); + assert.equal(turn.turn.runtimePolicy.cwd, imported.thread.worktreePath); + assert.deepInclude(turn.turn.runtimePolicy, { + runtimeMode: "approval-required", + interactionMode: "plan", + }); + assert.deepInclude(imported.thread, { + historyOrigin: "v1_import", + runtimeMode: "approval-required", + interactionMode: "plan", + linkOrigin: { sessionId: "link-source", label: "source", access: "full-access" }, + }); + assert.isTrue(imported.messages.every((message) => message.attachments.length === 0)); + assert.equal(imported.providerSessions.length, 1); + // Session IDs are environment-local; fresh runtime creation proves independence. + for (const [environment, projection, runtimeTurn] of [ + [f.source, sourceBefore, sourceTurn.turn], + [f.receiver, imported, turn.turn], + ] as const) { + assert.equal(environment.openedSessions.length, 1); + assert.equal(environment.ensuredThreads.length, 1); + const opened = environment.openedSessions[0]!; + const ensured = environment.ensuredThreads[0]!; + assert.equal(opened.threadId, projection.thread.id); + assert.equal(opened.runtimePolicy.cwd, projection.thread.worktreePath); + assert.isUndefined(opened.resumeFromSession); + assert.isUndefined(opened.initialNativeThreadId); + assert.equal(ensured.threadId, projection.thread.id); + assert.equal(ensured.runtimePolicy.cwd, projection.thread.worktreePath); + assert.equal(ensured.existingProviderThread?.appThreadId, projection.thread.id); + assert.isNull(ensured.existingProviderThread?.nativeThreadRef); + assert.equal(runtimeTurn.providerThread.appThreadId, projection.thread.id); + assert.deepEqual(runtimeTurn.providerThread.nativeThreadRef, { + driver: ProviderDriverKind.make("codex"), + nativeId: projection.thread.id, + strength: "strong", + }); + assert.equal(projection.providerSessions[0]!.id, opened.providerSessionId); + assert.equal(projection.providerSessions[0]!.cwd, opened.runtimePolicy.cwd); + assert.deepInclude( + projection.providerThreads.find(({ id }) => id === runtimeTurn.providerThread.id), + { + appThreadId: projection.thread.id, + providerSessionId: opened.providerSessionId, + nativeThreadRef: runtimeTurn.providerThread.nativeThreadRef, + }, + ); + } + assert.isFalse( + imported.providerThreads.some((thread) => + sourceBefore.providerThreads.some( + (source) => + source.id === thread.id || + (source.nativeThreadRef !== null && + source.nativeThreadRef.nativeId === thread.nativeThreadRef?.nativeId), + ), + ), + ); + assert.equal( + yield* fs.readFileString(`${imported.thread.worktreePath}/app.txt`), + "working\n", + ); + assert.equal( + yield* fs.readFileString(`${imported.thread.worktreePath}/notes.txt`), + "findings\n", + ); + assert.equal( + yield* git(imported.thread.worktreePath!, ["log", "-1", "--format=%s"]), + "unpushed", + ); + assert.deepEqual(yield* checkout(f.sourceRepo), before); + const receiverAfter = yield* checkout(f.receiverRepo); + assert.deepEqual( + { head: receiverAfter.head, staged: receiverAfter.staged, status: receiverAfter.status }, + { head: receiverBefore.head, staged: receiverBefore.staged, status: receiverBefore.status }, + ); + yield* turn.finish; + yield* f.handoff.start(f.start).pipe(Effect.result); + yield* f.handoff.settle(f.threadId); + yield* f.receiver.worker.drain(); + assert.equal(f.receiver.turns.length, 1); + assert.equal(f.applyEntries.length, 1); + assert.equal( + (yield* f.receiver.management.listProjectThreads({ + projectId: f.receiver.projectId, + includeSubagents: false, + })).length, + 1, + ); + assert.equal((yield* f.read()).runs.length, sourceBefore.runs.length); + }).pipe(Effect.provide(native)), +); + +it.effect( + "rejects unavailable and busy moves, then retains the explicit pending project across the terminal boundary", + () => + Effect.gen(function* () { + const f = yield* fixture; + const otherRepo = `${f.root}/explicit-receiver`; + yield* git(f.root, ["clone", "--quiet", f.receiverRepo, otherRepo]); + const otherProject = ProjectId.make("project:explicit-receiver"); + yield* f.receiver.projects.create({ + commandId: CommandId.make("create:explicit-receiver"), + projectId: otherProject, + title: "Explicit receiver", + workspaceRoot: otherRepo, + }); + const explicit = { ...f.own, projectId: otherProject }; + f.remote.descriptor = { + ...f.remote.descriptor, + capabilities: { repositoryIdentity: true, mcpModeLimitHeader: true, linkFence: true }, + }; + const unavailable = (yield* f.handoff.options(f.threadId)).find( + (option) => option.environmentId === destinationId, + ); + assert.isDefined(unavailable); + assert.isNotNull(unavailable!.reason); + yield* f.handoff.start(f.start).pipe(Effect.flip); + assert.isEmpty(f.packed); + assert.isEmpty(f.applyEntries); + f.remote.descriptor = { + ...descriptor(destinationId), + environmentId: EnvironmentId.make("wrong-peer"), + }; + yield* f.handoff.start(f.start).pipe(Effect.flip); + assert.isEmpty(f.packed); + f.remote.descriptor = descriptor(destinationId); + const receiverRequest = { + source: { environmentId: sourceId, threadId: f.threadId, handoffId: "permission-check" }, + projectId: f.receiver.projectId, + title: "Permissions", + modelSelection, + messages: history, + }; + f.remote.access = "read-only"; + const denied = yield* f.receive("t3_thread_import", receiverRequest); + assert.isTrue(denied.isFailure); + assert.deepInclude(denied.encodedResult, { code: "capability_denied" }); + f.remote.access = "full-access"; + const elevated = yield* f.receive("t3_thread_import", { + ...receiverRequest, + runtimeMode: "full-access", + }); + assert.isTrue(elevated.isFailure); + assert.deepInclude(elevated.encodedResult, { code: "runtime_mode_escalation_denied" }); + assert.isEmpty(f.applyEntries); + const fs = yield* FileSystem.FileSystem; + const receiverConfig = Context.get(f.receiver.core, ServerConfig.ServerConfig); + const beforeValidation = yield* fs.readDirectory(receiverConfig.worktreesDir); + const uploadLookup = vi.spyOn(AttachmentStore, "resolveAttachmentPathById"); + yield* Effect.addFinalizer(() => Effect.sync(() => uploadLookup.mockRestore())); + const bundleInput = { + ...receiverRequest, + runtimeMode: "approval-required" as const, + interactionMode: "plan" as const, + linkOrigin: { sessionId: "link-source", label: "source", access: "full-access" as const }, + bundle: { + attachmentId: AttachmentStore.createPendingAttachmentId("bundle"), + branch: "feature", + tip: "a".repeat(40), + snapshot: "b".repeat(64), + repository: { canonicalKey: null, sourceRoot: f.sourceRepo }, + }, + }; + yield* decodeImport(bundleInput); + for (const input of [ + { ...bundleInput, source: { ...bundleInput.source, handoffId: "../escape" } }, + { ...bundleInput, bundle: { ...bundleInput.bundle, tip: "abc1234" } }, + { ...bundleInput, bundle: { ...bundleInput.bundle, branch: "/feature" } }, + ]) { + const error = yield* f.receiving.importThread(input).pipe(Effect.flip); + assert.deepInclude(error, { _tag: "HandoffImportRefusedError", reason: "invalid-bundle" }); + assert.isEmpty(uploadLookup.mock.calls); + assert.isEmpty(f.receiverFsCalls); + assert.isEmpty(f.applyEntries); + assert.deepEqual(yield* fs.readDirectory(receiverConfig.worktreesDir), beforeValidation); + } + const nativeInvalid = { + ...bundleInput, + bundle: { ...bundleInput.bundle, branch: "/feature" }, + }; + yield* decodeImport(nativeInvalid); + const invalidReply = yield* f.receive("t3_thread_import", nativeInvalid); + assert.isTrue(invalidReply.isFailure); + assert.deepInclude(invalidReply.encodedResult, { code: "invalid_request" }); + assert.isEmpty(uploadLookup.mock.calls); + assert.isEmpty(f.receiverFsCalls); + assert.isEmpty(f.applyEntries); + const missingUpload = yield* f.receiving.importThread(bundleInput).pipe(Effect.flip); + assert.deepInclude(missingUpload, { + _tag: "HandoffImportRefusedError", + reason: "missing-upload", + }); + assert.equal(uploadLookup.mock.calls.length, 1); + uploadLookup.mockRestore(); + assert.isFalse(f.receiverFsCalls.some((call) => call.startsWith("mkdir:"))); + const opaque = yield* f.receive("t3_thread_import", { + ...receiverRequest, + projectId: otherProject, + source: { ...receiverRequest.source, handoffId: "handoff:opaque" }, + }); + assert.isFalse(opaque.isFailure); + const busy = yield* f.source.imports.importThread({ + source: { environmentId: sourceId, threadId: f.threadId, handoffId: "busy-fixture" }, + projectId: f.source.projectId, + worktreePath: f.sourceRepo, + title: "Busy source", + modelSelection, + runtimeMode: "approval-required", + interactionMode: "plan", + linkOrigin: undefined, + messages: [], + }); + const prepare = { + type: "message.dispatch" as const, + threadId: busy.threadId, + commandId: CommandId.make("prepare"), + messageId: MessageId.make("prepare"), + text: "preparing", + attachments: [], + dispatchMode: { type: "defer_start" as const }, + createdBy: "user" as const, + creationSource: "web" as const, + }; + yield* f.source.management.dispatch(prepare); + const preparing = (yield* f.source.management.getThreadProjection(busy.threadId)).runs.at( + -1, + )!; + assert.equal(preparing.status, "preparing"); + yield* f.handoff.start({ ...f.start, threadId: busy.threadId }).pipe(Effect.flip); + yield* f.source.management.dispatch({ + ...prepare, + commandId: CommandId.make("queued"), + messageId: MessageId.make("queued"), + text: "held queue", + dispatchMode: { type: "queue_after_active" }, + }); + yield* f.source.management.dispatch({ + type: "run.interrupt", + commandId: CommandId.make("hold-preparing"), + threadId: busy.threadId, + runId: preparing.id, + holdQueue: true, + }); + yield* f.source.worker.drain(); + assert.isNull((yield* f.source.management.getThreadShell(busy.threadId))!.activeRunId); + assert.equal( + (yield* f.source.management.getThreadProjection(busy.threadId)).runs.at(-1)?.status, + "queued", + ); + yield* f.handoff.start({ ...f.start, threadId: busy.threadId }).pipe(Effect.flip); + assert.isEmpty(f.packed); + yield* f.send("waiting"); + const waiting = yield* f.source.nextTurn; + assert.equal((yield* f.read()).runs.at(-1)?.status, "running"); + yield* f.handoff.start(f.start).pipe(Effect.flip); + yield* waiting.terminal; + assert.equal((yield* f.read()).runs.at(-1)?.status, "waiting"); + yield* f.handoff.start(f.start).pipe(Effect.flip); + yield* f.source.worker.drain(); + assert.isEmpty(f.packed); + yield* f.send("running before request"); + const active = yield* f.source.nextTurn; + yield* f.handoff + .start({ ...explicit, projectId: ProjectId.make("does-not-exist") }) + .pipe(Effect.flip); + assert.isNull((yield* f.read()).thread.handoff ?? null); + yield* f.send("steering before request", { + type: "steer_active", + targetRunId: active.turn.runId, + }); + yield* f.source.worker.drain(); + const beforeMessage = (yield* f.read()).messages.find( + (message) => message.id === MessageId.make("message:steering before request"), + )!; + const pending = yield* f.handoff.start(explicit); + assert.equal(pending.state, "pending"); + assert.deepInclude((yield* f.read()).thread.handoff, { projectId: otherProject }); + const boundary = Option.getOrThrow( + yield* f.source.management.streamStoredEventsFrom({ threadId: f.threadId }).pipe( + Stream.filter( + ({ event }) => + event.type === "thread.metadata-updated" && + event.payload.handoff?.handoffId === pending.handoffId, + ), + Stream.runHead, + ), + ); + if (pending.state !== "pending") return yield* Effect.die("expected pending handoff"); + assert.equal(pending.requestCommandId, boundary.commandId); + const beforeEvent = Option.getOrThrow( + yield* f.source.management + .streamStoredEventsFrom({ threadId: f.threadId, eventType: "message.updated" }) + .pipe( + Stream.filter( + ({ event }) => + event.type === "message.updated" && event.payload.id === beforeMessage.id, + ), + Stream.runHead, + ), + ); + assert.isBelow(beforeEvent.sequence, boundary.sequence); + assert.equal( + DateTime.toEpochMillis(beforeMessage.createdAt), + DateTime.toEpochMillis(boundary.event.occurredAt), + ); + yield* f.handoff.settle(f.threadId); + assert.equal((yield* f.read()).thread.handoff?.state, "pending"); + assert.isEmpty(f.packed); + yield* f.handoff.start_(); + yield* active.terminal; + assert.equal((yield* f.read()).thread.handoff?.state, "pending"); + yield* f.source.worker.drain(); + yield* f.source.management.streamStoredEventsFrom({ threadId: f.threadId }).pipe( + Stream.filter( + ({ event }) => + event.type === "thread.metadata-updated" && event.payload.handoff?.state === "departed", + ), + Stream.runHead, + ); + assert.equal(f.payloads[0]?.projectId, otherProject); + const received = yield* f.receiver.nextTurn; + const imported = yield* f.receiver.management.getThreadProjection(received.turn.threadId); + assert.equal(imported.thread.projectId, otherProject); + assert.equal(f.applyEntries[0]?.repoRoot, otherRepo); + assert.isEmpty( + yield* f.receiver.management.listProjectThreads({ + projectId: f.receiver.projectId, + includeSubagents: false, + }), + ); + yield* received.finish; + }).pipe(Effect.provide(native)), +); + +it.effect( + "pauses promotion while pending and resumes after cancellation or same-tick instructions without stale clears", + () => + Effect.gen(function* () { + const f = yield* fixture; + yield* f.send("first"); + const first = yield* f.source.nextTurn; + yield* f.send("already queued", { type: "queue_after_active" }); + const a = yield* f.handoff.start(f.own); + assert.equal(a.state, "pending"); + yield* first.finish; + assert.equal(f.source.turns.length, 1); + assert.equal((yield* f.read()).runs.at(-1)?.status, "queued"); + const pendingProjection = yield* f.read(); + if (a.state !== "pending") return yield* Effect.die("expected pending handoff"); + for (const [index, changed] of [ + { environmentId: sourceId }, + { projectId: f.source.projectId }, + { label: "another destination" }, + ].entries()) { + const commandId = CommandId.make(`changed-identity:${index}`); + yield* assertFenced( + f.source.management.dispatch({ + type: "thread.handoff.update", + commandId, + threadId: f.threadId, + expected: { state: "pending", handoffId: a.handoffId }, + handoff: { ...a, ...changed, state: "departing" }, + }), + ); + assert.isEmpty( + yield* Context.get(f.source.core, EffectOutbox.EffectOutboxV2).listByCommandId(commandId), + ); + assert.deepEqual(yield* f.read(), pendingProjection); + } + yield* assertFenced( + f.source.management.dispatch({ + type: "thread.handoff.update", + commandId: CommandId.make("wrong-expected-state"), + threadId: f.threadId, + expected: { state: "departing", handoffId: a.handoffId }, + handoff: { ...a, state: "departing" }, + }), + ); + assert.deepEqual(yield* f.read(), pendingProjection); + yield* f.handoff.cancel({ threadId: f.threadId, handoffId: a.handoffId }); + const second = yield* f.source.nextTurn; + assert.include(second.turn.message.text, "already queued"); + const b = yield* f.handoff.start(f.own); + assert.notEqual(b.handoffId, a.handoffId); + yield* f.handoff.cancel({ threadId: f.threadId, handoffId: a.handoffId }).pipe(Effect.result); + assert.equal((yield* f.read()).thread.handoff?.handoffId, b.handoffId); + yield* f.handoff.cancel({ threadId: f.threadId, handoffId: b.handoffId }); + assert.isNull((yield* f.read()).thread.handoff ?? null); + const beforeWrongRequest = yield* f.read(); + yield* assertFenced( + f.source.management.dispatch({ + type: "thread.handoff.update", + commandId: CommandId.make("actual-request-command"), + threadId: f.threadId, + callerThreadId: f.threadId, + expected: null, + handoff: { ...a, requestCommandId: CommandId.make("wrong-request-command") }, + }), + ); + assert.deepEqual(yield* f.read(), beforeWrongRequest); + yield* f.send("queued before third request", { type: "queue_after_active" }); + const c = yield* f.handoff.start(f.own); + assert.notEqual(c.handoffId, b.handoffId); + const boundary = Option.getOrThrow( + yield* f.source.management.streamStoredEventsFrom({ threadId: f.threadId }).pipe( + Stream.filter( + ({ event }) => + event.type === "thread.metadata-updated" && + event.payload.handoff?.handoffId === c.handoffId, + ), + Stream.runHead, + ), + ); + yield* f.send("instructions after request", { + type: "steer_active", + targetRunId: second.turn.runId, + }); + const after = Option.getOrThrow( + yield* f.source.management + .streamStoredEventsFrom({ threadId: f.threadId, eventType: "message.updated" }) + .pipe( + Stream.filter( + ({ event }) => + event.type === "message.updated" && + event.payload.id === MessageId.make("message:instructions after request"), + ), + Stream.runHead, + ), + ); + assert.isAbove(after.sequence, boundary.sequence); + assert.equal( + DateTime.toEpochMillis(after.event.occurredAt), + DateTime.toEpochMillis(boundary.event.occurredAt), + ); + assert.isNull((yield* f.read()).thread.handoff ?? null); + yield* second.finish; + const third = yield* f.source.nextTurn; + assert.include(third.turn.message.text, "queued before third request"); + yield* third.finish; + assert.isEmpty(f.payloads); + assert.isEmpty(f.packed); + }).pipe(Effect.provide(native)), +); + +it.effect( + "rolls back a returned Git lease on definitive native refusal for new and fast-forwarded branches", + () => + Effect.gen(function* () { + for (const existingBranch of [false, true]) { + const f = yield* fixture; + if (existingBranch) yield* git(f.receiverRepo, ["branch", "fix/login", "HEAD"]); + const before = yield* checkout(f.receiverRepo); + const worktrees = yield* git(f.receiverRepo, ["worktree", "list", "--porcelain"]); + f.remote.refuseImport = true; + if (existingBranch) { + yield* f.send("running before refused move"); + const active = yield* f.source.nextTurn; + yield* f.send("queued before refused move", { type: "queue_after_active" }); + yield* f.handoff.start(f.own); + yield* active.finish; + assert.equal((yield* f.read()).runs.at(-1)?.status, "queued"); + yield* f.handoff.settle(f.threadId); + } else yield* f.handoff.start(f.start).pipe(Effect.result); + assert.equal(f.applyEntries.length, 1); + assert.equal( + f.applied.length, + 1, + "the refusal must happen after real Git.apply returned its lease", + ); + assert.deepInclude(f.replies[0], { code: "invalid_request" }); + assert.match( + String((f.replies[0] as { message: string }).message), + /provider.*disabled|disabled.*provider/i, + ); + const failed = (yield* f.read()).thread.handoff; + assert.equal(failed?.state, "failed"); + assert.match( + failed?.state === "failed" ? failed.lastError : "", + /provider.*disabled|disabled.*provider/i, + ); + assert.deepEqual(yield* checkout(f.receiverRepo), before); + assert.equal(yield* git(f.receiverRepo, ["worktree", "list", "--porcelain"]), worktrees); + assert.isFalse(yield* (yield* FileSystem.FileSystem).exists(f.applied[0]!.worktreePath)); + assert.isEmpty( + yield* f.receiver.management.listProjectThreads({ + projectId: f.receiver.projectId, + includeSubagents: false, + }), + ); + if (existingBranch) { + const resumed = yield* f.source.nextTurn; + assert.include(resumed.turn.message.text, "queued before refused move"); + yield* resumed.finish; + const forkId = ThreadId.make("fork-after-failed-move"); + yield* f.source.management.dispatch({ + type: "thread.fork", + commandId: CommandId.make("fork-after-failed-move"), + sourceThreadId: f.threadId, + targetThreadId: forkId, + sourcePoint: { type: "run", runId: resumed.turn.runId }, + createdBy: "user", + creationSource: "web", + }); + assert.isNull((yield* f.source.management.getThreadProjection(forkId)).thread.handoff); + assert.equal((yield* f.read()).thread.handoff?.state, "failed"); + } + yield* f.send("continue locally after refusal"); + const continued = yield* f.source.nextTurn; + assert.include(continued.turn.message.text, "continue locally after refusal"); + yield* continued.finish; + } + }).pipe(Effect.provide(native)), +); + +it.effect( + "recovers lost acceptance with immutable identity and payload before consumed-upload lookup or Git", + () => + Effect.gen(function* () { + const f = yield* fixture; + f.remote.loseReply = true; + yield* f.handoff + .start({ ...f.start, continuationPrompt: "Continue the login investigation." }) + .pipe(Effect.result); + const departing = (yield* f.read()).thread.handoff; + assert.equal(departing?.state, "departing"); + yield* assertFenced(f.send("forbidden while uncertain")); + const first = yield* f.receiver.nextTurn; + yield* first.finish; + const imported = yield* f.receiver.management.getThreadProjection(first.turn.threadId); + assert.deepInclude(f.replies[0], { + threadId: first.turn.threadId, + runId: first.turn.runId, + created: true, + }); + const fs = yield* FileSystem.FileSystem; + yield* fs.writeFileString( + `${imported.thread.worktreePath}/notes.txt`, + "destination continued working\n", + ); + yield* fs.writeFileString( + `${f.sourceRepo}/app.txt`, + "source checkout changed after packing\n", + ); + yield* fs.remove(f.bundlePath, { force: true }); + f.remote.discardUploads = true; + yield* f.handoff.settle(f.threadId); + assert.deepInclude((yield* f.read()).thread.handoff, { + state: "departing", + handoffId: departing!.handoffId, + }); + assert.isFalse(yield* fs.exists(f.bundlePath)); + f.remote.loseReply = false; + yield* f.disposeHandoff; + const recovered = Context.get( + yield* Layer.build(Layer.fresh(f.handoffLayer)), + ThreadHandoff.ThreadHandoff, + ); + yield* Effect.all([recovered.settle(f.threadId), recovered.settle(f.threadId)], { + concurrency: "unbounded", + }); + assert.deepInclude((yield* f.read()).thread.handoff, { + state: "departed", + handoffId: departing!.handoffId, + threadId: first.turn.threadId, + }); + yield* assertFenced(f.send("still forbidden after recovery")); + assert.isAtLeast(f.payloads.length, 2); + const semantic = (payload: Record) => { + const { attachmentId: _locator, ...bundle } = payload.bundle as Record; + return { ...payload, bundle }; + }; + for (const payload of f.payloads) + assert.deepEqual(semantic(payload), semantic(f.payloads[0]!)); + for (const bytes of f.uploads) assert.deepEqual(bytes, f.uploads[0]); + for (const reply of f.replies.slice(1)) + assert.deepInclude(reply, { + threadId: first.turn.threadId, + runId: first.turn.runId, + created: false, + }); + assert.equal( + f.packed.length, + 1, + "recovery must retain the original bundle despite later source edits", + ); + assert.equal(f.applyEntries.length, 1, "replay must not even enter Git.apply"); + assert.equal( + yield* fs.readFileString(`${imported.thread.worktreePath}/notes.txt`), + "destination continued working\n", + ); + yield* f.receiver.worker.drain(); + assert.deepEqual( + yield* f.receiver.management.getThreadProjection(first.turn.threadId), + imported, + ); + assert.equal(f.receiver.turns.length, 1); + assert.equal( + (yield* f.receiver.management.listProjectThreads({ + projectId: f.receiver.projectId, + includeSubagents: false, + })).length, + 1, + ); + }).pipe(Effect.provide(native)), +); + +it.effect( + "retains accepted work across cancellation but rolls back interruption before acceptance", + () => + Effect.gen(function* () { + for (const accepted of [false, true]) { + const f = yield* fixture; + const fs = yield* FileSystem.FileSystem; + const entered = yield* Deferred.make(); + const release = yield* Deferred.make(); + const gate = Deferred.succeed(entered, undefined).pipe( + Effect.andThen(Deferred.await(release)), + ); + if (accepted) f.remote.afterAcceptance = gate; + else f.remote.afterApply = gate; + const before = yield* checkout(f.receiverRepo); + const worktreesBefore = yield* git(f.receiverRepo, ["worktree", "list", "--porcelain"]); + const caller = yield* f.handoff.start(f.start).pipe(Effect.forkChild); + yield* Deferred.await(entered); + assert.equal(f.applied.length, 1, "the real Git lease must exist before interruption"); + const path = f.applied[0]!.worktreePath; + assert.isTrue(yield* fs.exists(path)); + assert.equal((yield* f.read()).thread.handoff?.state, "departing"); + yield* assertFenced(f.send("forbidden at interruption gate")); + const threads = yield* f.receiver.management.listProjectThreads({ + projectId: f.receiver.projectId, + includeSubagents: false, + }); + assert.equal(threads.length, accepted ? 1 : 0); + const committed = accepted + ? yield* f.receiver.management.getThreadProjection(threads[0]!.id) + : null; + if (committed !== null) { + assert.equal(committed.thread.worktreePath, path); + assert.equal(committed.runs.length, 1); + assert.isNotEmpty(committed.messages); + yield* fs.writeFileString(`${path}/notes.txt`, "receiver edit before cancellation\n"); + } + // Request interruption synchronously while the real import's return is held at the gate. + yield* Effect.sync(() => caller.interruptUnsafe()); + yield* Deferred.succeed(release, undefined); + const exit = yield* Fiber.await(caller); + assert.isTrue(Exit.isFailure(exit)); + if (Exit.isFailure(exit)) assert.isTrue(exit.cause.reasons.some(Cause.isInterruptReason)); + if (committed === null) { + assert.isFalse(yield* fs.exists(path)); + assert.deepEqual(yield* checkout(f.receiverRepo), before); + assert.equal( + yield* git(f.receiverRepo, ["worktree", "list", "--porcelain"]), + worktreesBefore, + ); + assert.isEmpty( + yield* f.receiver.management.listProjectThreads({ + projectId: f.receiver.projectId, + includeSubagents: false, + }), + ); + continue; + } + assert.isTrue(yield* fs.exists(path)); + assert.equal( + yield* git(f.receiverRepo, ["rev-parse", "refs/heads/fix/login"]), + f.applied[0]!.tip, + ); + assert.deepEqual( + yield* f.receiver.management.getThreadProjection(committed.thread.id), + committed, + ); + assert.equal((yield* f.read()).thread.handoff?.state, "departing"); + yield* assertFenced(f.send("forbidden until accepted replay")); + const continuation = yield* f.receiver.nextTurn; + assert.equal(continuation.turn.runId, committed.runs[0]!.id); + yield* continuation.finish; + const completed = yield* f.receiver.management.getThreadProjection(committed.thread.id); + f.remote.discardUploads = true; + yield* f.handoff.settle(f.threadId); + assert.deepInclude((yield* f.read()).thread.handoff, { + state: "departed", + threadId: committed.thread.id, + }); + yield* f.receiver.worker.drain(); + assert.equal(f.receiver.turns.length, 1); + assert.equal(f.applyEntries.length, 1); + assert.deepEqual( + yield* f.receiver.management.getThreadProjection(committed.thread.id), + completed, + ); + assert.equal( + yield* fs.readFileString(`${path}/notes.txt`), + "receiver edit before cancellation\n", + ); + } + }).pipe(Effect.provide(native)), +); + +it.effect( + "retries a quiet uncertain move through start with the same identity without restarting", + () => + Effect.gen(function* () { + const f = yield* fixture; + f.remote.loseReply = true; + yield* f.handoff + .start({ ...f.start, continuationPrompt: "Keep the original request." }) + .pipe(Effect.result); + const departing = (yield* f.read()).thread.handoff!; + assert.equal(departing.state, "departing"); + assert.isEmpty(f.source.turns); + const continuation = yield* f.receiver.nextTurn; + yield* continuation.finish; + const accepted = yield* f.receiver.management.getThreadProjection(continuation.turn.threadId); + const retry = { ...f.start, handoffId: departing.handoffId }; + for (const changed of [ + { ...retry, handoffId: "stale-handoff" }, + { ...retry, environmentId: EnvironmentId.make("other-peer") }, + { ...retry, projectId: ProjectId.make("other-project") }, + { ...retry, continuationPrompt: "Replace the original request." }, + ]) { + assert.equal((yield* f.handoff.start(changed).pipe(Effect.result))._tag, "Failure"); + assert.deepEqual((yield* f.read()).thread.handoff, departing); + assert.equal(f.payloads.length, 1); + } + const fs = yield* FileSystem.FileSystem; + yield* fs.writeFileString(`${f.sourceRepo}/app.txt`, "changed after ambiguous reply\n"); + yield* fs.writeFileString( + `${accepted.thread.worktreePath}/notes.txt`, + "accepted receiver edit\n", + ); + yield* fs.remove(f.bundlePath, { force: true }); + f.remote.discardUploads = true; + f.remote.loseReply = false; + yield* f.receiver.settings.updateSettings({ + providerInstances: { + [modelSelection.instanceId]: { driver: ProviderDriverKind.make("codex"), enabled: false }, + }, + }); + const result = yield* f.handoff.start(retry).pipe(Effect.result); + assert.equal( + result._tag, + "Success", + "a restored peer must be reachable through the supported same-ID retry", + ); + assert.deepInclude((yield* f.read()).thread.handoff, { + state: "departed", + handoffId: departing.handoffId, + threadId: continuation.turn.threadId, + }); + assert.deepInclude(f.replies.at(-1), { + threadId: continuation.turn.threadId, + runId: continuation.turn.runId, + created: false, + }); + assert.equal(f.payloads.length, 2); + assert.deepEqual(f.payloads[1], f.payloads[0]); + assert.equal(f.uploads.length, 2); + assert.deepEqual(f.uploads[1], f.uploads[0]); + assert.equal(f.packed.length, 1); + assert.equal(f.applyEntries.length, 1); + const acknowledged = yield* f.handoff.start(retry); + assert.deepEqual(acknowledged, (yield* f.read()).thread.handoff); + assert.equal( + f.payloads.length, + 2, + "a departed retry only acknowledges the accepted identity", + ); + yield* assertFenced(f.send("no source turn after same-ID retry")); + yield* f.receiver.worker.drain(); + assert.deepEqual( + yield* f.receiver.management.getThreadProjection(continuation.turn.threadId), + accepted, + ); + assert.equal(f.receiver.turns.length, 1); + assert.equal( + (yield* f.receiver.management.listProjectThreads({ + projectId: f.receiver.projectId, + includeSubagents: false, + })).length, + 1, + ); + assert.equal( + yield* fs.readFileString(`${accepted.thread.worktreePath}/notes.txt`), + "accepted receiver edit\n", + ); + }).pipe(Effect.provide(native)), +); + +it.effect( + "releases queued work when same-ID retry is definitively refused after an ambiguous preacceptance failure", + () => + Effect.gen(function* () { + const f = yield* fixture; + yield* git(f.receiverRepo, ["branch", "fix/login", "HEAD"]); + const before = yield* checkout(f.receiverRepo); + const worktrees = yield* git(f.receiverRepo, ["worktree", "list", "--porcelain"]); + yield* f.send("active before ambiguous move"); + const active = yield* f.source.nextTurn; + yield* f.send("queued behind ambiguous move", { type: "queue_after_active" }); + const pending = yield* f.handoff.start(f.own); + f.remote.beforeImportFailure = new OrchestratorMcpFailure({ + code: "orchestration_error", + message: "Connection closed before receiver delivery.", + }); + yield* active.finish; + yield* f.handoff.settle(f.threadId); + assert.deepInclude((yield* f.read()).thread.handoff, { + state: "departing", + handoffId: pending.handoffId, + }); + assert.equal((yield* f.read()).runs.at(-1)?.status, "queued"); + assert.equal(f.payloads.length, 1); + assert.isEmpty(f.replies); + assert.isEmpty(f.applyEntries); + assert.isEmpty( + yield* f.receiver.management.listProjectThreads({ + projectId: f.receiver.projectId, + includeSubagents: false, + }), + ); + const retry = { ...f.start, handoffId: pending.handoffId }; + f.remote.beforeImportFailure = new OrchestratorMcpFailure({ + code: "invalid_request", + message: "Wire validation refused before acceptance lookup.", + }); + yield* f.handoff.start(retry).pipe(Effect.result); + assert.equal(f.payloads.length, 2, "retry must reach the pre-lookup transport refusal"); + assert.equal((yield* f.read()).thread.handoff?.state, "departing"); + assert.equal((yield* f.read()).runs.at(-1)?.status, "queued"); + assert.isEmpty(f.applyEntries); + f.remote.beforeImportFailure = null; + f.remote.access = "read-only"; + yield* f.handoff.start(retry).pipe(Effect.result); + assert.equal(f.payloads.length, 3); + assert.equal( + f.replies.length, + 1, + "the real protected handler must refuse the read-only caller", + ); + assert.equal((yield* f.read()).thread.handoff?.state, "departing"); + assert.equal((yield* f.read()).runs.at(-1)?.status, "queued"); + assert.isEmpty(f.applyEntries); + f.remote.access = "full-access"; + f.remote.refuseImport = true; + yield* f.handoff.start(retry).pipe(Effect.result); + assert.equal( + f.applied.length, + 1, + "same-ID retry must reach the real receiver and acquire a Git lease before refusal", + ); + assert.match( + String((f.replies.at(-1) as { message: string }).message), + /provider.*disabled|disabled.*provider/i, + ); + assert.deepEqual(yield* checkout(f.receiverRepo), before); + assert.equal(yield* git(f.receiverRepo, ["worktree", "list", "--porcelain"]), worktrees); + assert.isFalse(yield* (yield* FileSystem.FileSystem).exists(f.applied[0]!.worktreePath)); + assert.isEmpty( + yield* f.receiver.management.listProjectThreads({ + projectId: f.receiver.projectId, + includeSubagents: false, + }), + ); + const failed = (yield* f.read()).thread.handoff; + assert.equal( + failed?.state, + "failed", + "definitive nonacceptance must release a previously uncertain source", + ); + assert.equal(failed?.handoffId, pending.handoffId); + assert.equal(f.packed.length, 1); + for (const payload of f.payloads) assert.deepEqual(payload, f.payloads[0]); + for (const bytes of f.uploads) assert.deepEqual(bytes, f.uploads[0]); + const resumed = yield* f.source.nextTurn; + assert.include(resumed.turn.message.text, "queued behind ambiguous move"); + yield* resumed.finish; + const payloadCount = f.payloads.length; + assert.equal( + (yield* f.handoff.start(retry).pipe(Effect.result))._tag, + "Failure", + "a failed handoff ID cannot mint another move", + ); + assert.equal(f.payloads.length, payloadCount); + assert.deepEqual((yield* f.read()).thread.handoff, failed); + }).pipe(Effect.provide(native)), +); + +it.effect( + "moves a native fork with inherited history through its fork point and its own followup", + () => + Effect.gen(function* () { + const f = yield* fixture; + yield* f.send("source decision at fork point"); + const boundary = yield* f.source.nextTurn; + yield* boundary.finish; + yield* TestClock.adjust("1 millis"); + const sourceHandoff = (yield* f.read()).thread.handoff; + assert.isNull(sourceHandoff ?? null); + const forkId = ThreadId.make("move-visible-fork"); + yield* f.source.management.dispatch({ + type: "thread.fork", + commandId: CommandId.make("move-visible-fork"), + sourceThreadId: f.threadId, + targetThreadId: forkId, + sourcePoint: { type: "run", runId: boundary.turn.runId }, + createdBy: "user", + creationSource: "web", + }); + yield* f.send("later source secret excluded from fork"); + const later = yield* f.source.nextTurn; + yield* later.finish; + yield* TestClock.adjust("1 millis"); + yield* f.source.management.dispatch({ + type: "message.dispatch", + commandId: CommandId.make("fork-followup"), + threadId: forkId, + messageId: MessageId.make("fork-followup"), + text: "fork own followup", + attachments: [], + dispatchMode: { type: "start_immediately" }, + createdBy: "user", + creationSource: "web", + }); + const followup = yield* f.source.nextTurn; + assert.equal(followup.turn.threadId, forkId); + yield* followup.finish; + const fork = yield* f.source.management.getThreadProjection(forkId); + assert.equal(fork.thread.lineage.relationshipToParent, "fork"); + assert.deepEqual(fork.thread.forkedFrom, { + type: "run", + threadId: f.threadId, + runId: boundary.turn.runId, + }); + assert.equal(f.source.forkedThreads.length, 1); + assert.equal( + f.source.forkedThreads[0]!.providerTurnId, + ProviderTurnId.make(`turn:${boundary.turn.attemptId}`), + ); + assert.equal(fork.contextTransfers[0]?.resolution?.strategy, "native_fork"); + const expected = [ + ...history.map(({ role, text }) => ({ role, text })), + { role: "user", text: "source decision at fork point" }, + { role: "user", text: "fork own followup" }, + ]; + assert.deepEqual( + fork.visibleTurnItems.flatMap(({ item }) => + item.type === "user_message" || item.type === "assistant_message" + ? [{ role: item.type === "user_message" ? "user" : "assistant", text: item.text }] + : [], + ), + expected, + ); + const moved = yield* f.handoff.start({ ...f.start, threadId: forkId }); + assert.equal(moved.state, "departed"); + if (moved.state !== "departed") return yield* Effect.die("fork did not depart"); + const continuation = yield* f.receiver.nextTurn; + const imported = yield* f.receiver.management.getThreadProjection(moved.threadId); + assert.deepEqual( + imported.messages + .filter((message) => message.runId === null) + .map(({ role, text }) => ({ role, text })), + expected, + "destination history must include the visible inherited prefix and fork-local followup", + ); + for (const { text } of expected) assert.include(continuation.turn.message.text, text); + assert.notInclude(continuation.turn.message.text, "later source secret excluded from fork"); + assert.equal(imported.runs.length, 1); + assert.strictEqual((yield* f.read()).thread.handoff, sourceHandoff); + yield* continuation.finish; + }).pipe(Effect.provide(native)), +); + +it.effect( + "requires explicit current-ID abandonment to release uncertain queued work and preserves the accepted copy", + () => + Effect.gen(function* () { + const f = yield* fixture; + yield* f.send("active before abandoned move"); + const active = yield* f.source.nextTurn; + yield* f.send("queued behind abandoned move", { type: "queue_after_active" }); + const pending = yield* f.handoff.start(f.own); + f.remote.loseReply = true; + yield* active.finish; + yield* f.handoff.settle(f.threadId); + const departing = (yield* f.read()).thread.handoff; + assert.deepInclude(departing, { state: "departing", handoffId: pending.handoffId }); + const continuation = yield* f.receiver.nextTurn; + yield* continuation.finish; + const accepted = yield* f.receiver.management.getThreadProjection(continuation.turn.threadId); + const fs = yield* FileSystem.FileSystem; + yield* fs.writeFileString( + `${accepted.thread.worktreePath}/notes.txt`, + "preserve abandoned destination copy\n", + ); + const artifact = `${Context.get(f.source.core, ServerConfig.ServerConfig).stateDir}/handoffs/${pending.handoffId}`; + assert.isTrue(yield* fs.exists(`${artifact}/request.json`)); + f.remote.beforeImportFailure = new OrchestratorMcpFailure({ + code: "orchestration_error", + message: "Peer permanently unavailable.", + }); + const cancel = { threadId: f.threadId, handoffId: pending.handoffId }; + for (const request of [cancel, { ...cancel, handoffId: "stale-handoff", abandon: true }]) { + assert.equal((yield* f.handoff.cancel(request).pipe(Effect.result))._tag, "Failure"); + assert.deepEqual((yield* f.read()).thread.handoff, departing); + assert.equal((yield* f.read()).runs.at(-1)?.status, "queued"); + assert.isTrue(yield* fs.exists(`${artifact}/request.json`)); + } + yield* assertFenced(f.send("ordinary cancel must keep the fence")); + const abandon = { ...cancel, abandon: true }; + const result = yield* f.handoff.cancel(abandon).pipe(Effect.result); + assert.equal( + result._tag, + "Success", + "explicit current-ID abandonment must offer local recovery without the peer", + ); + const failed = (yield* f.read()).thread.handoff; + assert.equal(failed?.state, "failed"); + assert.equal(failed?.handoffId, pending.handoffId); + assert.match( + failed?.state === "failed" ? failed.lastError : "", + /destination.*(?:may|might).*(?:copy|accept)/i, + ); + assert.isFalse(yield* fs.exists(artifact)); + const resumed = yield* f.source.nextTurn; + assert.include(resumed.turn.message.text, "queued behind abandoned move"); + yield* resumed.finish; + assert.equal( + f.payloads.length, + 1, + "abandonment must not require contacting the unavailable peer", + ); + assert.deepEqual( + yield* f.receiver.management.getThreadProjection(continuation.turn.threadId), + accepted, + ); + assert.equal(f.receiver.turns.length, 1); + assert.equal( + (yield* f.receiver.management.listProjectThreads({ + projectId: f.receiver.projectId, + includeSubagents: false, + })).length, + 1, + ); + assert.equal( + yield* fs.readFileString(`${accepted.thread.worktreePath}/notes.txt`), + "preserve abandoned destination copy\n", + ); + }).pipe(Effect.provide(native)), +); + +it.effect("releases queued work on the first native refusal of a mismatched repository", () => + Effect.gen(function* () { + const f = yield* fixture; + const wrongRepo = `${f.root}/unrelated`; + const wrongProjectId = ProjectId.make("project:unrelated"); + yield* git(f.root, ["init", "--quiet", "--initial-branch=main", wrongRepo]); + yield* git(wrongRepo, ["commit", "--quiet", "--allow-empty", "-m", "unrelated"]); + yield* f.receiver.projects.create({ + commandId: CommandId.make("create-unrelated-project"), + projectId: wrongProjectId, + title: "Unrelated repository", + workspaceRoot: wrongRepo, + }); + const before = yield* checkout(wrongRepo); + const worktrees = yield* git(wrongRepo, ["worktree", "list", "--porcelain"]); + yield* f.send("active before wrong-repository move"); + const active = yield* f.source.nextTurn; + yield* f.send("queued behind wrong-repository move", { type: "queue_after_active" }); + const pending = yield* f.handoff.start({ ...f.own, projectId: wrongProjectId }); + yield* active.finish; + assert.equal((yield* f.read()).runs.at(-1)?.status, "queued"); + yield* f.handoff.settle(f.threadId); + assert.equal(f.payloads.length, 1); + assert.equal(f.replies.length, 1); + const refusal = yield* decodeFailure(f.replies[0]); + assert.equal(refusal.code, "invalid_request"); + assert.match(refusal.message, /different repository/i); + assert.isEmpty(f.applyEntries, "repository refusal must precede Git.apply"); + assert.isEmpty( + yield* f.receiver.management.listProjectThreads({ + projectId: wrongProjectId, + includeSubagents: false, + }), + ); + assert.deepEqual(yield* checkout(wrongRepo), before); + assert.equal(yield* git(wrongRepo, ["worktree", "list", "--porcelain"]), worktrees); + expect.soft(refusal.handoffNotAccepted).toEqual({ + source: { + environmentId: sourceId, + threadId: f.threadId, + handoffId: pending.handoffId, + }, + projectId: wrongProjectId, + }); + const failed = (yield* f.read()).thread.handoff; + assert.equal(failed?.state, "failed", "proven repository refusal must release the source"); + assert.equal(failed?.handoffId, pending.handoffId); + assert.match(failed?.state === "failed" ? failed.lastError : "", /different repository/i); + const resumed = yield* f.source.nextTurn; + assert.include(resumed.turn.message.text, "queued behind wrong-repository move"); + yield* resumed.finish; + assert.isEmpty(f.receiver.turns); + }).pipe(Effect.provide(native)), +); + +it.effect( + "exposes the departing handoff identity through native MCP thread read after a lost reply", + () => + Effect.gen(function* () { + const f = yield* fixture; + f.remote.loseReply = true; + assert.equal((yield* f.handoff.start(f.start).pipe(Effect.result))._tag, "Failure"); + const departing = (yield* f.read()).thread.handoff!; + assert.equal(departing.state, "departing"); + const continuation = yield* f.receiver.nextTurn; + yield* continuation.finish; + const dependencies = Layer.mergeAll( + Layer.succeedContext(f.source.core), + Layer.mock(ProviderRegistry.ProviderRegistry)({}), + Layer.mock(ProviderAdapterRegistry.ProviderAdapterRegistryV2)({}), + Layer.mock(ScheduledTaskService.ScheduledTaskService)({}), + Layer.mock(ThreadLaunchService.ThreadLaunchService)({}), + Layer.mock(SecretRequests.SecretRequests)({}), + ); + const services = yield* OrchestratorMcpService.layer.pipe( + Layer.provideMerge(dependencies), + Layer.build, + ); + const handlers = yield* McpToolAccess.HandlersLayer.layer(OrchestratorHandlers.layer).pipe( + Layer.provide(Layer.succeedContext(services)), + Layer.build, + ); + const toolkit = yield* Toolkit.make(OrchestratorToolkit.tools.t3_thread_read).pipe( + Effect.provide(handlers), + ); + const reply = yield* toolkit.handle("t3_thread_read", { threadId: f.threadId }).pipe( + Effect.flatMap(Stream.runLast), + Effect.provide(services), + Effect.provideService(McpInvocationContext.McpInvocationContext, { + environmentId: sourceId, + requestNamespace: "handoff-recovery-client", + thread: undefined, + client: { sessionId: "handoff-recovery", label: "Recovery", access: "full-access" }, + capabilities: new Set(["orchestration"] as const), + issuedAt: 0, + }), + Effect.map(Option.getOrThrow), + ); + assert.isFalse(reply.isFailure); + assert.deepNestedInclude(reply.encodedResult, { + "thread.handoff.state": "departing", + "thread.handoff.handoffId": departing.handoffId, + "thread.handoff.environmentId": destinationId, + "thread.handoff.projectId": f.receiver.projectId, + }); + }).pipe(Effect.provide(native)), +); + +it.effect("finds the unique matching handoff project while optional metadata is cold", () => + Effect.gen(function* () { + const f = yield* makeFixture(true); + const listed = yield* f.receive("t3_project_list", { limit: 100 }); + assert.isFalse(listed.isFailure); + assert.deepNestedInclude(listed.encodedResult, { + "projects[0].id": f.receiver.projectId, + "projects[0].repositoryIdentity": null, + nextCursor: null, + }); + expect.soft(yield* f.handoff.options(f.threadId)).toEqual([ + { + environmentId: destinationId, + label: "destination", + projectId: f.receiver.projectId, + reason: null, + }, + ]); + const identity = yield* Context.get( + f.receiver.core, + RepositoryIdentityResolver.RepositoryIdentityResolver, + ).resolve(f.receiverRepo); + assert.equal(identity?.canonicalKey, "example.com/team/handoff"); + }).pipe(Effect.provide(native)), +); diff --git a/apps/server/src/peer/handoff/ThreadHandoff.ts b/apps/server/src/peer/handoff/ThreadHandoff.ts new file mode 100644 index 0000000000000..f6e4162056036 --- /dev/null +++ b/apps/server/src/peer/handoff/ThreadHandoff.ts @@ -0,0 +1,713 @@ +import { + CommandId, + ExecutionEnvironmentDescriptor, + type EnvironmentId, + type OrchestrationV2AppThread, + type OrchestrationV2ThreadHandoff, + OrchestratorMcpThreadImportInput, + type OrchestratorMcpImportedMessage, + type ProjectId, + type ThreadId, + ThreadHandoffError, + ThreadHandoffId, + linkOriginIsFenced, +} from "@t3tools/contracts"; +import * as KeyedLock from "@t3tools/shared/KeyedLock"; +import * as Context from "effect/Context"; +import * as Crypto from "effect/Crypto"; +import * as DateTime from "effect/DateTime"; +import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; +import * as Layer from "effect/Layer"; +import * as Option from "effect/Option"; +import * as Path from "effect/Path"; +import * as Schema from "effect/Schema"; +import type * as Scope from "effect/Scope"; +import * as Stream from "effect/Stream"; +import * as HttpBody from "effect/http/HttpBody"; +import * as HttpClient from "effect/http/HttpClient"; +import * as HttpClientRequest from "effect/http/HttpClientRequest"; + +import * as ServerConfig from "../../config.ts"; +import * as ServerEnvironment from "../../environment/ServerEnvironment.ts"; +import type * as McpInvocationContext from "../../mcp/McpInvocationContext.ts"; +import { AttachmentToolkit } from "../../mcp/toolkits/attachment/tools.ts"; +import { ProjectToolkit } from "../../mcp/toolkits/project/tools.ts"; +import * as EventSink from "../../orchestration-v2/EventSink.ts"; +import * as ProjectionStore from "../../orchestration-v2/ProjectionStore.ts"; +import * as ThreadManagement from "../../orchestration-v2/ThreadManagementService.ts"; +import * as ProjectService from "../../project/ProjectService.ts"; +import * as RepositoryIdentityResolver from "../../project/RepositoryIdentityResolver.ts"; +import { forkParked } from "../../serverActivation.ts"; +import * as VcsProcess from "../../vcs/VcsProcess.ts"; +import * as PeerForwarding from "../PeerForwarding.ts"; +import * as PeerLinks from "../PeerLinks.ts"; +import * as HandoffGit from "./HandoffGit.ts"; + +const DEFAULT_CONTINUATION = + "This thread was moved here with its conversation and working changes. Continue where it left off."; +const Recovery = Schema.Struct({ + input: OrchestratorMcpThreadImportInput, + bundlePath: Schema.NullOr(Schema.String), + uncertain: Schema.Boolean, +}); +const decodeRecovery = Schema.decodeUnknownEffect(Schema.fromJsonString(Recovery)); +const encodeRecovery = Schema.encodeEffect(Schema.fromJsonString(Recovery)); +const decodeDescriptor = Schema.decodeUnknownEffect(ExecutionEnvironmentDescriptor); +type Handoff = OrchestrationV2ThreadHandoff; +type Start = { + readonly threadId?: ThreadId | undefined; + readonly callerThreadId?: ThreadId | undefined; + readonly environmentId: EnvironmentId; + readonly handoffId?: string | undefined; + readonly projectId?: ProjectId | undefined; + readonly continuationPrompt?: string | undefined; +}; +export class ThreadHandoff extends Context.Service< + ThreadHandoff, + { + readonly options: (threadId: ThreadId) => Effect.Effect< + ReadonlyArray<{ + readonly environmentId: EnvironmentId; + readonly label: string; + readonly projectId: ProjectId | null; + readonly reason: string | null; + }>, + ThreadHandoffError + >; + readonly start: (input: Start) => Effect.Effect; + readonly cancel: (input: { + readonly threadId: ThreadId; + readonly handoffId: string; + readonly abandon?: boolean | undefined; + }) => Effect.Effect; + readonly settle: (threadId: ThreadId) => Effect.Effect; + readonly start_: () => Effect.Effect; + } +>()("t3/peer/handoff/ThreadHandoff") {} + +const isHandoffId = Schema.is(ThreadHandoffId); +const refused = (message: string) => new ThreadHandoffError({ reason: "refused", message }); +const failed = (cause: unknown) => + new ThreadHandoffError({ + reason: "failed", + message: "The move could not finish. Retry it when the linked environment is available.", + cause, + }); +const make = Effect.gen(function* () { + const threads = yield* ThreadManagement.ThreadManagementService; + const projections = yield* ProjectionStore.ProjectionStoreV2; + const events = yield* EventSink.EventSinkV2; + const projects = yield* ProjectService.ProjectService; + const identities = yield* RepositoryIdentityResolver.RepositoryIdentityResolver; + const forwarding = yield* PeerForwarding.PeerForwarding; + const links = yield* PeerLinks.PeerLinks; + const git = yield* HandoffGit.HandoffGit; + const http = yield* HttpClient.HttpClient; + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const config = yield* ServerConfig.ServerConfig; + const crypto = yield* Crypto.Crypto; + const processes = yield* VcsProcess.VcsProcess; + const hereId = yield* (yield* ServerEnvironment.ServerEnvironment).getEnvironmentId; + const locks = yield* KeyedLock.make(); + const artifactDir = (handoffId: string) => path.join(config.stateDir, "handoffs", handoffId); + const scopeFor = (thread: OrchestrationV2AppThread): McpInvocationContext.McpInvocationScope => ({ + environmentId: hereId, + requestNamespace: `handoff:${thread.id}`, + thread: undefined, + // The server owns idle/recovery work; it is not a live provider impersonation. + client: { + sessionId: thread.linkOrigin?.sessionId ?? `handoff:${thread.id}`, + label: thread.linkOrigin?.label ?? "Thread handoff", + linked: thread.linkOrigin !== undefined, + access: thread.linkOrigin?.access ?? thread.runtimeMode, + narrowedTo: { runtimeMode: thread.runtimeMode, interactionMode: thread.interactionMode }, + }, + capabilities: new Set(["orchestration"]), + issuedAt: 0, + }); + const records = (threadId: ThreadId) => + threads.getThreadRecords(threadId, ["runs"]).pipe( + Effect.mapError( + (cause) => + new ThreadHandoffError({ + reason: "not_found", + message: "The thread was not found.", + cause, + }), + ), + ); + const setHandoff = ( + threadId: ThreadId, + current: Handoff | null | undefined, + next: Handoff | null, + step: string, + callerThreadId?: ThreadId, + ) => + threads + .dispatch({ + type: "thread.handoff.update", + commandId: CommandId.make( + `handoff:${threadId}:${next?.handoffId ?? current?.handoffId}:${step}`, + ), + threadId, + expected: current == null ? null : { state: current.state, handoffId: current.handoffId }, + handoff: next, + ...(callerThreadId === undefined ? {} : { callerThreadId }), + }) + .pipe( + Effect.asVoid, + Effect.mapError( + (cause) => + new ThreadHandoffError({ + reason: "refused", + message: + "The thread changed while requesting the move. Read its current state and try again.", + cause, + }), + ), + ); + const assertDestination = (environmentId: EnvironmentId) => + Effect.gen(function* () { + const resolved = yield* links.resolve(environmentId).pipe(Effect.mapError(failed)); + if (resolved.link.access === "read-only") + return yield* refused("The destination link is read-only."); + const response = yield* http + .execute(HttpClientRequest.get(`${resolved.url}/.well-known/t3/environment`)) + .pipe(Effect.mapError(failed)); + if (response.status !== 200) + return yield* refused("The destination could not confirm handoff support."); + const descriptor = yield* response.json.pipe( + Effect.flatMap(decodeDescriptor), + Effect.mapError(failed), + ); + if ( + descriptor.environmentId !== environmentId || + resolved.link.environmentId !== environmentId + ) + return yield* refused("The destination identity does not match this link. Link it again."); + if ( + descriptor.capabilities.threadImport !== true || + descriptor.capabilities.threadHandoff !== true || + descriptor.capabilities.mcpModeLimitHeader !== true || + descriptor.capabilities.linkFence !== true + ) + return yield* refused("Update the destination server to support thread handoff."); + return resolved; + }); + const remoteProject = ( + thread: OrchestrationV2AppThread, + environmentId: EnvironmentId, + requested?: ProjectId, + ) => + Effect.gen(function* () { + yield* assertDestination(environmentId); + const scope = scopeFor(thread); + if (requested !== undefined) { + const project = yield* forwarding + .call(scope, ProjectToolkit.tools.t3_project_read, environmentId, { + projectId: requested, + }) + .pipe( + Effect.mapError( + (cause) => + new ThreadHandoffError({ reason: "refused", message: cause.message, cause }), + ), + ); + if (project.id !== requested || project.deletedAt !== null) + return yield* refused("Choose an existing destination project."); + return project.id; + } + const local = yield* projects.getById(thread.projectId).pipe(Effect.mapError(failed)); + const identity = Option.isNone(local) + ? null + : yield* identities.resolve(local.value.workspaceRoot); + if (identity === null) + return yield* refused( + "Choose the destination project; this repository has no canonical remote.", + ); + const listed = yield* forwarding + .call(scope, ProjectToolkit.tools.t3_project_list, environmentId, { + limit: 100, + resolveRepositoryIdentity: true, + }) + .pipe(Effect.mapError(failed)); + const matches = listed.projects.filter( + (project) => project.repositoryIdentity?.canonicalKey === identity.canonicalKey, + ); + if (matches.length !== 1 || listed.nextCursor !== null) + return yield* refused("Choose the destination project for this repository."); + return matches[0]!.id; + }); + const conversation = (thread: OrchestrationV2AppThread) => + Effect.gen(function* () { + const tail: OrchestratorMcpImportedMessage[] = []; + let remaining = 2_000_000; + let segment = thread; + let throughRunId: NonNullable< + ProjectionStore.ProjectionRecordFilter["messageTail"] + >["throughRunId"]; + const seen = new Set(); + while (!seen.has(segment.id) && tail.length < 2_000 && remaining > 0) { + seen.add(segment.id); + let before: NonNullable["before"]; + while (tail.length < 2_000 && remaining > 0) { + // A page bounds decoding independently of the total retained conversation budget. + const page = yield* threads + .getThreadRecords(segment.id, ["messages"], { + messageRoles: ["user", "assistant"], + messageTail: { + limit: 10, + maxTextLength: 200_000, + ...(before === undefined ? {} : { before }), + ...(throughRunId === undefined ? {} : { throughRunId }), + }, + }) + .pipe(Effect.mapError(failed)); + if (page.messages.length === 0) break; + for (const message of page.messages.toReversed()) { + if (tail.length === 2_000 || remaining === 0) break; + if ( + message.streaming || + message.notification !== undefined || + message.delegatedCompletion !== undefined || + message.text.trim() === "" + ) + continue; + const text = message.text.slice(0, Math.min(200_000, remaining)); + if (text.trim() === "") continue; + tail.push({ + role: message.role === "user" ? "user" : "assistant", + text, + createdAt: DateTime.formatIso(message.createdAt), + }); + remaining -= text.length; + } + const oldest = page.messages[0]!; + before = { createdAt: oldest.createdAt, messageId: oldest.id }; + } + const fork = segment.forkedFrom; + if ( + fork?.type !== "run" || + seen.has(fork.threadId) || + tail.length === 2_000 || + remaining === 0 + ) + break; + const source = yield* threads + .getThreadRecords(fork.threadId, ["runs"], { runIds: [fork.runId] }) + .pipe(Effect.mapError(failed)); + // Native visible history inherits by run ordinal, including each ancestor's own inherited prefix. + if (source.runs.length === 0) break; + segment = source.thread; + throughRunId = fork.runId; + } + return tail.toReversed(); + }); + const gitRead = (cwd: string, args: ReadonlyArray) => + processes + .run({ + operation: "ThreadHandoff.validateWorkspace", + command: "git", + cwd, + args, + maxOutputBytes: 2_000_000, + outputMode: "error", + env: { + GIT_DIR: undefined, + GIT_WORK_TREE: undefined, + GIT_COMMON_DIR: undefined, + GIT_INDEX_FILE: undefined, + }, + }) + .pipe( + Effect.map((result) => result.stdout.trim()), + Effect.mapError(failed), + ); + const prepare = ( + thread: OrchestrationV2AppThread, + handoff: Extract, + ) => + Effect.gen(function* () { + const directory = artifactDir(handoff.handoffId); + const manifest = path.join(directory, "request.json"); + if (yield* fs.exists(manifest).pipe(Effect.mapError(failed))) + return yield* fs + .readFileString(manifest) + .pipe(Effect.flatMap(decodeRecovery), Effect.mapError(failed)); + const project = yield* projects.getById(thread.projectId).pipe(Effect.mapError(failed)); + if (Option.isNone(project)) return yield* refused("The source project was not found."); + const cwd = thread.worktreePath ?? project.value.workspaceRoot; + const repository = yield* processes + .run({ + operation: "ThreadHandoff.inspectWorkspace", + command: "git", + cwd, + args: ["rev-parse", "--is-inside-work-tree"], + allowNonZeroExit: true, + env: { GIT_DIR: undefined, GIT_WORK_TREE: undefined, GIT_COMMON_DIR: undefined }, + }) + .pipe(Effect.mapError(failed)); + if (repository.exitCode === 0) { + // Nested checkouts and dirty submodules contain work a superproject snapshot cannot carry. + const nested = yield* gitRead(cwd, ["ls-files", "-z", "--others", "--exclude-standard"]); + if (nested.split("\0").some((entry) => entry.endsWith("/"))) + return yield* refused( + "Move or commit nested Git checkouts before handing off this thread.", + ); + const submodules = yield* gitRead(cwd, [ + "diff", + "HEAD", + "--ignore-submodules=none", + "--raw", + "--", + ]); + if (submodules.split("\n").some((entry) => /^:[0-7]{6} 160000 |^:160000 /.test(entry))) + return yield* refused( + "Commit or discard submodule changes before handing off this thread.", + ); + } + yield* fs.makeDirectory(directory, { recursive: true }).pipe(Effect.mapError(failed)); + const packed = yield* git.pack({ cwd, handoffId: handoff.handoffId, outDir: directory }).pipe( + Effect.catchTags({ HandoffNotARepositoryError: () => Effect.succeed(null) }), + Effect.mapError( + (cause) => new ThreadHandoffError({ reason: "refused", message: cause.message, cause }), + ), + ); + const identity = yield* identities.resolve(cwd, { refresh: true }); + const sourceRoot = + packed === null ? cwd : yield* gitRead(cwd, ["rev-parse", "--show-toplevel"]); + const input: OrchestratorMcpThreadImportInput = { + source: { environmentId: hereId, threadId: thread.id, handoffId: handoff.handoffId }, + projectId: handoff.projectId, + title: thread.title, + modelSelection: thread.modelSelection, + runtimeMode: thread.runtimeMode, + interactionMode: thread.interactionMode, + messages: yield* conversation(thread), + continuationPrompt: handoff.continuationPrompt?.trim() || DEFAULT_CONTINUATION, + ...(packed === null + ? {} + : { + bundle: { + attachmentId: "pending", + branch: packed.branch, + tip: packed.tip, + snapshot: packed.snapshot, + repository: { canonicalKey: identity?.canonicalKey ?? null, sourceRoot }, + }, + }), + }; + const recovery = { input, bundlePath: packed?.bundlePath ?? null, uncertain: false }; + const encoded = yield* encodeRecovery(recovery).pipe(Effect.mapError(failed)); + yield* fs + .writeFileString(`${manifest}.tmp`, encoded) + .pipe(Effect.andThen(fs.rename(`${manifest}.tmp`, manifest)), Effect.mapError(failed)); + return recovery; + }); + const upload = (thread: OrchestrationV2AppThread, handoff: Handoff, bundlePath: string) => + Effect.gen(function* () { + const bytes = yield* fs.readFile(bundlePath).pipe(Effect.mapError(failed)); + const prepared = yield* forwarding + .call( + scopeFor(thread), + AttachmentToolkit.tools.t3_attachment_prepare_upload, + handoff.environmentId, + { + upload: { + type: "file", + name: `${handoff.handoffId}.bundle`, + mimeType: "application/x-git-bundle", + sizeBytes: bytes.byteLength, + }, + }, + ) + .pipe(Effect.mapError(failed)); + const resolved = yield* links.resolve(handoff.environmentId).pipe(Effect.mapError(failed)); + const uploadUrl = new URL(prepared.relativeUrl, resolved.url); + if ( + uploadUrl.origin !== new URL(resolved.url).origin || + !prepared.relativeUrl.startsWith("/") + ) + return yield* refused("The destination returned an invalid upload location."); + const response = yield* http + .execute( + HttpClientRequest.post(uploadUrl.href).pipe( + HttpClientRequest.setBody(HttpBody.uint8Array(bytes, "application/x-git-bundle")), + ), + ) + .pipe(Effect.mapError(failed)); + yield* response.text.pipe(Effect.ignore); + if (response.status !== 204) + return yield* refused("The destination refused the bundle upload."); + return prepared.attachmentId; + }); + const depart = (thread: OrchestrationV2AppThread) => + Effect.gen(function* () { + const threadId = thread.id; + const handoff = thread.handoff; + if (handoff?.state !== "departing") return handoff; + let uncertain = true; + let sent = false; + let notAccepted = false; + const manifestExists = yield* fs + .exists(path.join(artifactDir(handoff.handoffId), "request.json")) + .pipe(Effect.mapError(failed)); + uncertain = manifestExists; + const outcome = yield* Effect.gen(function* () { + const recovery = yield* prepare(thread, handoff); + uncertain = recovery.uncertain; + yield* assertDestination(handoff.environmentId); + const input = + recovery.bundlePath === null + ? recovery.input + : { + ...recovery.input, + bundle: { + ...recovery.input.bundle!, + attachmentId: yield* upload(thread, handoff, recovery.bundlePath), + }, + }; + // Persist uncertainty before the remote call: a process exit can lose a committed reply too. + const manifest = path.join(artifactDir(handoff.handoffId), "request.json"); + yield* encodeRecovery({ ...recovery, uncertain: true }).pipe( + Effect.flatMap((encoded) => fs.writeFileString(`${manifest}.tmp`, encoded)), + Effect.andThen(fs.rename(`${manifest}.tmp`, manifest)), + Effect.mapError(failed), + ); + sent = true; + const result = yield* forwarding + .call( + scopeFor(thread), + ProjectToolkit.tools.t3_thread_import, + handoff.environmentId, + input, + ) + .pipe( + Effect.mapError((cause) => { + const proof = cause.handoffNotAccepted; + notAccepted = + proof !== undefined && + proof.source.environmentId === recovery.input.source.environmentId && + proof.source.threadId === recovery.input.source.threadId && + proof.source.handoffId === handoff.handoffId && + proof.projectId === handoff.projectId; + return new ThreadHandoffError({ reason: "failed", message: cause.message, cause }); + }), + ); + return result; + }).pipe(Effect.result); + if (outcome._tag === "Failure") { + if (notAccepted || (!sent && !uncertain)) { + yield* setHandoff( + threadId, + handoff, + { ...handoff, state: "failed", lastError: outcome.failure.message }, + "failed", + ); + yield* fs + .remove(artifactDir(handoff.handoffId), { recursive: true, force: true }) + .pipe(Effect.ignore); + } + return yield* Effect.fail(outcome.failure); + } + const departed: Handoff = { + state: "departed", + handoffId: handoff.handoffId, + environmentId: handoff.environmentId, + label: handoff.label, + projectId: handoff.projectId, + threadId: outcome.success.threadId, + }; + yield* setHandoff(threadId, handoff, departed, "departed"); + yield* fs + .remove(artifactDir(handoff.handoffId), { recursive: true, force: true }) + .pipe(Effect.ignore); + return departed; + }); + const start: ThreadHandoff["Service"]["start"] = (input) => + Effect.gen(function* () { + const threadId = input.threadId ?? input.callerThreadId; + if (threadId === undefined) + return yield* refused("Pass threadId for a client outside a T3 thread."); + return yield* locks.withLock( + threadId, + Effect.gen(function* () { + const current = yield* records(threadId); + const thread = current.thread; + if (input.handoffId !== undefined) { + const handoff = thread.handoff; + if ( + !isHandoffId(input.handoffId) || + handoff?.handoffId !== input.handoffId || + handoff.environmentId !== input.environmentId || + (input.projectId !== undefined && input.projectId !== handoff.projectId) || + input.continuationPrompt !== undefined || + (handoff.state !== "departing" && handoff.state !== "departed") + ) + return yield* refused( + "That move is no longer current, or its saved destination was changed.", + ); + if (handoff.state === "departed") return handoff; + if ( + !(yield* fs + .exists(path.join(artifactDir(handoff.handoffId), "request.json")) + .pipe(Effect.mapError(failed))) + ) + return yield* refused( + "The saved move is unavailable. Continue here to release this thread.", + ); + return (yield* depart(thread)) ?? handoff; + } + if ( + thread.archivedAt !== null || + thread.deletedAt !== null || + thread.lineage.relationshipToParent === "subagent" + ) + return yield* refused("Only an unarchived, top-level thread can move."); + if (thread.linkOrigin !== undefined && linkOriginIsFenced(thread.linkOrigin)) + return yield* refused("This linked thread cannot use this environment's links."); + if (thread.handoff != null && thread.handoff.state !== "failed") + return yield* refused(`This thread's move is already ${thread.handoff.state}.`); + const live = current.runs.some((run) => + ["preparing", "starting", "running", "waiting"].includes(run.status), + ); + const busy = live || current.runs.some((run) => run.status === "queued"); + if (busy && !(live && input.callerThreadId === threadId)) + return yield* refused( + "Wait for all running and queued work before moving this thread.", + ); + const projectId = yield* remoteProject(thread, input.environmentId, input.projectId); + const resolved = yield* links.resolve(input.environmentId).pipe(Effect.mapError(failed)); + const handoffId = yield* crypto.randomUUIDv4().pipe(Effect.orDie); + const base = { + handoffId, + environmentId: input.environmentId, + label: resolved.link.label, + projectId, + ...(input.continuationPrompt === undefined + ? {} + : { continuationPrompt: input.continuationPrompt }), + }; + const next: Handoff = busy + ? { + ...base, + state: "pending", + requestCommandId: CommandId.make(`handoff:${threadId}:${handoffId}:request`), + } + : { ...base, state: "departing" }; + yield* setHandoff(threadId, thread.handoff, next, "request", input.callerThreadId); + if (next.state === "pending") return next; + return (yield* depart({ ...thread, handoff: next })) ?? next; + }), + ); + }); + const cancel: ThreadHandoff["Service"]["cancel"] = (input) => + locks.withLock( + input.threadId, + Effect.gen(function* () { + const { thread } = yield* records(input.threadId); + const handoff = thread.handoff; + if (handoff?.handoffId !== input.handoffId) + return yield* refused("That move is no longer current."); + if (handoff.state === "pending") { + yield* setHandoff(input.threadId, handoff, null, "cancel"); + return; + } + if (handoff.state !== "departing" || input.abandon !== true) + return yield* refused("Confirm continuing here to abandon this uncertain move."); + yield* setHandoff( + input.threadId, + handoff, + { + ...handoff, + state: "failed", + lastError: "Move abandoned. The destination may already have a copy.", + }, + "abandon", + ); + yield* fs + .remove(artifactDir(handoff.handoffId), { recursive: true, force: true }) + .pipe(Effect.ignore); + }), + ); + const settle: ThreadHandoff["Service"]["settle"] = (threadId) => + locks + .withLock( + threadId, + Effect.gen(function* () { + const shell = yield* threads.getThreadShell(threadId).pipe(Effect.mapError(failed)); + if (shell?.handoff?.state !== "pending" && shell?.handoff?.state !== "departing") return; + const current = yield* records(threadId); + const handoff = current.thread.handoff; + if (handoff?.state === "pending") { + if (current.thread.archivedAt !== null || current.thread.deletedAt !== null) return; + if ( + current.runs.some((run) => + ["preparing", "starting", "running", "waiting"].includes(run.status), + ) + ) + return; + const next = { ...handoff, state: "departing" as const }; + yield* setHandoff(threadId, handoff, next, "departing"); + yield* depart({ ...current.thread, handoff: next }); + } else yield* depart(current.thread); + }), + ) + .pipe(Effect.asVoid, Effect.ignoreCause({ log: true })); + const options: ThreadHandoff["Service"]["options"] = (threadId) => + Effect.gen(function* () { + const { thread } = yield* records(threadId); + const listed = yield* links.list.pipe(Effect.mapError(failed)); + return yield* Effect.forEach( + listed, + (link) => + remoteProject(thread, link.environmentId).pipe( + Effect.map((projectId) => ({ + environmentId: link.environmentId, + label: link.label, + projectId, + reason: null, + })), + Effect.catch((error) => + Effect.succeed({ + environmentId: link.environmentId, + label: link.label, + projectId: null, + reason: error.message, + }), + ), + ), + { concurrency: 4 }, + ); + }); + const start_: ThreadHandoff["Service"]["start_"] = () => + forkParked( + Effect.gen(function* () { + const snapshot = yield* projections.getShellSnapshot().pipe(Effect.orDie); + yield* Effect.forEach( + snapshot.threads.filter( + (thread) => + thread.handoff?.state === "pending" || thread.handoff?.state === "departing", + ), + (thread) => settle(thread.id), + { discard: true }, + ); + yield* events + .stream({ afterSequence: snapshot.snapshotSequence, eventType: "run.updated" }) + .pipe( + Stream.filter( + ({ event }) => + event.type === "run.updated" && + ["completed", "failed", "cancelled", "interrupted", "rolled_back"].includes( + event.payload.status, + ), + ), + Stream.runForEach(({ event }) => settle(event.threadId)), + ); + }).pipe(Effect.ignoreCause({ log: true })), + ); + return ThreadHandoff.of({ options, start, cancel, settle, start_ }); +}); +export const layer = Layer.effect(ThreadHandoff, make); diff --git a/apps/server/src/project/ProjectService.ts b/apps/server/src/project/ProjectService.ts index 1cfb2e093340b..4e5b55f2ea753 100644 --- a/apps/server/src/project/ProjectService.ts +++ b/apps/server/src/project/ProjectService.ts @@ -32,6 +32,7 @@ import * as ThreadCommandExecutor from "../orchestration-v2/ThreadCommandExecuto import { planThreadDeletion } from "../orchestration-v2/ThreadDeletion.ts"; import * as ServerSettings from "../serverSettings.ts"; import * as ProjectEnrichmentService from "./ProjectEnrichmentService.ts"; +import * as RepositoryIdentityResolver from "./RepositoryIdentityResolver.ts"; import * as WorkspacePaths from "../workspace/WorkspacePaths.ts"; export interface ProjectCreateInput extends ProjectCreatePayload { @@ -131,6 +132,15 @@ export class ProjectService extends Context.Service< options?: { readonly includeDeleted?: boolean }, ) => Effect.Effect, ProjectOperationError>; readonly snapshot: Effect.Effect; + readonly list: (options?: { + readonly cursor?: number | undefined; + readonly limit?: number | undefined; + readonly resolveRepositoryIdentity?: boolean | undefined; + }) => Effect.Effect< + { readonly projects: ReadonlyArray; readonly nextCursor: number | null }, + ProjectOperationError, + RepositoryIdentityResolver.RepositoryIdentityResolver + >; readonly getShell: ( projectId: ProjectId, ) => Effect.Effect, ProjectOperationError>; @@ -562,6 +572,36 @@ export const make = Effect.gen(function* () { } satisfies ProjectSnapshot; }); + const list: ProjectService["Service"]["list"] = Effect.fn("ProjectService.list")( + function* (options) { + const rows = yield* projects + .list() + .pipe( + Effect.mapError( + (cause) => new ProjectOperationError({ operation: "list-projects", cause }), + ), + ); + const active = rows.filter((row) => row.deletedAt === null); + const start = Math.max(0, options?.cursor ?? 0); + const end = start + Math.min(100, Math.max(1, options?.limit ?? 20)); + const identities = options?.resolveRepositoryIdentity + ? yield* RepositoryIdentityResolver.RepositoryIdentityResolver + : undefined; + const page = yield* Effect.forEach( + active.slice(start, end), + (row) => + Effect.gen(function* () { + const project = yield* hydrate(row); + if (identities === undefined) return project; + const repositoryIdentity = yield* identities.resolve(row.workspaceRoot); + return { ...project, repositoryIdentity }; + }), + { concurrency: 4 }, + ); + return { projects: page, nextCursor: end < active.length ? end : null }; + }, + ); + return ProjectService.of({ create, bootstrap, @@ -570,6 +610,7 @@ export const make = Effect.gen(function* () { getById, getByWorkspaceRoot, snapshot, + list, getShell, listShells, }); diff --git a/apps/server/src/relay/AgentAwarenessRelay.test.ts b/apps/server/src/relay/AgentAwarenessRelay.test.ts index 285f2cfe1f9bc..21937704a5d75 100644 --- a/apps/server/src/relay/AgentAwarenessRelay.test.ts +++ b/apps/server/src/relay/AgentAwarenessRelay.test.ts @@ -263,6 +263,7 @@ const makeTestRelay = Effect.fnUntraced(function* ( update: unused, delete: unused, getByWorkspaceRoot: unused, + list: unused, snapshot: Effect.succeed({ projects: [] } as never), getShell: unused, listShells: unused, diff --git a/apps/server/src/server.ts b/apps/server/src/server.ts index 9b15a67653a5f..7ba3805682856 100644 --- a/apps/server/src/server.ts +++ b/apps/server/src/server.ts @@ -64,6 +64,9 @@ import * as ProviderInstanceRegistryHydration from "./provider/ProviderInstanceR import * as ProjectSettingsOverrideSweep from "./provider/ProjectSettingsOverrideSweep.ts"; import * as TerminalManager from "./terminal/Manager.ts"; import * as PeerMcpClient from "./peer/PeerMcpClient.ts"; +import * as HandoffGit from "./peer/handoff/HandoffGit.ts"; +import * as HandoffImport from "./peer/handoff/HandoffImport.ts"; +import * as ThreadHandoff from "./peer/handoff/ThreadHandoff.ts"; import * as PeerForwarding from "./peer/PeerForwarding.ts"; import * as PeerLinks from "./peer/PeerLinks.ts"; import * as McpHttpServer from "./mcp/McpHttpServer.ts"; @@ -696,6 +699,9 @@ const layerMakeRoutes = Layer.mergeAll( ServerHttp.layerStaticAndDevRoute, Ws.layer, ), + Layer.effectDiscard( + ThreadHandoff.ThreadHandoff.pipe(Effect.flatMap((handoff) => handoff.start_())), + ), McpHttpServer.layer.pipe( Layer.provide(ProviderAdapterRegistry.layerFromProviderInstanceRegistry), Layer.provide(McpOAuth.layerMcpClientAuthenticator), @@ -709,6 +715,14 @@ const layerMakeRoutes = Layer.mergeAll( Layer.provide(layerPullRequestService), Layer.provide(ServerBrowser.layer.pipe(Layer.provide(DesktopBrowserChannel.layer))), Layer.provide(PreviewBrowser.layer), + Layer.provide( + Layer.merge( + ThreadHandoff.layer.pipe( + Layer.provide(Layer.merge(ProjectionStoreV2.layer, RuntimeLayer.layerEventSink)), + ), + HandoffImport.layer, + ).pipe(Layer.provide(HandoffGit.layer)), + ), Layer.provide(PeerForwarding.layer.pipe(Layer.provide(PeerMcpClient.layer))), Layer.provide(PeerLinks.layer), Layer.provide(PreviewAutomationBroker.layer), diff --git a/apps/server/src/vcs/GitVcsDriver.ts b/apps/server/src/vcs/GitVcsDriver.ts index 5d382f16d2ec2..9dc87bb34150f 100644 --- a/apps/server/src/vcs/GitVcsDriver.ts +++ b/apps/server/src/vcs/GitVcsDriver.ts @@ -341,6 +341,10 @@ export class GitVcsDriver extends Context.Service< input: VcsListRefsInput, ) => Effect.Effect; readonly pullCurrentBranch: (cwd: string) => Effect.Effect; + readonly initializeSubmodules: ( + worktreePath: string, + options?: CreateWorktreeOptions, + ) => Effect.Effect; readonly createWorktree: ( input: VcsCreateWorktreeInput, options?: CreateWorktreeOptions, diff --git a/apps/server/src/vcs/GitVcsDriverCore.ts b/apps/server/src/vcs/GitVcsDriverCore.ts index f3120f5ebefd4..50ca191f16076 100644 --- a/apps/server/src/vcs/GitVcsDriverCore.ts +++ b/apps/server/src/vcs/GitVcsDriverCore.ts @@ -3445,6 +3445,83 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* }, ); + const initializeSubmodules: GitVcsDriver.GitVcsDriver["Service"]["initializeSubmodules"] = + Effect.fn("GitVcsDriver.initializeSubmodules")(function* (worktreePath, options) { + const progress = options?.progress; + // `git worktree add` leaves submodules empty, so a repo that keeps agent + // skills, tooling or source in one gets a worktree that is quietly missing + // them. + const hasSubmodules = yield* fileSystem + .exists(path.join(worktreePath, ".gitmodules")) + .pipe(Effect.orElseSucceed(() => false)); + const submoduleMode = !hasSubmodules + ? { value: "none" as const, source: "environment" as const } + : resolveProjectFileBackedSetting( + "worktreeSubmodules", + options?.submodules ?? null, + options?.submodules != null + ? null + : yield* fileSystem + .readFileString(path.join(worktreePath, T3_PROJECT_FILE_NAME)) + .pipe( + Effect.flatMap((contents) => { + const file = parseT3ProjectFile(contents); + return file === null + ? Effect.logWarning( + "t3.json is invalid; initializing submodules recursively", + { + worktreePath, + }, + ).pipe(Effect.as(null)) + : Effect.succeed(file); + }), + Effect.orElseSucceed(() => null), + ), + ); + if (hasSubmodules && submoduleMode.value === "none" && progress?.onSubmodulesDisabled) { + yield* progress.onSubmodulesDisabled({ + source: submoduleMode.source === "t3.json" ? "t3.json" : "settings", + }); + } + if (submoduleMode.value !== "none") { + if (progress?.onSubmodulesStarted) { + yield* progress.onSubmodulesStarted(); + } + const onSubmoduleLine = progress?.onSubmoduleLine; + yield* runGit( + "GitVcsDriver.createWorktree.updateSubmodules", + worktreePath, + submoduleMode.value === "recursive" + ? ["submodule", "update", "--init", "--recursive"] + : ["submodule", "update", "--init"], + onSubmoduleLine + ? { + env: { LC_ALL: "C" }, + progress: { onStdoutLine: onSubmoduleLine, onStderrLine: onSubmoduleLine }, + } + : {}, + ).pipe( + Effect.matchEffect({ + onFailure: (cause) => + Effect.logWarning("worktree submodule checkout failed; submodule paths are empty", { + worktreePath, + cause, + }).pipe( + Effect.andThen( + progress?.onSubmodulesFinished + ? progress.onSubmodulesFinished({ ok: false, detail: cause.message }) + : Effect.void, + ), + ), + onSuccess: () => + progress?.onSubmodulesFinished + ? progress.onSubmodulesFinished({ ok: true, detail: null }) + : Effect.void, + }), + ); + } + }); + const createWorktree: GitVcsDriver.GitVcsDriver["Service"]["createWorktree"] = Effect.fn( "createWorktree", )(function* (input, options) { @@ -3506,73 +3583,7 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* yield* progress.onWorktreeClaimed(worktreePath); } - // `git worktree add` leaves submodules empty, so a repo that keeps agent - // skills, tooling or source in one gets a worktree that is quietly missing - // them. - const hasSubmodules = yield* fileSystem - .exists(path.join(worktreePath, ".gitmodules")) - .pipe(Effect.orElseSucceed(() => false)); - const submoduleMode = !hasSubmodules - ? { value: "none" as const, source: "environment" as const } - : resolveProjectFileBackedSetting( - "worktreeSubmodules", - options?.submodules ?? null, - options?.submodules != null - ? null - : yield* fileSystem.readFileString(path.join(worktreePath, T3_PROJECT_FILE_NAME)).pipe( - Effect.flatMap((contents) => { - const file = parseT3ProjectFile(contents); - return file === null - ? Effect.logWarning("t3.json is invalid; initializing submodules recursively", { - worktreePath, - }).pipe(Effect.as(null)) - : Effect.succeed(file); - }), - Effect.orElseSucceed(() => null), - ), - ); - if (hasSubmodules && submoduleMode.value === "none" && progress?.onSubmodulesDisabled) { - yield* progress.onSubmodulesDisabled({ - source: submoduleMode.source === "t3.json" ? "t3.json" : "settings", - }); - } - if (submoduleMode.value !== "none") { - if (progress?.onSubmodulesStarted) { - yield* progress.onSubmodulesStarted(); - } - const onSubmoduleLine = progress?.onSubmoduleLine; - yield* runGit( - "GitVcsDriver.createWorktree.updateSubmodules", - worktreePath, - submoduleMode.value === "recursive" - ? ["submodule", "update", "--init", "--recursive"] - : ["submodule", "update", "--init"], - onSubmoduleLine - ? { - env: { LC_ALL: "C" }, - progress: { onStdoutLine: onSubmoduleLine, onStderrLine: onSubmoduleLine }, - } - : {}, - ).pipe( - Effect.matchEffect({ - onFailure: (cause) => - Effect.logWarning("worktree submodule checkout failed; submodule paths are empty", { - worktreePath, - cause, - }).pipe( - Effect.andThen( - progress?.onSubmodulesFinished - ? progress.onSubmodulesFinished({ ok: false, detail: cause.message }) - : Effect.void, - ), - ), - onSuccess: () => - progress?.onSubmodulesFinished - ? progress.onSubmodulesFinished({ ok: true, detail: null }) - : Effect.void, - }), - ); - } + yield* initializeSubmodules(worktreePath, options); if (input.newRefName && input.baseRefName) { const baseBranch = yield* resolveBranchName(input.cwd, input.baseRefName); @@ -4118,6 +4129,7 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* readConfigValue, resolveBranchName, listRefs, + initializeSubmodules, createWorktree: (input, options) => withListRefsInvalidation(input.cwd, createWorktree(input, options)), fetchPullRequestBranch: (input) => diff --git a/apps/server/src/ws.ts b/apps/server/src/ws.ts index 48e4bed7a172d..1bd93e122a96b 100644 --- a/apps/server/src/ws.ts +++ b/apps/server/src/ws.ts @@ -121,6 +121,7 @@ import * as ProviderSessionManager from "./orchestration-v2/ProviderSessionManag import * as ThreadLaunchService from "./orchestration-v2/ThreadLaunchService.ts"; import * as ThreadMessageIntake from "./orchestration-v2/ThreadMessageIntake.ts"; import * as IdAllocator from "@t3tools/provider-core/server/IdAllocator"; +import * as ThreadHandoff from "./peer/handoff/ThreadHandoff.ts"; import * as PeerLinks from "./peer/PeerLinks.ts"; import * as ScheduledTasks from "./scheduledTasks/ScheduledTaskService.ts"; import * as SecretRequests from "./secrets/SecretRequests.ts"; @@ -1262,6 +1263,7 @@ const layerWsRpc = ( const databaseStorage = yield* DatabaseStorage.DatabaseStorage; const serverSettings = yield* ServerSettings.ServerSettingsService; const peerLinks = yield* PeerLinks.PeerLinks; + const handoff = yield* ThreadHandoff.ThreadHandoff; const startup = yield* ServerRuntimeStartup.ServerRuntimeStartup; const workspaceEntries = yield* WorkspaceEntries.WorkspaceEntries; const workspaceFileSystem = yield* WorkspaceFileSystem.WorkspaceFileSystem; @@ -2352,6 +2354,10 @@ const layerWsRpc = ( [WS_METHODS.serverRunStorageCleanup]: () => storageCleanup.runNow, [WS_METHODS.serverGetStorageCleanupReport]: () => storageCleanup.reports, [WS_METHODS.serverGetDatabaseStorage]: () => databaseStorage.status, + [WS_METHODS.threadHandoffOptions]: ({ threadId }) => + handoff.options(threadId).pipe(Effect.map((options) => ({ options }))), + [WS_METHODS.threadHandoffStart]: (input) => handoff.start(input), + [WS_METHODS.threadHandoffCancel]: (input) => handoff.cancel(input).pipe(Effect.as({})), [WS_METHODS.peerLinksList]: () => peerLinks.list.pipe(Effect.map((links) => ({ links }))), [WS_METHODS.peerLinksLink]: (input) => peerLinks.link(input), [WS_METHODS.peerLinksUnlink]: ({ environmentId }) => @@ -3098,6 +3104,7 @@ export const layer = Layer.unwrap( const pullRequests = yield* PullRequestService.PullRequestService; const reviewGuides = yield* PullRequestReviewGuideService.PullRequestReviewGuideService; const peerLinks = yield* PeerLinks.PeerLinks; + const handoff = yield* ThreadHandoff.ThreadHandoff; const sql = yield* SqlClient.SqlClient; return HttpRouter.add( "GET", @@ -3189,6 +3196,7 @@ export const layer = Layer.unwrap( ), ), Layer.provide(Layer.succeed(PeerLinks.PeerLinks, peerLinks)), + Layer.provide(Layer.succeed(ThreadHandoff.ThreadHandoff, handoff)), Layer.provide( SourceControlDiscovery.layer.pipe( Layer.provide( diff --git a/packages/client-runtime/src/state/commandPermissions.test.ts b/packages/client-runtime/src/state/commandPermissions.test.ts index 61c6776393553..40c09db988bd6 100644 --- a/packages/client-runtime/src/state/commandPermissions.test.ts +++ b/packages/client-runtime/src/state/commandPermissions.test.ts @@ -13,6 +13,7 @@ import { AuthSettingsWriteScope, AuthSourceControlWriteScope, ThreadId, + ProjectId, EnvironmentId, ScheduledTaskId, WS_METHODS, @@ -136,6 +137,83 @@ describe("command permissions", () => { }), ), ); + it.effect.each([ + { + tag: WS_METHODS.threadHandoffStart, + input: { threadId: ThreadId.make("source-thread"), environmentId: other }, + response: { + state: "departing", + handoffId: "handoff-one", + environmentId: other, + projectId: ProjectId.make("destination-project"), + label: "Other", + }, + }, + { + tag: WS_METHODS.threadHandoffCancel, + input: { threadId: ThreadId.make("source-thread"), handoffId: "handoff-one" }, + response: {}, + }, + ] as const)( + "checks the source grant at execution before sending $tag", + ({ tag, input, response }) => + Effect.scoped( + Effect.gen(function* () { + const registry = yield* setup; + registry.mount(sessions(other)); + const rpc = vi.fn(() => Effect.succeed(response)); + const session = { client: { [tag]: rpc } } as unknown as RpcSession; + const supervisor = { + target: { environmentId: env, label: "Source" }, + session: yield* SubscriptionRef.make(Option.some(session)), + } as unknown as EnvironmentSupervisor["Service"]; + const run: EnvironmentRegistry["Service"]["run"] = (environmentId, effect) => { + expect(environmentId).toBe(env); + return Effect.provideService(effect, EnvironmentSupervisor, supervisor); + }; + const command = createEnvironmentRpcCommand( + Atom.runtime( + Layer.succeed(EnvironmentRegistry, { + run, + } as unknown as EnvironmentRegistry["Service"]), + ), + { label: tag, tag }, + ); + const target = { environmentId: env, input }; + registry.set(sessions(other), AsyncResult.success(grant(true))); + registry.set(sessions(env), AsyncResult.success(grant(true, AuthSettingsWriteScope))); + expect(registry.get(command.permissionAtom(env, input))).toBe(false); + const denied = yield* Effect.promise(() => command.run(registry, target)); + expect(denied._tag).toBe("Failure"); + if (denied._tag === "Failure") { + expect(Cause.squash(denied.cause)).toMatchObject({ + _tag: "EnvironmentAuthorizationError", + requiredPermission: AuthOrchestrationOperateScope, + }); + } + expect(rpc).not.toHaveBeenCalled(); + + registry.set(sessions(env), AsyncResult.success(grant(true))); + expect(registry.get(command.permissionAtom(env, input))).toBe(true); + registry.set(sessions(env), AsyncResult.success(grant(false))); + const revoked = yield* Effect.promise(() => command.run(registry, target)); + expect(revoked._tag).toBe("Failure"); + if (revoked._tag === "Failure") { + expect(Cause.squash(revoked.cause)).toMatchObject({ + _tag: "EnvironmentAuthorizationError", + requiredPermission: AuthOrchestrationOperateScope, + }); + } + expect(rpc).not.toHaveBeenCalled(); + + registry.set(sessions(other), AsyncResult.success(grant(false))); + registry.set(sessions(env), AsyncResult.success(grant(true))); + const allowed = yield* Effect.promise(() => command.run(registry, target)); + expect(allowed).toMatchObject({ _tag: "Success", value: response }); + expect(rpc).toHaveBeenCalledExactlyOnceWith(input); + }), + ), + ); it.effect("waits for an initial grant", () => Effect.scoped( Effect.gen(function* () { diff --git a/packages/contracts/src/clientRpcPermissions.ts b/packages/contracts/src/clientRpcPermissions.ts index a6a88c0c00d76..a50fcdabff92b 100644 --- a/packages/contracts/src/clientRpcPermissions.ts +++ b/packages/contracts/src/clientRpcPermissions.ts @@ -12,6 +12,8 @@ import { WS_METHODS } from "./rpc.ts"; /** Incremental client enforcement; the server still authorizes every request. */ export const CLIENT_GUARDED_RPC_SCOPES = { + [WS_METHODS.threadHandoffStart]: AuthOrchestrationOperateScope, + [WS_METHODS.threadHandoffCancel]: AuthOrchestrationOperateScope, [WS_METHODS.peerLinksLink]: AuthAccessWriteScope, [WS_METHODS.peerLinksUnlink]: AuthAccessWriteScope, [WS_METHODS.serverRunStorageCleanup]: AuthSettingsWriteScope, diff --git a/packages/contracts/src/environment.ts b/packages/contracts/src/environment.ts index 3cd6617c4e196..c2735fb09e20d 100644 --- a/packages/contracts/src/environment.ts +++ b/packages/contracts/src/environment.ts @@ -83,6 +83,7 @@ export const ExecutionEnvironmentCapabilities = Schema.Struct({ mcpModeLimitHeader: Schema.optionalKey(Schema.Boolean), linkFence: Schema.optionalKey(Schema.Boolean), threadImport: Schema.optionalKey(Schema.Boolean), + threadHandoff: Schema.optionalKey(Schema.Boolean), connectionProbe: Schema.optionalKey(Schema.Boolean), attachmentUploads: Schema.optionalKey(Schema.Boolean), questionAttachments: Schema.optionalKey(Schema.Boolean), diff --git a/packages/contracts/src/orchestrationV2.ts b/packages/contracts/src/orchestrationV2.ts index 0fcfa73d446f4..062b768348eb3 100644 --- a/packages/contracts/src/orchestrationV2.ts +++ b/packages/contracts/src/orchestrationV2.ts @@ -15,6 +15,7 @@ import { ContextHandoffId, ContextTransferId, EventId, + EnvironmentId, IsoDateTime, MessageId, NodeId, @@ -368,6 +369,46 @@ export const OrchestrationV2LimitRecoveryUpdate = Schema.Struct({ ); export type OrchestrationV2LimitRecoveryUpdate = typeof OrchestrationV2LimitRecoveryUpdate.Type; +export const ThreadHandoffId = TrimmedNonEmptyString.check( + Schema.isPattern(/^[a-zA-Z0-9_-]{1,128}$/), +); +export const OrchestrationV2ThreadHandoffState = Schema.Literals([ + "pending", + "departing", + "departed", + "failed", +]); +const handoffFields = { + handoffId: ThreadHandoffId, + environmentId: EnvironmentId, + label: Schema.String, + projectId: ProjectId, +}; +export const OrchestrationV2ThreadHandoff = Schema.Union([ + Schema.Struct({ + ...handoffFields, + state: Schema.Literal("pending"), + requestCommandId: CommandId, + continuationPrompt: Schema.optional(Schema.String), + }), + Schema.Struct({ + ...handoffFields, + state: Schema.Literal("departing"), + continuationPrompt: Schema.optional(Schema.String), + }), + Schema.Struct({ + ...handoffFields, + state: Schema.Literal("departed"), + threadId: ThreadId, + }), + Schema.Struct({ + ...handoffFields, + state: Schema.Literal("failed"), + lastError: Schema.String, + }), +]); +export type OrchestrationV2ThreadHandoff = typeof OrchestrationV2ThreadHandoff.Type; + export const OrchestrationV2AppThread = Schema.Struct({ ...OrchestrationV2CreationFields, id: ThreadId, @@ -385,6 +426,7 @@ export const OrchestrationV2AppThread = Schema.Struct({ activeProviderThreadId: Schema.NullOr(ProviderThreadId), historyOrigin: Schema.optional(OrchestrationV2ThreadHistoryOrigin), linkOrigin: Schema.optional(OrchestrationV2LinkOrigin), + handoff: Schema.optional(Schema.NullOr(OrchestrationV2ThreadHandoff)), lineage: OrchestrationV2AppThreadLineage, forkedFrom: Schema.NullOr( Schema.Union([ @@ -1830,6 +1872,7 @@ export const OrchestrationV2ThreadShell = Schema.Struct({ activeProviderThreadId: Schema.NullOr(ProviderThreadId), historyOrigin: Schema.optional(OrchestrationV2ThreadHistoryOrigin), linkOrigin: Schema.optional(OrchestrationV2LinkOrigin), + handoff: Schema.optional(Schema.NullOr(OrchestrationV2ThreadHandoff)), latestRunId: Schema.NullOr(RunId), latestRunRequestedAt: Schema.optional(Schema.NullOr(Schema.DateTimeUtc)), latestRunStartedAt: Schema.optional(Schema.NullOr(Schema.DateTimeUtc)), @@ -3034,6 +3077,19 @@ export type OrchestrationV2Command = typeof OrchestrationV2Command.Type; * send them. */ const OrchestrationV2InternalCommand = Schema.Union([ + Schema.Struct({ + type: Schema.Literal("thread.handoff.update"), + commandId: CommandId, + threadId: ThreadId, + expected: Schema.NullOr( + Schema.Struct({ + state: OrchestrationV2ThreadHandoffState, + handoffId: ThreadHandoffId, + }), + ), + handoff: Schema.NullOr(OrchestrationV2ThreadHandoff), + callerThreadId: Schema.optional(ThreadId), + }), Schema.Struct({ type: Schema.Literal("thread.pull-request-watch.sync"), commandId: CommandId, diff --git a/packages/contracts/src/orchestratorMcp.test.ts b/packages/contracts/src/orchestratorMcp.test.ts index 54d20cab2f58f..35e20fb81afc8 100644 --- a/packages/contracts/src/orchestratorMcp.test.ts +++ b/packages/contracts/src/orchestratorMcp.test.ts @@ -87,6 +87,46 @@ describe("orchestrator MCP contracts", () => { }, ); + it.each([ + { field: "handoffId", invalid: ["../../escape", "handoff:unsafe"] }, + { field: "branch", invalid: ["feature/../escape"] }, + { field: "tip", invalid: ["abc1234", "g".repeat(40)] }, + { field: "snapshot", invalid: ["abc1234", "g".repeat(64)] }, + ])("rejects malformed bundle $field at the import wire boundary", ({ field, invalid }) => { + const request = { + source: { + environmentId: "environment:source", + threadId: "thread:source", + handoffId: "handoff-one", + }, + projectId: "project:import", + title: "Continue the investigation", + modelSelection: { instanceId: "codex", model: "gpt-5.4" }, + messages: [{ role: "user", text: "Earlier question", createdAt: "2026-10-06T10:00:00.000Z" }], + bundle: { + attachmentId: "pending/handoff-one.bundle", + branch: "feature/continue", + tip: "a".repeat(40), + snapshot: "b".repeat(64), + repository: { canonicalKey: null, sourceRoot: "/source/repo" }, + }, + }; + expect(decodeThreadImportInput(request)).toEqual(request); + expect( + decodeThreadImportInput({ + ...request, + bundle: { ...request.bundle, branch: null, tip: "a".repeat(64), snapshot: "b".repeat(40) }, + }).bundle?.branch, + ).toBeNull(); + for (const value of invalid) { + const malformed = + field === "handoffId" + ? { ...request, source: { ...request.source, handoffId: value } } + : { ...request, bundle: { ...request.bundle, [field]: value } }; + expect(decodeThreadImportInputResult(malformed)._tag, field + ": " + value).toBe("Failure"); + } + }); + it("accepts explicit worktree isolation and keeps shared delegation as the default", () => { expect(decodeDelegateTaskInput({ task: "Review changes" }).isolation).toBeUndefined(); expect( diff --git a/packages/contracts/src/orchestratorMcp.ts b/packages/contracts/src/orchestratorMcp.ts index ce64f3fbc6afc..228d170819b33 100644 --- a/packages/contracts/src/orchestratorMcp.ts +++ b/packages/contracts/src/orchestratorMcp.ts @@ -34,6 +34,8 @@ import { import { ThreadTitleRegeneration } from "./threadTitle.ts"; import { OrchestrationV2ImportedMessage, + ThreadHandoffId, + OrchestrationV2ThreadHandoff, OrchestrationV2ImportedMessages, OrchestrationV2Actor, OrchestrationV2CreationSource, @@ -408,6 +410,8 @@ export const OrchestratorMcpThreadDetail = Schema.Struct({ linkedPullRequest: Schema.NullOr(ThreadLinkedPullRequest), /** Missing on older servers. */ pullRequests: Schema.optional(Schema.Array(OrchestratorMcpThreadPullRequest)), + /** Missing on older servers. */ + handoff: Schema.optional(Schema.NullOr(OrchestrationV2ThreadHandoff)), titleRegeneration: Schema.NullOr(ThreadTitleRegeneration), branch: Schema.NullOr(Schema.String), worktreePath: Schema.NullOr(Schema.String), @@ -543,11 +547,13 @@ export type OrchestratorMcpProviderCapability = typeof OrchestratorMcpProviderCa export const OrchestratorMcpImportedMessage = OrchestrationV2ImportedMessage; export type OrchestratorMcpImportedMessage = typeof OrchestratorMcpImportedMessage.Type; +const isThreadHandoffId = Schema.is(ThreadHandoffId); + export const OrchestratorMcpThreadImportInput = Schema.Struct({ source: Schema.Struct({ environmentId: EnvironmentId, threadId: ThreadId, - handoffId: TrimmedNonEmptyString.check(Schema.isMaxLength(128)), + handoffId: TrimmedNonEmptyString.check(Schema.isMaxLength(256)), }), projectId: ProjectId, title: TrimmedNonEmptyString.check(Schema.isMaxLength(512)), @@ -556,9 +562,38 @@ export const OrchestratorMcpThreadImportInput = Schema.Struct({ interactionMode: Schema.optional(ProviderInteractionMode), worktreePath: Schema.optional(TrimmedNonEmptyString), branch: Schema.optional(TrimmedNonEmptyString), + bundle: Schema.optional( + Schema.Struct({ + attachmentId: TrimmedNonEmptyString.check(Schema.isMaxLength(256)), + branch: Schema.NullOr( + TrimmedNonEmptyString.check( + Schema.isMaxLength(1024), + Schema.isPattern( + // oxlint-disable-next-line no-control-regex -- Git ref syntax excludes ASCII control characters. + /^(?!-)(?!.*(?:\.\.|@\{|\/\/|[~^:?*[\\\x00-\x20\x7f]))(?!.*(?:^|\/)\.)(?!.*\.lock(?:\/|$))(?!.*[/.]$).+$/, + ), + ), + ), + tip: Schema.String.check(Schema.isPattern(/^(?:[a-f0-9]{40}|[a-f0-9]{64})$/)), + snapshot: Schema.String.check(Schema.isPattern(/^(?:[a-f0-9]{40}|[a-f0-9]{64})$/)), + repository: Schema.optional( + Schema.Struct({ + canonicalKey: Schema.NullOr(TrimmedNonEmptyString), + sourceRoot: TrimmedNonEmptyString, + }), + ), + }), + ), messages: OrchestrationV2ImportedMessages, continuationPrompt: Schema.optional(OrchestratorMcpPrompt), -}); +}).check( + Schema.makeFilter( + (input) => + input.bundle === undefined || + isThreadHandoffId(input.source.handoffId) || + "Git bundle handoff IDs must contain only letters, digits, underscores, and hyphens.", + ), +); export type OrchestratorMcpThreadImportInput = typeof OrchestratorMcpThreadImportInput.Type; export const OrchestratorMcpThreadImportResult = Schema.Struct({ @@ -569,6 +604,16 @@ export const OrchestratorMcpThreadImportResult = Schema.Struct({ }); export type OrchestratorMcpThreadImportResult = typeof OrchestratorMcpThreadImportResult.Type; +export const OrchestratorMcpThreadHandoffInput = Schema.Struct({ + threadId: Schema.optional(ThreadId), + handoffId: Schema.optional(ThreadHandoffId), + environmentId: EnvironmentId, + projectId: Schema.optional(ProjectId), + continuationPrompt: Schema.optional(OrchestratorMcpPrompt), +}); +export type OrchestratorMcpThreadHandoffInput = typeof OrchestratorMcpThreadHandoffInput.Type; +export const OrchestratorMcpThreadHandoffResult = OrchestrationV2ThreadHandoff; + export const OrchestratorMcpCapabilitiesResult = Schema.Struct({ parentThreadId: Schema.NullOr(ThreadId), inheritedProviderInstanceId: Schema.NullOr(ProviderInstanceId), @@ -733,5 +778,11 @@ export class OrchestratorMcpFailure extends Schema.TaggedError()("PeerLink message: Schema.String, cause: Schema.optionalKey(Schema.Defect()), }) {} + +export class ThreadHandoffError extends Schema.TaggedError()( + "ThreadHandoffError", + { + reason: Schema.Literals(["not_found", "refused", "failed"]), + message: Schema.String, + cause: Schema.optionalKey(Schema.Defect()), + }, +) {} diff --git a/packages/contracts/src/rpc.ts b/packages/contracts/src/rpc.ts index 90f1be6f94be0..74f31c71be535 100644 --- a/packages/contracts/src/rpc.ts +++ b/packages/contracts/src/rpc.ts @@ -21,6 +21,7 @@ import { PeerLinkRemoveInput, PeerLinkRemoveResult, PeerLinkSummary, + ThreadHandoffError, } from "./peerLink.ts"; import { ChatGptReconnectProfileInput, @@ -33,7 +34,13 @@ import * as Schema from "effect/Schema"; import * as Rpc from "effect/rpc/Rpc"; import * as RpcGroup from "effect/rpc/RpcGroup"; import * as RpcMiddleware from "effect/rpc/RpcMiddleware"; -import { NonNegativeInt, TrimmedNonEmptyString } from "./baseSchemas.ts"; +import { + EnvironmentId, + ProjectId, + ThreadId, + NonNegativeInt, + TrimmedNonEmptyString, +} from "./baseSchemas.ts"; import { CodexAuthCallbackInput, CodexAuthCallbackState, @@ -223,6 +230,8 @@ import { OrchestrationV2CommandOutputError, OrchestrationV2GetThreadProjectionError, OrchestrationV2RpcSchemas, + OrchestrationV2ThreadHandoff, + ThreadHandoffId, OrchestrationV2ThreadLaunchError, } from "./orchestrationV2.ts"; import { @@ -381,6 +390,9 @@ import { Project, ProjectMutation, ProjectMutationError } from "./project.ts"; export const WS_METHODS = { peerLinksList: "peerLinks.list", + threadHandoffOptions: "threadHandoff.options", + threadHandoffStart: "threadHandoff.start", + threadHandoffCancel: "threadHandoff.cancel", peerLinksLink: "peerLinks.link", peerLinksUnlink: "peerLinks.unlink", projectsList: "projects.list", @@ -757,6 +769,41 @@ const WsServerGetDatabaseStorageRpc = Rpc.make(WS_METHODS.serverGetDatabaseStora error: EnvironmentAuthorizationError, }); +const WsThreadHandoffOptionsRpc = Rpc.make(WS_METHODS.threadHandoffOptions, { + payload: Schema.Struct({ threadId: ThreadId }), + success: Schema.Struct({ + options: Schema.Array( + Schema.Struct({ + environmentId: EnvironmentId, + label: Schema.String, + projectId: Schema.NullOr(ProjectId), + reason: Schema.NullOr(Schema.String), + }), + ), + }), + error: Schema.Union([ThreadHandoffError, EnvironmentAuthorizationError]), +}); +const WsThreadHandoffStartRpc = Rpc.make(WS_METHODS.threadHandoffStart, { + payload: Schema.Struct({ + threadId: ThreadId, + handoffId: Schema.optional(ThreadHandoffId), + environmentId: EnvironmentId, + projectId: Schema.optional(ProjectId), + continuationPrompt: Schema.optional(TrimmedNonEmptyString.check(Schema.isMaxLength(120_000))), + }), + success: OrchestrationV2ThreadHandoff, + error: Schema.Union([ThreadHandoffError, EnvironmentAuthorizationError]), +}); +const WsThreadHandoffCancelRpc = Rpc.make(WS_METHODS.threadHandoffCancel, { + payload: Schema.Struct({ + threadId: ThreadId, + handoffId: ThreadHandoffId, + abandon: Schema.optional(Schema.Boolean), + }), + success: Schema.Struct({}), + error: Schema.Union([ThreadHandoffError, EnvironmentAuthorizationError]), +}); + const WsPeerLinksListRpc = Rpc.make(WS_METHODS.peerLinksList, { payload: Schema.Struct({}), success: Schema.Struct({ links: Schema.Array(PeerLinkSummary) }), @@ -1881,6 +1928,9 @@ export class RpcScopeAuthorization extends RpcMiddleware.Service> = { ["Update", "Updating", "Updated", "environment preferences"], "environment-update", ), + t3_thread_handoff: tool(["Move", "Moving", "Moved", "a thread"], "thread-create"), t3_thread_import: tool(["Import", "Importing", "Imported", "a conversation"], "thread-create"), t3_thread_launch: tool(["Launch", "Launching", "Launched", "a project thread"], "thread-create"), t3_project_list: tool(["List", "Listing", "Listed", "projects"], "project-list"),