diff --git a/apps/server/src/orchestration-v2/Orchestrator.ts b/apps/server/src/orchestration-v2/Orchestrator.ts index 568025d523b38..b7ddd77de49c9 100644 --- a/apps/server/src/orchestration-v2/Orchestrator.ts +++ b/apps/server/src/orchestration-v2/Orchestrator.ts @@ -3994,6 +3994,25 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio cause: `Thread ${command.threadId} is already archived.`, }); } + if (command.type === "thread.archive") { + const records = yield* projectionStore + .getThreadRecords(command.threadId, ["subagents"]) + .pipe(mapDispatchError(command)); + if ( + records.subagents.some( + (task) => + task.origin === "app_owned" && + task.childThreadId !== null && + isOrchestrationV2WorkActive(task.status), + ) + ) { + return yield* new OrchestratorDispatchError({ + commandId: command.commandId, + commandType: command.type, + cause: `Thread ${command.threadId} has running delegated tasks. Wait for them or cancel them before archiving.`, + }); + } + } if (command.type === "thread.unarchive" && thread.archivedAt === null) { return yield* new OrchestratorDispatchError({ commandId: command.commandId, diff --git a/apps/server/src/orchestration-v2/RemoteDelegatedTask.test.ts b/apps/server/src/orchestration-v2/RemoteDelegatedTask.test.ts index f8363bef98f9a..f075b43d9f882 100644 --- a/apps/server/src/orchestration-v2/RemoteDelegatedTask.test.ts +++ b/apps/server/src/orchestration-v2/RemoteDelegatedTask.test.ts @@ -226,6 +226,13 @@ it.layer(layerTest)("a delegated task in a linked environment", (it) => { const index = yield* (yield* ProjectionStore.ProjectionStoreV2).listActiveShellIndex(); assert.isTrue(index.find((entry) => entry.id === parent.threadId)?.hasRemoteTasks); assert.isFalse(index.find((entry) => entry.id === other.threadId)?.hasRemoteTasks); + // Nothing here can cancel the task there, so it must not block archiving. + yield* orchestrator.dispatch({ + type: "thread.archive", + commandId: CommandId.make("command:archive:records"), + threadId: parent.threadId, + }); + assert.isNotNull((yield* orchestrator.getThreadShell(parent.threadId))?.archivedAt); }), ); diff --git a/apps/server/src/orchestration-v2/runtimeLayer.test.ts b/apps/server/src/orchestration-v2/runtimeLayer.test.ts index c66af723d5d86..647c6bc0880f6 100644 --- a/apps/server/src/orchestration-v2/runtimeLayer.test.ts +++ b/apps/server/src/orchestration-v2/runtimeLayer.test.ts @@ -6394,6 +6394,127 @@ it.layer(layerTest)("OrchestrationV2LayerLive lifecycle", (it) => { }), ); + it.effect("rejects archiving an idle parent until its delegated child completes", () => + Effect.gen(function* () { + const orchestrator = yield* Orchestrator.OrchestratorV2; + const sink = yield* EventSink.EventSinkV2; + const threadId = ThreadId.make("archive-delegated-parent"); + const projectId = ProjectId.make("archive-delegated-project"); + yield* seedProject({ + projectId, + title: "Delegated archive", + workspaceRoot: "/workspace/archive-delegated", + defaultModelSelection: null, + createdAt: "2026-09-07T00:00:00.000Z", + }); + yield* orchestrator.dispatch({ + type: "thread.create", + createdBy: "user", + creationSource: "web", + commandId: CommandId.make("archive-delegated-create"), + threadId, + projectId, + title: "Delegated archive", + modelSelection, + runtimeMode: "full-access", + interactionMode: "default", + branch: null, + worktreePath: null, + }); + yield* orchestrator.dispatch({ + type: "message.dispatch", + createdBy: "user", + creationSource: "web", + commandId: CommandId.make("archive-delegated-message"), + threadId, + messageId: MessageId.make("archive-delegated-message"), + text: "Delegate the work, then finish your turn.", + attachments: [], + modelSelection, + dispatchMode: { type: "start_immediately" }, + }); + const parentRun = (yield* orchestrator.getThreadProjection(threadId)).runs[0]!; + yield* orchestrator.dispatch({ + type: "delegated_task.request", + createdBy: "agent", + creationSource: "mcp", + commandId: CommandId.make("archive-delegated-request"), + parentThreadId: threadId, + parentRunId: parentRun.id, + parentNodeId: parentRun.rootNodeId!, + task: "Keep working after the parent finishes.", + modelSelection, + runtimeMode: "full-access", + interactionMode: "default", + completionWake: "always", + }); + const delegatedParentRun = (yield* orchestrator.getThreadProjection(threadId)).runs[0]!; + const now = yield* DateTime.now; + yield* sink.write({ + events: [ + { + id: EventId.make("archive-delegated-parent-completed"), + type: "run.updated", + threadId, + runId: parentRun.id, + occurredAt: now, + payload: { ...delegatedParentRun, status: "completed", completedAt: now }, + }, + ], + }); + const parent = yield* orchestrator.getThreadProjection(threadId); + const task = parent.subagents[0]!; + assert.equal(parent.runs[0]?.status, "completed"); + assert.equal(task.origin, "app_owned"); + assert.equal(task.status, "running"); + assert.isNotNull(task.childThreadId); + + // Archive must preserve the idle parent's path for receiving this child's result. + const error = yield* orchestrator + .dispatch({ + type: "thread.archive", + commandId: CommandId.make("archive-delegated-blocked"), + threadId, + }) + .pipe(Effect.flip); + assert.instanceOf(error, Orchestrator.OrchestratorDispatchError); + assert.equal( + error.cause, + `Thread ${threadId} has running delegated tasks. Wait for them or cancel them before archiving.`, + ); + assert.isNull((yield* orchestrator.getThreadProjection(threadId)).thread.archivedAt); + + const childThreadId = task.childThreadId!; + const childRun = (yield* orchestrator.getThreadProjection(childThreadId)).runs[0]!; + yield* sink.write({ + events: [ + { + id: EventId.make("archive-delegated-child-run-completed"), + type: "run.updated", + threadId: childThreadId, + runId: childRun.id, + occurredAt: now, + payload: { ...childRun, status: "completed", completedAt: now }, + }, + { + id: EventId.make("archive-delegated-child-completed"), + type: "subagent.updated", + threadId, + nodeId: task.id, + occurredAt: now, + payload: { ...task, status: "completed", result: "Done", completedAt: now }, + }, + ], + }); + yield* orchestrator.dispatch({ + type: "thread.archive", + commandId: CommandId.make("archive-delegated-allowed"), + threadId, + }); + assert.isNotNull((yield* orchestrator.getThreadProjection(threadId)).thread.archivedAt); + }), + ); + it.effect("cancels preparing work on archive and keeps it cancelled through replay", () => Effect.gen(function* () { const orchestrator = yield* Orchestrator.OrchestratorV2; diff --git a/docs/user/thread-sidebar.md b/docs/user/thread-sidebar.md index 09ce63e2a3e65..711203dc51ab0 100644 --- a/docs/user/thread-sidebar.md +++ b/docs/user/thread-sidebar.md @@ -273,6 +273,7 @@ On mobile, open a subagent from its conversation card or **Agents**. On iOS layouts with an inspector, its thread opens beside the parent. Maximize it to fill the screen; close the full-screen view to return to the parent and inspector. Stop on a thread also stops the subagents it delegated to. +Before archiving the parent, wait for its delegated tasks to finish or cancel them. Subagent threads started by the agent can't take messages; message the parent thread instead. When such a subagent needs an approval or an answer, the parent