Repository navigation
fix(server): archiving a thread also archives its side chats #32213
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
0d5fb3b
d087099
35e7f12
00ced21
f2ef627
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -1546,6 +1546,14 @@ const nativeSubagentRef = (subagent: OrchestrationV2Subagent): DyingNativeSubage | |
| providerInstanceId: subagent.providerInstanceId, | ||
| }); | ||
|
|
||
| const hasRunningDelegatedTasks = (subagents: ReadonlyArray<OrchestrationV2Subagent>) => | ||
| 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; | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Count running remote delegated tasks as blocking side-chat archive.
Suggested fix
Extend the existing archive cascade test with a side chat that has a running remote delegated task. Assert that the side chat stays active.
|
||
| } | ||
| 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<ThreadId> = [], | ||
|
|
@@ -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( | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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. | ||
|
Comment on lines
+320
to
+321
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Document the side chats that remain active. If a side chat has running delegated tasks, the cascade skips it and leaves its work active. The new test explicitly checks this exception. Qualify the stopping claim so users know that archiving the parent does not stop that work. Also state that separately archived side chats remain archived.
|
||
|
|
||
| On mobile, a thread lists its side chats above the composer. Tap one to open it and | ||
| answer its questions. | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Track archive ownership instead of inferring it from timestamps.
If a user archives the parent, unarchives a side chat, then archives that side chat separately, its timestamp still satisfies this comparison. Unarchiving the parent then restores the separately archived side chat. A separate archive immediately before the parent archive can also match within the same millisecond.
Record which archive operation owns each cascaded archive. Restore only matching archives. Extend the existing test with an intervening separate archive.