Repository navigation
fix(server): cascade parent lifecycle to subagent descendants #36730
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
d6654e6
0bc7233
eddab8c
52da8cf
3dbe87c
d885a46
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 |
|---|---|---|
|
|
@@ -116,7 +116,12 @@ import { | |
| isNativeMaintenanceCommand, | ||
| resolveMessageDispatchIntent, | ||
| } from "./CommandPolicy.ts"; | ||
| import { CommandReceiptStoreV2, type CommandReceiptV2 } from "./CommandReceiptStore.ts"; | ||
| import { | ||
| archivedByCascade, | ||
| CommandReceiptStoreV2, | ||
| type CommandReceiptV2, | ||
| supersedesCascade, | ||
| } from "./CommandReceiptStore.ts"; | ||
| import { | ||
| ContextHandoffServiceV2, | ||
| readForkHistoryItems, | ||
|
|
@@ -460,6 +465,9 @@ export interface OrchestratorV2Shape { | |
| readonly getThreadProjection: ( | ||
| threadId: ThreadId, | ||
| ) => Effect.Effect<OrchestrationV2ThreadProjection, OrchestratorV2Error>; | ||
| readonly getSubagentChildThreads: ( | ||
| ...args: Parameters<ProjectionStoreV2["Service"]["getSubagentChildThreads"]> | ||
| ) => Effect.Effect<ReadonlyArray<OrchestrationV2AppThread>, OrchestratorV2Error>; | ||
| readonly getCheckpointContext: ( | ||
| threadId: ThreadId, | ||
| ) => Effect.Effect<ProjectionCheckpointContext, OrchestratorV2Error>; | ||
|
|
@@ -1785,6 +1793,20 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio | |
| Effect.mapError((cause) => new OrchestratorProjectionError({ threadId, cause })), | ||
| ); | ||
|
|
||
| // A cascade walks the whole subtree itself, so its own commands start none. | ||
| const subagentCascadeEffects = Effect.fn("Orchestrator.subagentCascadeEffects")(function* ( | ||
| { commandId, threadId }: { readonly commandId: CommandId; readonly threadId: ThreadId }, | ||
| operation: "thread.archive" | "thread.unarchive" | "thread.delete", | ||
| ) { | ||
| if (yield* EffectOutbox.SubagentCascade) return []; | ||
| if ((yield* projectionStore.getSubagentChildThreads(threadId)).length === 0) return []; | ||
| // The outbox schema persists only the caller's runtime and interaction modes. | ||
| const limit = yield* DispatchModeLimit; | ||
| const request = { type: "subagent-threads.cascade", operation, limit } as const; | ||
| const id = `effect:${commandId}:subagent-threads.cascade`; | ||
| return [{ id, commandId, threadId, request } satisfies PendingOrchestrationEffectV2]; | ||
| }); | ||
|
|
||
| /** Side chats live inside their parent, so deleting the parent deletes them too. */ | ||
| const liveSideChatIds = Effect.fn("Orchestrator.liveSideChatIds")( | ||
| function* (threadId: ThreadId) { | ||
|
|
@@ -5176,6 +5198,13 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio | |
| } | ||
| } | ||
|
|
||
| if (command.type === "thread.archive" || command.type === "thread.unarchive") { | ||
| const cascade = yield* subagentCascadeEffects(command, command.type).pipe( | ||
| mapDispatchError(command), | ||
| ); | ||
| yield* Ref.update(effects, (existing) => [...existing, ...cascade]); | ||
| } | ||
|
|
||
| if (command.type === "thread.metadata.update" && command.regenerateTitle === true) { | ||
| yield* Ref.update(effects, (existing) => [ | ||
| ...existing, | ||
|
|
@@ -14816,7 +14845,7 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio | |
| (cause) => new OrchestratorProjectionError({ threadId: deletion.threadId, cause }), | ||
| ), | ||
| ); | ||
| return yield* mapDispatchError(command)( | ||
| const plan = yield* mapDispatchError(command)( | ||
| planThreadDeletion({ | ||
| command: deletion, | ||
| projection, | ||
|
|
@@ -14827,6 +14856,10 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio | |
| idAllocator, | ||
| }), | ||
| ); | ||
| const cascade = yield* subagentCascadeEffects(deletion, deletion.type).pipe( | ||
| mapDispatchError(command), | ||
| ); | ||
| return { ...plan, effects: [...plan.effects, ...cascade] }; | ||
| }); | ||
| // Checked under the thread lock: a message sent since the client looked keeps the thread. | ||
| if (command.onlyIfUnused === true) { | ||
|
|
@@ -15232,6 +15265,38 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio | |
| if (shell !== null) yield* refuseAboveDispatchModeLimit(command, threadId, shell); | ||
| } | ||
|
|
||
| // Decided under the child's lock, so a newer independent choice on it always wins. | ||
| const cascade = yield* EffectOutbox.SubagentCascade; | ||
| if ( | ||
| cascade !== undefined && | ||
| (command.type === "thread.archive" || | ||
| command.type === "thread.unarchive" || | ||
| command.type === "thread.delete") | ||
| ) { | ||
| const { source, path } = cascade; | ||
| const read = mapDispatchError(command); | ||
| const thread = yield* projectionStore.getThread(command.threadId).pipe(read); | ||
| const parentThreadId = thread.lineage.parentThreadId; | ||
| const parent = | ||
| parentThreadId === null | ||
| ? null | ||
| : yield* projectionStore.getThread(parentThreadId).pipe(read); | ||
| // A child moved to another project since the walk found it is no longer this subtree's. | ||
| let applies = parent?.projectId === thread.projectId; | ||
| if (applies && source !== undefined && command.type !== "thread.delete") { | ||
| const latest = yield* commandReceipts.getLatestLifecycle(command.threadId).pipe(read); | ||
| applies = | ||
| thread.deletedAt === null && | ||
| (command.type === "thread.archive" | ||
| ? thread.archivedAt === null && !supersedesCascade(latest, source, command.commandId) | ||
| : thread.archivedAt !== null && | ||
| (yield* archivedByCascade(commandReceipts, latest, source.threadId, path).pipe( | ||
| read, | ||
| ))); | ||
| } | ||
| if (!applies) return { sequence: source?.resultSequence ?? 0, storedEvents: [] }; | ||
|
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. Stop traversal when the project check skips a child. If child C moves to another project after discovery, this return skips C but reports success. Return a distinct skip result and make the visitor stop that subtree. Extend the existing moved-child case with a descendant in the new project.
|
||
| } | ||
|
|
||
| const plan = yield* dispatchOnce(command, sideChatIds).pipe( | ||
| Effect.tap((planned) => refuseAboveRuntimeModeCeiling(command, planned.events)), | ||
| Effect.filterOrFail( | ||
|
|
@@ -16068,6 +16133,11 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio | |
| .pipe(Effect.mapError((cause) => new OrchestratorProjectionError({ threadId, cause }))); | ||
| }, | ||
| ), | ||
| getSubagentChildThreads: Effect.fn("OrchestratorV2.getSubagentChildThreads")((threadId) => | ||
| projectionStore | ||
| .getSubagentChildThreads(threadId) | ||
| .pipe(Effect.mapError((cause) => new OrchestratorProjectionError({ threadId, cause }))), | ||
| ), | ||
| getThreadProjection: Effect.fn("OrchestratorV2.getThreadProjection")(function (threadId) { | ||
| return projectionStore | ||
| .getThreadProjection(threadId) | ||
|
|
||
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.
Preserve cascades for side chats deleted with a descendant.
If subagent
Chas side chatS, andShas subagentT, a parent delete cascades toC. Its deletion plan also deletesS. This guard suppresses the cascade effect forSbecauseSubagentCascadeis set. The outer visitor follows only subagent edges, so it never reachesT.Suppress redundant scheduling only for threads that the current traversal will visit. Alternatively, include the deleted side chats' subagent trees in that traversal. Extend the existing recursive deletion test with this topology.