From 0d5fb3b1a4fdf1ad651699f90fa54f16b4eca278 Mon Sep 17 00:00:00 2001 From: Jackson Miller Date: Thu, 5 Nov 2026 22:29:40 -0500 Subject: [PATCH 1/4] fix(server): archiving a thread also archives its side chats Archiving a thread left its side chats running and reachable only by URL: their work kept going, they still took new messages, and unarchiving the parent showed them again. Deleting a thread already takes its side chats. Archive and unarchive now cascade to the parent's live side chats the way delete does: the same lookup, the parent's lock before the side chats' locks, and one commit for all of them. Each side chat is archived exactly as archiving it directly would, which detaches its provider sessions and cancels its queued and preparing runs. Unarchiving the parent restores the side chats its archive took, and leaves ones archived before it archived. Co-Authored-By: Claude Opus 5.5 --- .../src/orchestration-v2/Orchestrator.ts | 39 ++++- .../ThreadFork.execution.test.ts | 144 +++++++++++++++++- docs/user/thread-sidebar.md | 3 +- 3 files changed, 176 insertions(+), 10 deletions(-) diff --git a/apps/server/src/orchestration-v2/Orchestrator.ts b/apps/server/src/orchestration-v2/Orchestrator.ts index de632876f76a7..ec49098cd3cc5 100644 --- a/apps/server/src/orchestration-v2/Orchestrator.ts +++ b/apps/server/src/orchestration-v2/Orchestrator.ts @@ -1826,7 +1826,7 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio Effect.mapError((cause) => new OrchestratorProjectionError({ threadId, cause })), ); - /** Side chats live inside their parent, so deleting the parent deletes them too. */ + /** Side chats live inside their parent, so deleting or archiving the parent takes them too. */ const liveSideChatIds = Effect.fn("Orchestrator.liveSideChatIds")( function* (threadId: ThreadId) { const { contextTransfers } = yield* projectionStore @@ -15015,7 +15015,32 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio }; } case "thread.archive": - case "thread.unarchive": + case "thread.unarchive": { + const parentArchivedAt = (yield* findThread(command.threadId))?.archivedAt ?? null; + yield* dispatchThreadMutation(command, events, effects); + // Unarchive restores the side chats this parent's archive took, not ones archived before it. + for (const threadId of sideChatIds) { + const sideChatArchivedAt = (yield* findThread(threadId))?.archivedAt ?? null; + const follows = + command.type === "thread.archive" + ? sideChatArchivedAt === null + : sideChatArchivedAt !== null && + parentArchivedAt !== null && + DateTime.toEpochMillis(sideChatArchivedAt) >= + DateTime.toEpochMillis(parentArchivedAt); + if (!follows) continue; + yield* dispatchThreadMutation( + { + ...command, + commandId: CommandId.make(`${command.commandId}:side-chat:${threadId}`), + threadId, + }, + events, + effects, + ); + } + break; + } case "thread.settle": case "thread.unsettle": case "thread.snooze": @@ -15351,7 +15376,7 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio } satisfies OrchestratorV2DispatchResult; }); - /** `sideChatIds` are the live side chats a `thread.delete` cascades to, read under their locks. */ + /** `sideChatIds` are the live side chats a delete, archive, or unarchive cascades to, read under their locks. */ const dispatchWithReceiptEffect = Effect.fn("orchestrationV2.dispatch.withReceipt")(function* ( command: OrchestrationV2ServerCommand, sideChatIds: ReadonlyArray = [], @@ -15624,7 +15649,7 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio commandThreadId(command), dispatchWithReceiptEffect(command), ); - // A parent's lock comes before its side chats' locks, so a delete cascade and a side chat + // A parent's lock comes before its side chats' locks, so a cascade and a side chat // fork or command never interleave. A fork from a side chat or onto an existing thread would // invert that order; dispatch rejects both, so they skip the source lock. if ( @@ -15638,7 +15663,11 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio ? dispatch : threadDispatch.withLock(command.sourceThreadId, dispatch); } - if (command.type === "thread.delete") + if ( + command.type === "thread.delete" || + command.type === "thread.archive" || + command.type === "thread.unarchive" + ) return yield* threadDispatch.withLock( command.threadId, liveSideChatIds(command.threadId).pipe( diff --git a/apps/server/src/orchestration-v2/ThreadFork.execution.test.ts b/apps/server/src/orchestration-v2/ThreadFork.execution.test.ts index 745980da07988..ba7f09502fbbc 100644 --- a/apps/server/src/orchestration-v2/ThreadFork.execution.test.ts +++ b/apps/server/src/orchestration-v2/ThreadFork.execution.test.ts @@ -10,6 +10,7 @@ import { ProjectId, ProviderDriverKind, ProviderInstanceId, + ProviderSessionId, ProviderThreadId, ProviderTurnId, RunAttemptId, @@ -20,12 +21,15 @@ import { import * as DateTime from "effect/DateTime"; import * as Deferred from "effect/Deferred"; import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; import * as Statement from "effect/sql/Statement"; import * as Stream from "effect/Stream"; import * as TestClock from "effect/testing/TestClock"; +import * as SqlitePersistence from "../persistence/Sqlite.ts"; import { CodexProviderCapabilitiesV2 } from "./Adapters/CodexAdapterV2.ts"; import { ClaudeProviderCapabilitiesV2 } from "./Adapters/ClaudeAdapterV2.ts"; +import * as EffectOutbox from "./EffectOutbox.ts"; import * as EffectWorker from "./EffectWorker.ts"; import * as EventSink from "./EventSink.ts"; import * as Orchestrator from "./Orchestrator.ts"; @@ -52,10 +56,14 @@ const forkCases = ( planSelectionTransition: () => Effect.succeed({ type: "apply_on_next_turn" }), openSession: () => Effect.die("Execution is paused after dispatch for handoff inspection"), }; - const layer = ProviderReplayHarness.layerWithRegistry( - { name: `fork-boundary-${driver}` }, - ProviderAdapterRegistry.layerFromAdapters([adapter]), - { runEffectWorker: false }, + const layerDatabase = SqlitePersistence.layerMemory; + const layer = Layer.merge( + ProviderReplayHarness.layerWithRegistry( + { name: `fork-boundary-${driver}` }, + ProviderAdapterRegistry.layerFromAdapters([adapter]), + { runEffectWorker: false, databaseLayer: layerDatabase }, + ), + EffectOutbox.layer.pipe(Layer.provide(layerDatabase)), ); return { driver, status, instanceId, modelSelection, layer }; @@ -1169,6 +1177,134 @@ it.effect("closing a side chat deletes it only while unused; a deleted parent ta }).pipe(Effect.provide(forkCases[0]!.layer)), ); +it.effect( + "archiving a parent stops and archives its side chats; unarchiving brings them back", + () => + Effect.gen(function* () { + const orchestrator = yield* Orchestrator.OrchestratorV2; + const eventSink = yield* EventSink.EventSinkV2; + const outbox = yield* EffectOutbox.EffectOutboxV2; + const { driver, instanceId } = forkCases[0]!; + const parentId = ThreadId.make("side-chat-archive-parent"); + const busy = ThreadId.make("side-chat-archive-busy"); + const archivedFirst = ThreadId.make("side-chat-archive-archived-first"); + yield* createParent(parentId); + yield* writeParentRun({ + threadId: parentId, + runId: RunId.make("side-chat-archive-parent-run"), + ordinal: 1, + status: "completed", + text: "Parent work", + stable: true, + }); + yield* forkSideChat("side-chat-archive-fork-busy", parentId, busy); + yield* forkSideChat("side-chat-archive-fork-archived-first", parentId, archivedFirst); + const runningRunId = RunId.make("side-chat-archive-busy-running"); + const queuedRunId = RunId.make("side-chat-archive-busy-queued"); + yield* writeParentRun({ + threadId: busy, + runId: runningRunId, + ordinal: 1, + status: "running", + text: "Run a long command", + }); + yield* writeParentRun({ + threadId: busy, + runId: queuedRunId, + ordinal: 2, + status: "queued", + text: "Then this", + }); + const now = yield* DateTime.now; + for (const threadId of [parentId, busy]) { + yield* eventSink.write({ + events: [ + { + id: EventId.make(`${threadId}:session`), + type: "provider-session.attached", + threadId, + driver, + providerInstanceId: instanceId, + occurredAt: now, + payload: { + id: ProviderSessionId.make(`${threadId}:session`), + driver, + providerInstanceId: instanceId, + status: "running", + cwd: "/workspace", + model: "test-model", + capabilities: CodexProviderCapabilitiesV2, + createdAt: now, + updatedAt: now, + lastError: null, + }, + }, + ], + }); + } + const archive = (commandId: string, threadId: ThreadId) => + orchestrator.dispatch({ + type: "thread.archive", + commandId: CommandId.make(commandId), + threadId, + }); + const archivedAt = (threadId: ThreadId) => + orchestrator + .getThreadProjection(threadId) + .pipe(Effect.map(({ thread }) => thread.archivedAt)); + + // Archiving a side chat on its own leaves its parent alone. + yield* archive("side-chat-archive-only-child", archivedFirst); + const childArchivedAt = yield* archivedAt(archivedFirst); + assert.isNotNull(childArchivedAt); + assert.isNull(yield* archivedAt(parentId)); + yield* TestClock.adjust("1 second"); + + yield* archive("side-chat-archive-parent", parentId); + assert.isNotNull(yield* archivedAt(parentId)); + const archivedBusy = yield* orchestrator.getThreadProjection(busy); + assert.isNotNull(archivedBusy.thread.archivedAt); + assert.equal(archivedBusy.runs.find((run) => run.id === queuedRunId)?.status, "cancelled"); + assert.isEmpty(archivedBusy.providerSessions); + assert.deepEqual(yield* archivedAt(archivedFirst), childArchivedAt); + // The side chat's session is detached the way the parent's is. + const stopEffects = (commandId: string) => + outbox + .listByCommandId(CommandId.make(commandId)) + .pipe( + Effect.map((effects) => + effects.map(({ threadId, request }) => ({ threadId, request })), + ), + ); + const stopsFor = (threadId: ThreadId) => [ + { + threadId, + request: { + type: "provider-session.detach" as const, + providerSessionId: ProviderSessionId.make(`${threadId}:session`), + detail: "Thread archived.", + revokeMcpCredential: true, + }, + }, + { threadId, request: { type: "terminal.cleanup" as const } }, + ]; + assert.deepEqual(yield* stopEffects("side-chat-archive-parent"), stopsFor(parentId)); + assert.deepEqual( + yield* stopEffects(`side-chat-archive-parent:side-chat:${busy}`), + stopsFor(busy), + ); + + yield* orchestrator.dispatch({ + type: "thread.unarchive", + commandId: CommandId.make("side-chat-archive-unarchive-parent"), + threadId: parentId, + }); + assert.isNull(yield* archivedAt(parentId)); + assert.isNull(yield* archivedAt(busy)); + assert.deepEqual(yield* archivedAt(archivedFirst), childArchivedAt); + }).pipe(Effect.provide(forkCases[0]!.layer)), +); + it.effect("a side chat opened during the parent's first turn starts from a digest", () => Effect.gen(function* () { const orchestrator = yield* Orchestrator.OrchestratorV2; diff --git a/docs/user/thread-sidebar.md b/docs/user/thread-sidebar.md index 4c02f1c79baee..a646f7f5b7583 100644 --- a/docs/user/thread-sidebar.md +++ b/docs/user/thread-sidebar.md @@ -317,7 +317,8 @@ your next message there. Bring back is available while the thread is idle. Closing a side chat you never sent a message in deletes it. To delete one you used, choose **Delete** at the top of the side chat. Deleting a thread also deletes its -side chats. +side chats. Archiving a thread also archives its side chats and stops their work, and +unarchiving the thread brings them back. On mobile, a thread lists its side chats above the composer. Tap one to open it and answer its questions. From d087099a943cc6c46165b32cf1da6406cd6880a7 Mon Sep 17 00:00:00 2001 From: Jackson Miller Date: Fri, 6 Nov 2026 17:38:08 -0500 Subject: [PATCH 2/4] test(server): an archived parent ends its side chat's guest share Co-Authored-By: Claude Opus 5.5 --- .../orchestration-v2/ThreadFork.execution.test.ts | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/apps/server/src/orchestration-v2/ThreadFork.execution.test.ts b/apps/server/src/orchestration-v2/ThreadFork.execution.test.ts index ba7f09502fbbc..81e52fb204f8d 100644 --- a/apps/server/src/orchestration-v2/ThreadFork.execution.test.ts +++ b/apps/server/src/orchestration-v2/ThreadFork.execution.test.ts @@ -5,6 +5,7 @@ import { CheckpointScopeId, CommandId, EventId, + GuestShareId, MessageId, NodeId, ProjectId, @@ -1242,6 +1243,17 @@ it.effect( ], }); } + // A guest share on the busy side chat ends with the parent's archive, as on any archived thread. + const shareId = GuestShareId.make("side-chat-archive-share"); + yield* orchestrator.dispatch({ + type: "thread.guest-share.open", + commandId: CommandId.make("side-chat-archive-share-busy"), + threadId: busy, + shareId, + host: "env.t3.test", + expiresAt: DateTime.formatIso(DateTime.add(now, { hours: 24 })), + }); + assert.equal((yield* orchestrator.getThreadProjection(busy)).thread.guestShare?.id, shareId); const archive = (commandId: string, threadId: ThreadId) => orchestrator.dispatch({ type: "thread.archive", @@ -1266,6 +1278,7 @@ it.effect( assert.isNotNull(archivedBusy.thread.archivedAt); assert.equal(archivedBusy.runs.find((run) => run.id === queuedRunId)?.status, "cancelled"); assert.isEmpty(archivedBusy.providerSessions); + assert.isNull(archivedBusy.thread.guestShare ?? null); assert.deepEqual(yield* archivedAt(archivedFirst), childArchivedAt); // The side chat's session is detached the way the parent's is. const stopEffects = (commandId: string) => @@ -1301,6 +1314,8 @@ it.effect( }); assert.isNull(yield* archivedAt(parentId)); assert.isNull(yield* archivedAt(busy)); + // Unarchive does not reopen a share, for a side chat as for any thread. + assert.isNull((yield* orchestrator.getThreadProjection(busy)).thread.guestShare ?? null); assert.deepEqual(yield* archivedAt(archivedFirst), childArchivedAt); }).pipe(Effect.provide(forkCases[0]!.layer)), ); From 35e7f126ca45a14e3c578a6d7d4ff1bc7b011ddd Mon Sep 17 00:00:00 2001 From: Jackson Miller Date: Sat, 14 Nov 2026 20:47:34 -0500 Subject: [PATCH 3/4] fix(server): a side chat waiting on delegated tasks no longer blocks its parent's archive Archive refuses a thread with running delegated tasks, so cascading to such a side chat failed the parent's whole archive. The cascade now leaves that side chat active, as archive does for sub-agent descendants in #33921. Co-Authored-By: Claude Opus 5.5 --- .../src/orchestration-v2/Orchestrator.ts | 24 ++++++---- .../ThreadFork.execution.test.ts | 44 +++++++++++++++++++ 2 files changed, 60 insertions(+), 8 deletions(-) diff --git a/apps/server/src/orchestration-v2/Orchestrator.ts b/apps/server/src/orchestration-v2/Orchestrator.ts index ec49098cd3cc5..305bd93a8220e 100644 --- a/apps/server/src/orchestration-v2/Orchestrator.ts +++ b/apps/server/src/orchestration-v2/Orchestrator.ts @@ -1546,6 +1546,14 @@ const nativeSubagentRef = (subagent: OrchestrationV2Subagent): DyingNativeSubage providerInstanceId: subagent.providerInstanceId, }); +const hasRunningDelegatedTasks = (subagents: ReadonlyArray) => + subagents.some( + (task) => + task.origin === "app_owned" && + task.childThreadId !== null && + isOrchestrationV2WorkActive(task.status), + ); + const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(function* () { const checkpointService = yield* CheckpointServiceV2; const commandPolicy = yield* CommandPolicyV2; @@ -4251,14 +4259,7 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio 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), - ) - ) { + if (hasRunningDelegatedTasks(records.subagents)) { return yield* new OrchestratorDispatchError({ commandId: command.commandId, commandType: command.type, @@ -15029,6 +15030,13 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio DateTime.toEpochMillis(sideChatArchivedAt) >= DateTime.toEpochMillis(parentArchivedAt); if (!follows) continue; + // Archive refuses a side chat still waiting on delegated tasks, so it stays active. + if (command.type === "thread.archive") { + const { subagents } = yield* projectionStore + .getThreadRecords(threadId, ["subagents"]) + .pipe(mapDispatchError(command)); + if (hasRunningDelegatedTasks(subagents)) continue; + } yield* dispatchThreadMutation( { ...command, diff --git a/apps/server/src/orchestration-v2/ThreadFork.execution.test.ts b/apps/server/src/orchestration-v2/ThreadFork.execution.test.ts index 81e52fb204f8d..95c15af3ea806 100644 --- a/apps/server/src/orchestration-v2/ThreadFork.execution.test.ts +++ b/apps/server/src/orchestration-v2/ThreadFork.execution.test.ts @@ -1189,6 +1189,7 @@ it.effect( const parentId = ThreadId.make("side-chat-archive-parent"); const busy = ThreadId.make("side-chat-archive-busy"); const archivedFirst = ThreadId.make("side-chat-archive-archived-first"); + const delegating = ThreadId.make("side-chat-archive-delegating"); yield* createParent(parentId); yield* writeParentRun({ threadId: parentId, @@ -1200,6 +1201,7 @@ it.effect( }); yield* forkSideChat("side-chat-archive-fork-busy", parentId, busy); yield* forkSideChat("side-chat-archive-fork-archived-first", parentId, archivedFirst); + yield* forkSideChat("side-chat-archive-fork-delegating", parentId, delegating); const runningRunId = RunId.make("side-chat-archive-busy-running"); const queuedRunId = RunId.make("side-chat-archive-busy-queued"); yield* writeParentRun({ @@ -1243,6 +1245,46 @@ it.effect( ], }); } + const delegatingRunId = RunId.make("side-chat-archive-delegating-run"); + yield* writeParentRun({ + threadId: delegating, + runId: delegatingRunId, + ordinal: 1, + status: "completed", + text: "Delegate a review", + }); + const taskId = NodeId.make("side-chat-archive-delegated-task"); + yield* eventSink.write({ + events: [ + { + id: EventId.make(`${taskId}:running`), + type: "subagent.updated", + threadId: delegating, + occurredAt: now, + payload: { + id: taskId, + threadId: delegating, + runId: delegatingRunId, + parentNodeId: NodeId.make("side-chat-archive-delegating-node"), + origin: "app_owned", + createdBy: "agent", + driver, + providerInstanceId: instanceId, + providerThreadId: null, + childThreadId: ThreadId.make("side-chat-archive-delegated-child"), + nativeTaskRef: null, + prompt: "Review the changes", + title: null, + model: null, + status: "running", + result: null, + startedAt: now, + completedAt: null, + updatedAt: now, + }, + }, + ], + }); // A guest share on the busy side chat ends with the parent's archive, as on any archived thread. const shareId = GuestShareId.make("side-chat-archive-share"); yield* orchestrator.dispatch({ @@ -1280,6 +1322,8 @@ it.effect( assert.isEmpty(archivedBusy.providerSessions); assert.isNull(archivedBusy.thread.guestShare ?? null); assert.deepEqual(yield* archivedAt(archivedFirst), childArchivedAt); + // Archive refuses a thread still waiting on delegated tasks, so the cascade leaves it active. + assert.isNull(yield* archivedAt(delegating)); // The side chat's session is detached the way the parent's is. const stopEffects = (commandId: string) => outbox From f2ef62725e10e119e4896df4ba28ad41f0441955 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sun, 15 Nov 2026 04:20:40 +0000 Subject: [PATCH 4/4] test(server): trim the side chat archive test to the cascade itself Co-Authored-By: Claude Opus 5.5 --- .../ThreadFork.execution.test.ts | 57 ++----------------- 1 file changed, 4 insertions(+), 53 deletions(-) diff --git a/apps/server/src/orchestration-v2/ThreadFork.execution.test.ts b/apps/server/src/orchestration-v2/ThreadFork.execution.test.ts index 95c15af3ea806..773a971604746 100644 --- a/apps/server/src/orchestration-v2/ThreadFork.execution.test.ts +++ b/apps/server/src/orchestration-v2/ThreadFork.execution.test.ts @@ -5,7 +5,6 @@ import { CheckpointScopeId, CommandId, EventId, - GuestShareId, MessageId, NodeId, ProjectId, @@ -22,15 +21,12 @@ import { import * as DateTime from "effect/DateTime"; import * as Deferred from "effect/Deferred"; import * as Effect from "effect/Effect"; -import * as Layer from "effect/Layer"; import * as Statement from "effect/sql/Statement"; import * as Stream from "effect/Stream"; import * as TestClock from "effect/testing/TestClock"; -import * as SqlitePersistence from "../persistence/Sqlite.ts"; import { CodexProviderCapabilitiesV2 } from "./Adapters/CodexAdapterV2.ts"; import { ClaudeProviderCapabilitiesV2 } from "./Adapters/ClaudeAdapterV2.ts"; -import * as EffectOutbox from "./EffectOutbox.ts"; import * as EffectWorker from "./EffectWorker.ts"; import * as EventSink from "./EventSink.ts"; import * as Orchestrator from "./Orchestrator.ts"; @@ -57,14 +53,10 @@ const forkCases = ( planSelectionTransition: () => Effect.succeed({ type: "apply_on_next_turn" }), openSession: () => Effect.die("Execution is paused after dispatch for handoff inspection"), }; - const layerDatabase = SqlitePersistence.layerMemory; - const layer = Layer.merge( - ProviderReplayHarness.layerWithRegistry( - { name: `fork-boundary-${driver}` }, - ProviderAdapterRegistry.layerFromAdapters([adapter]), - { runEffectWorker: false, databaseLayer: layerDatabase }, - ), - EffectOutbox.layer.pipe(Layer.provide(layerDatabase)), + const layer = ProviderReplayHarness.layerWithRegistry( + { name: `fork-boundary-${driver}` }, + ProviderAdapterRegistry.layerFromAdapters([adapter]), + { runEffectWorker: false }, ); return { driver, status, instanceId, modelSelection, layer }; @@ -1184,7 +1176,6 @@ it.effect( Effect.gen(function* () { const orchestrator = yield* Orchestrator.OrchestratorV2; const eventSink = yield* EventSink.EventSinkV2; - const outbox = yield* EffectOutbox.EffectOutboxV2; const { driver, instanceId } = forkCases[0]!; const parentId = ThreadId.make("side-chat-archive-parent"); const busy = ThreadId.make("side-chat-archive-busy"); @@ -1285,17 +1276,6 @@ it.effect( }, ], }); - // A guest share on the busy side chat ends with the parent's archive, as on any archived thread. - const shareId = GuestShareId.make("side-chat-archive-share"); - yield* orchestrator.dispatch({ - type: "thread.guest-share.open", - commandId: CommandId.make("side-chat-archive-share-busy"), - threadId: busy, - shareId, - host: "env.t3.test", - expiresAt: DateTime.formatIso(DateTime.add(now, { hours: 24 })), - }); - assert.equal((yield* orchestrator.getThreadProjection(busy)).thread.guestShare?.id, shareId); const archive = (commandId: string, threadId: ThreadId) => orchestrator.dispatch({ type: "thread.archive", @@ -1320,36 +1300,9 @@ it.effect( assert.isNotNull(archivedBusy.thread.archivedAt); assert.equal(archivedBusy.runs.find((run) => run.id === queuedRunId)?.status, "cancelled"); assert.isEmpty(archivedBusy.providerSessions); - assert.isNull(archivedBusy.thread.guestShare ?? null); assert.deepEqual(yield* archivedAt(archivedFirst), childArchivedAt); // Archive refuses a thread still waiting on delegated tasks, so the cascade leaves it active. assert.isNull(yield* archivedAt(delegating)); - // The side chat's session is detached the way the parent's is. - const stopEffects = (commandId: string) => - outbox - .listByCommandId(CommandId.make(commandId)) - .pipe( - Effect.map((effects) => - effects.map(({ threadId, request }) => ({ threadId, request })), - ), - ); - const stopsFor = (threadId: ThreadId) => [ - { - threadId, - request: { - type: "provider-session.detach" as const, - providerSessionId: ProviderSessionId.make(`${threadId}:session`), - detail: "Thread archived.", - revokeMcpCredential: true, - }, - }, - { threadId, request: { type: "terminal.cleanup" as const } }, - ]; - assert.deepEqual(yield* stopEffects("side-chat-archive-parent"), stopsFor(parentId)); - assert.deepEqual( - yield* stopEffects(`side-chat-archive-parent:side-chat:${busy}`), - stopsFor(busy), - ); yield* orchestrator.dispatch({ type: "thread.unarchive", @@ -1358,8 +1311,6 @@ it.effect( }); assert.isNull(yield* archivedAt(parentId)); assert.isNull(yield* archivedAt(busy)); - // Unarchive does not reopen a share, for a side chat as for any thread. - assert.isNull((yield* orchestrator.getThreadProjection(busy)).thread.guestShare ?? null); assert.deepEqual(yield* archivedAt(archivedFirst), childArchivedAt); }).pipe(Effect.provide(forkCases[0]!.layer)), );