From 9458715adf57359a0452136c1a8d1a0b9c6c4655 Mon Sep 17 00:00:00 2001 From: Fuksss Date: Tue, 27 Oct 2026 15:44:18 +0100 Subject: [PATCH 1/2] fix(server): archive refuses a thread with running delegated tasks An idle parent can be archived while its delegated child still runs. The child's later result is then disposed without waking the parent. Reject archive while app-owned delegated tasks are active. Allow archive after the tasks finish, and explain the requirement in the subagent guide. Tests: rejects archiving an idle parent until its delegated child completes; runtimeLayer lifecycle, archive cancellation, replay and unarchive tests. --- .../src/orchestration-v2/Orchestrator.ts | 16 +++ .../src/orchestration-v2/runtimeLayer.test.ts | 121 ++++++++++++++++++ docs/user/thread-sidebar.md | 1 + 3 files changed, 138 insertions(+) diff --git a/apps/server/src/orchestration-v2/Orchestrator.ts b/apps/server/src/orchestration-v2/Orchestrator.ts index e27f32ef84420..d13d252f5a581 100644 --- a/apps/server/src/orchestration-v2/Orchestrator.ts +++ b/apps/server/src/orchestration-v2/Orchestrator.ts @@ -2948,6 +2948,22 @@ 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" && 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/runtimeLayer.test.ts b/apps/server/src/orchestration-v2/runtimeLayer.test.ts index 025191f098823..7d0cf3657dfea 100644 --- a/apps/server/src/orchestration-v2/runtimeLayer.test.ts +++ b/apps/server/src/orchestration-v2/runtimeLayer.test.ts @@ -3978,6 +3978,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 8cfee144b444e..752de8ad4dcdf 100644 --- a/docs/user/thread-sidebar.md +++ b/docs/user/thread-sidebar.md @@ -242,6 +242,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 From 1b90751c99df1ca3d23c56ea3e76d4d962c7f4b5 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Fri, 13 Nov 2026 00:09:04 +0000 Subject: [PATCH 2/2] fix(server): archive only waits on delegated tasks the user can stop A task running in a linked environment has no local child thread, and neither Stop nor subagent.stop can cancel it from here, so it must not block archiving its parent. Co-Authored-By: Claude Opus 5.5 --- apps/server/src/orchestration-v2/Orchestrator.ts | 5 ++++- .../src/orchestration-v2/RemoteDelegatedTask.test.ts | 7 +++++++ 2 files changed, 11 insertions(+), 1 deletion(-) diff --git a/apps/server/src/orchestration-v2/Orchestrator.ts b/apps/server/src/orchestration-v2/Orchestrator.ts index feba6a14f8748..b7ddd77de49c9 100644 --- a/apps/server/src/orchestration-v2/Orchestrator.ts +++ b/apps/server/src/orchestration-v2/Orchestrator.ts @@ -4000,7 +4000,10 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio .pipe(mapDispatchError(command)); if ( records.subagents.some( - (task) => task.origin === "app_owned" && isOrchestrationV2WorkActive(task.status), + (task) => + task.origin === "app_owned" && + task.childThreadId !== null && + isOrchestrationV2WorkActive(task.status), ) ) { return yield* new OrchestratorDispatchError({ 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); }), );