diff --git a/apps/server/src/orchestration-v2/Orchestrator.ts b/apps/server/src/orchestration-v2/Orchestrator.ts index 61929131e9d72..3a4ee469e375d 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; @@ -1826,7 +1834,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 @@ -4258,14 +4266,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, @@ -15023,7 +15024,39 @@ 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; + // 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, + commandId: CommandId.make(`${command.commandId}:side-chat:${threadId}`), + threadId, + }, + events, + effects, + ); + } + break; + } case "thread.settle": case "thread.unsettle": case "thread.snooze": @@ -15359,7 +15392,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 = [], @@ -15646,7 +15679,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 ( @@ -15660,7 +15693,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..773a971604746 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, @@ -1169,6 +1170,151 @@ 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 { 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"); + const delegating = ThreadId.make("side-chat-archive-delegating"); + 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); + 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({ + 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 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, + }, + }, + ], + }); + 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); + // Archive refuses a thread still waiting on delegated tasks, so the cascade leaves it active. + assert.isNull(yield* archivedAt(delegating)); + + 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.