Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 26 additions & 0 deletions apps/server/src/orchestration-v2/Orchestrator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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
? {}
: {
Expand Down
72 changes: 72 additions & 0 deletions apps/server/src/orchestration-v2/runtimeLayer.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
1 change: 1 addition & 0 deletions packages/contracts/src/orchestrationV2.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)),
Expand Down
Loading