diff --git a/apps/server/src/orchestration-v2/Orchestrator.ts b/apps/server/src/orchestration-v2/Orchestrator.ts index e27f32ef84420..14a18be8a56e7 100644 --- a/apps/server/src/orchestration-v2/Orchestrator.ts +++ b/apps/server/src/orchestration-v2/Orchestrator.ts @@ -2941,6 +2941,29 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio cause: `Thread ${command.threadId} is no longer empty.`, }); } + const targetProjectId = + command.type === "thread.metadata.update" && command.projectId !== thread.projectId + ? command.projectId + : undefined; + if (targetProjectId !== undefined) { + const project = yield* projects.get(targetProjectId).pipe(mapDispatchError(command)); + const { subagents } = yield* projectionStore + .getThreadRecords(command.threadId, ["subagents"]) + .pipe(mapDispatchError(command)); + if ( + Option.isNone(project) || + project.value.deletedAt !== null || + thread.settledAt === null || + thread.lineage.relationshipToParent === "subagent" || + subagents.some((task) => task.childThreadId !== null) + ) { + return yield* new OrchestratorDispatchError({ + commandId: command.commandId, + commandType: command.type, + cause: `Only a settled top-level thread without child threads can move to an existing project.`, + }); + } + } if (command.type === "thread.archive" && thread.archivedAt !== null) { return yield* new OrchestratorDispatchError({ commandId: command.commandId, @@ -3387,6 +3410,9 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio : {}), ...(command.branch === undefined ? {} : { branch: command.branch }), ...(command.worktreePath === undefined ? {} : { worktreePath: command.worktreePath }), + ...(targetProjectId === undefined + ? {} + : { projectId: targetProjectId, branch: null, worktreePath: null }), ...(command.linkedPullRequest === undefined ? {} : { diff --git a/apps/server/src/orchestration-v2/runtimeLayer.test.ts b/apps/server/src/orchestration-v2/runtimeLayer.test.ts index 025191f098823..cd668040aa186 100644 --- a/apps/server/src/orchestration-v2/runtimeLayer.test.ts +++ b/apps/server/src/orchestration-v2/runtimeLayer.test.ts @@ -1872,6 +1872,78 @@ it.layer(layerLegacyImportTest)("OrchestrationV2 legacy import", (it) => { }); it.layer(layerTest)("OrchestrationV2LayerLive lifecycle", (it) => { + it.effect("moves a settled thread to another project", () => + Effect.gen(function* () { + const orchestrator = yield* Orchestrator.OrchestratorV2; + const threadId = ThreadId.make("runtime-layer-move-thread"); + const fromProjectId = ProjectId.make("runtime-layer-move-from"); + const toProjectId = ProjectId.make("runtime-layer-move-to"); + for (const projectId of [fromProjectId, toProjectId]) { + yield* seedProject({ + projectId, + title: projectId, + workspaceRoot: `/workspace/${projectId}`, + defaultModelSelection: null, + createdAt: "2026-09-07T00:00:00.000Z", + }); + } + yield* orchestrator.dispatch({ + type: "thread.create", + createdBy: "user", + creationSource: "web", + commandId: CommandId.make("runtime-layer-move-create"), + threadId, + projectId: fromProjectId, + title: "Move me", + modelSelection, + runtimeMode: "full-access", + interactionMode: "default", + branch: "feature/move", + worktreePath: "/workspace/move-from-worktree", + }); + yield* orchestrator.dispatch({ + type: "thread.settle", + commandId: CommandId.make("runtime-layer-move-settle"), + threadId, + }); + yield* orchestrator.dispatch({ + type: "thread.metadata.update", + commandId: CommandId.make("runtime-layer-move-to"), + threadId, + projectId: toProjectId, + }); + const moved = (yield* orchestrator.getThreadProjection(threadId)).thread; + assert.deepInclude(moved, { projectId: toProjectId, branch: null, worktreePath: null }); + const shell = yield* orchestrator.getShellSnapshot(); + assert.equal( + shell.threads.find((candidate) => candidate.id === threadId)?.projectId, + toProjectId, + ); + yield* orchestrator.dispatch({ + type: "message.dispatch", + createdBy: "user", + creationSource: "web", + commandId: CommandId.make("runtime-layer-move-message"), + threadId, + messageId: MessageId.make("runtime-layer-move-message"), + text: "Unsettle this thread.", + attachments: [], + modelSelection, + dispatchMode: { type: "start_immediately" }, + }); + assert.isNull((yield* orchestrator.getThreadProjection(threadId)).thread.settledAt); + const unsettled = yield* orchestrator + .dispatch({ + type: "thread.metadata.update", + commandId: CommandId.make("runtime-layer-move-unsettled"), + threadId, + projectId: fromProjectId, + }) + .pipe(Effect.flip); + assert.instanceOf(unsettled, Orchestrator.OrchestratorDispatchError); + }), + ); + it.effect("applies lifecycle commands idempotently and emits archive/removal shell deltas", () => Effect.gen(function* () { const orchestrator = yield* Orchestrator.OrchestratorV2; diff --git a/packages/contracts/src/orchestrationV2.ts b/packages/contracts/src/orchestrationV2.ts index f4e8303972267..9c451bf783b6b 100644 --- a/packages/contracts/src/orchestrationV2.ts +++ b/packages/contracts/src/orchestrationV2.ts @@ -2893,6 +2893,7 @@ export const OrchestrationV2Command = Schema.Union([ threadId: ThreadId, title: Schema.optional(TrimmedNonEmptyString), regenerateTitle: Schema.optional(Schema.Boolean), + projectId: Schema.optional(ProjectId), branch: Schema.optional(Schema.NullOr(TrimmedNonEmptyString)), worktreePath: Schema.optional(Schema.NullOr(TrimmedNonEmptyString)), expectedWorktreePath: Schema.optional(Schema.NullOr(TrimmedNonEmptyString)),