| author | |
| committer | |
| log | 8c777fbfade340f2092c011483c7fb0cde44c675 |
| tree | 0279d4930624b58090a5c827559dfd16d393c0a3 |
| parent | 98beed1a226c42b85c52ff5ee9d5dbb67d776a77 |
| signature | Signed by PGP key B5690EEEBB952194 |
15 files changed, 542 insertions(+), 19 deletions(-)
apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts+109| ... | ... | @@ -2130,6 +2130,7 @@ describe("ClaudeAdapterV2 background wake turns", () => { |
| 2130 | 2130 | const makeWakeHarnessWithOptions = (options?: { |
| 2131 | 2131 | readonly close?: (sdkMessages: Queue.Queue<SDKMessage>) => Effect.Effect<void>; |
| 2132 | 2132 | readonly interrupt?: Effect.Effect<void>; |
| 2133 | readonly stopTask?: (taskId: string) => Effect.Effect<void>; | |
| 2133 | 2134 | readonly environment?: NodeJS.ProcessEnv; |
| 2134 | 2135 | readonly settings?: ClaudeSettings; |
| 2135 | 2136 | // A CLI process opened after the first streams from its own queue, so the |
| ... | ... | @@ -2155,6 +2156,10 @@ describe("ClaudeAdapterV2 background wake turns", () => { |
| 2155 | 2156 | const permissionModeChanges: Array<string> = []; |
| 2156 | 2157 | const continuationRequests: Array<ProviderContinuationRequests.ProviderContinuationRequest> = |
| 2157 | 2158 | []; |
| 2159 | const subagentReceipts = | |
| 2160 | yield* Queue.unbounded< | |
| 2161 | Extract<ProviderAdapter.ProviderAdapterV2Event, { type: "subagent.updated" }> | |
| 2162 | >(); | |
| 2158 | 2163 | const terminalReceipts = |
| 2159 | 2164 | yield* Queue.unbounded< |
| 2160 | 2165 | Extract<ProviderAdapter.ProviderAdapterV2Event, { type: "turn.terminal" }> |
| ... | ... | @@ -2227,6 +2232,7 @@ describe("ClaudeAdapterV2 background wake turns", () => { |
| 2227 | 2232 | Effect.sync(() => { |
| 2228 | 2233 | permissionModeChanges.push(mode); |
| 2229 | 2234 | }), |
| 2235 | ...(options?.stopTask === undefined ? {} : { stopTask: options.stopTask }), | |
| 2230 | 2236 | interrupt: options?.interrupt ?? Effect.void, |
| 2231 | 2237 | close: options?.close?.(sdkMessages) ?? Effect.void, |
| 2232 | 2238 | }; |
| ... | ... | @@ -2253,6 +2259,7 @@ describe("ClaudeAdapterV2 background wake turns", () => { |
| 2253 | 2259 | Stream.runForEach((event) => |
| 2254 | 2260 | Effect.gen(function* () { |
| 2255 | 2261 | events.push(event); |
| 2262 | if (event.type === "subagent.updated") yield* Queue.offer(subagentReceipts, event); | |
| 2256 | 2263 | if (event.type === "turn.terminal") { |
| 2257 | 2264 | yield* Queue.offer(terminalReceipts, event); |
| 2258 | 2265 | } |
| ... | ... | @@ -2292,6 +2299,7 @@ describe("ClaudeAdapterV2 background wake turns", () => { |
| 2292 | 2299 | continuationRequests, |
| 2293 | 2300 | events, |
| 2294 | 2301 | terminalReceipts, |
| 2302 | subagentReceipts, | |
| 2295 | 2303 | systemNoticeReceipts, |
| 2296 | 2304 | getOpenedOptions: () => openedOptions, |
| 2297 | 2305 | terminalEvents, |
| ... | ... | @@ -8076,6 +8084,107 @@ describe("ClaudeAdapterV2 background wake turns", () => { |
| 8076 | 8084 | ), |
| 8077 | 8085 | ); |
| 8078 | 8086 | |
| 8087 | it.effect.each([false, true])( | |
| 8088 | "stops one native subagent while its sibling keeps running, owner settled %s", | |
| 8089 | (ownerSettled) => | |
| 8090 | Effect.scoped( | |
| 8091 | Effect.gen(function* () { | |
| 8092 | const stopped: string[] = []; | |
| 8093 | let closes = 0; | |
| 8094 | const harness = yield* makeWakeHarnessWithOptions({ | |
| 8095 | stopTask: (taskId) => | |
| 8096 | Effect.sync(() => { | |
| 8097 | stopped.push(taskId); | |
| 8098 | }), | |
| 8099 | interrupt: Effect.die("A child stop must not interrupt its owner"), | |
| 8100 | close: () => | |
| 8101 | Effect.sync(() => { | |
| 8102 | closes++; | |
| 8103 | }), | |
| 8104 | }); | |
| 8105 | yield* harness.runtime.startTurn( | |
| 8106 | makeClaudeTestTurnInput({ | |
| 8107 | threadId: harness.threadId, | |
| 8108 | providerThread: harness.providerThread, | |
| 8109 | now: yield* DateTime.now, | |
| 8110 | attemptId: RunAttemptId.make("attempt-native-child-stop"), | |
| 8111 | text: "Start two agents.", | |
| 8112 | attachments: [], | |
| 8113 | }), | |
| 8114 | ); | |
| 8115 | for (const [taskId, toolUseId, uuid] of [ | |
| 8116 | ["agent-stop", "toolu-stop", "00000000-0000-4000-8000-000000000801"], | |
| 8117 | ["agent-keep", "toolu-keep", "00000000-0000-4000-8000-000000000802"], | |
| 8118 | ]) { | |
| 8119 | yield* harness.offerAndWait( | |
| 8120 | makeSubagentTaskStartedFrame({ taskId: taskId!, toolUseId: toolUseId!, uuid: uuid! }), | |
| 8121 | ); | |
| 8122 | yield* Queue.take(harness.subagentReceipts); | |
| 8123 | } | |
| 8124 | if (ownerSettled) { | |
| 8125 | yield* harness.offerAndWait( | |
| 8126 | makeResultFrame({ | |
| 8127 | uuid: "00000000-0000-4000-8000-000000000803", | |
| 8128 | result: "Agents are working.", | |
| 8129 | }), | |
| 8130 | ); | |
| 8131 | yield* Queue.take(harness.terminalReceipts); | |
| 8132 | } | |
| 8133 | const stop = harness.runtime.stopSubagent; | |
| 8134 | assert.isDefined(stop); | |
| 8135 | yield* stop!({ providerThread: harness.providerThread, nativeTaskId: "agent-stop" }); | |
| 8136 | assert.deepEqual(stopped, ["agent-stop"]); | |
| 8137 | yield* harness.offerAndWait( | |
| 8138 | claudeSdkFrame({ | |
| 8139 | ...makeSubagentNotificationFrame({ | |
| 8140 | taskId: "agent-stop", | |
| 8141 | toolUseId: "toolu-stop", | |
| 8142 | summary: "Stopped by user", | |
| 8143 | uuid: "00000000-0000-4000-8000-000000000804", | |
| 8144 | }), | |
| 8145 | status: "stopped", | |
| 8146 | }), | |
| 8147 | ); | |
| 8148 | if (ownerSettled) { | |
| 8149 | yield* harness.runtime.startTurn( | |
| 8150 | makeClaudeTestTurnInput({ | |
| 8151 | threadId: harness.threadId, | |
| 8152 | providerThread: harness.providerThread, | |
| 8153 | now: yield* DateTime.now, | |
| 8154 | attemptId: RunAttemptId.make("attempt-native-child-stop-wake"), | |
| 8155 | text: "Background task stopped.", | |
| 8156 | attachments: [], | |
| 8157 | providerTurnOrdinal: 2, | |
| 8158 | messageCreatedBy: "agent", | |
| 8159 | messageCreationSource: "provider", | |
| 8160 | }), | |
| 8161 | ); | |
| 8162 | } | |
| 8163 | const stoppedEvent = yield* Queue.take(harness.subagentReceipts); | |
| 8164 | assert.equal(stoppedEvent.subagent.status, "cancelled"); | |
| 8165 | const tasks = harness.events.filter((event) => event.type === "subagent.updated"); | |
| 8166 | assert.equal( | |
| 8167 | tasks.findLast((event) => event.subagent.nativeTaskRef?.nativeId === "agent-stop") | |
| 8168 | ?.subagent.status, | |
| 8169 | "cancelled", | |
| 8170 | ); | |
| 8171 | assert.equal( | |
| 8172 | tasks.findLast((event) => event.subagent.nativeTaskRef?.nativeId === "agent-keep") | |
| 8173 | ?.subagent.status, | |
| 8174 | "running", | |
| 8175 | ); | |
| 8176 | assert.lengthOf(harness.terminalEvents(), ownerSettled ? 1 : 0); | |
| 8177 | yield* stop!({ providerThread: harness.providerThread, nativeTaskId: "agent-stop" }); | |
| 8178 | assert.deepEqual(stopped, ["agent-stop"]); | |
| 8179 | assert.equal(closes, 0); | |
| 8180 | }).pipe( | |
| 8181 | Effect.provide( | |
| 8182 | Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer), | |
| 8183 | ), | |
| 8184 | ), | |
| 8185 | ), | |
| 8186 | ); | |
| 8187 | ||
| 8079 | 8188 | it.effect.each(["requested", "observed-before", "observed-after", "inherit", "unknown"] as const)( |
| 8080 | 8189 | "records the subagent model from %s without inheriting the parent override", |
| 8081 | 8190 | (source) => |
apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts+34| ... | ... | @@ -342,6 +342,7 @@ export interface ClaudeAgentSdkQuerySession { |
| 342 | 342 | readonly setPermissionMode: ( |
| 343 | 343 | mode: PermissionMode, |
| 344 | 344 | ) => Effect.Effect<void, ClaudeAgentSdkQueryRunnerError>; |
| 345 | readonly stopTask?: (taskId: string) => Effect.Effect<void, ClaudeAgentSdkQueryRunnerError>; | |
| 345 | 346 | readonly interrupt: Effect.Effect<void, ClaudeAgentSdkQueryRunnerError>; |
| 346 | 347 | readonly close: Effect.Effect<void, ClaudeAgentSdkQueryRunnerError>; |
| 347 | 348 | } |
| ... | ... | @@ -734,6 +735,11 @@ export const layerQueryRunner: Layer.Layer< |
| 734 | 735 | }), |
| 735 | 736 | ), |
| 736 | 737 | ), |
| 738 | stopTask: (taskId) => | |
| 739 | Effect.tryPromise({ | |
| 740 | try: () => queryRuntime.stopTask(taskId), | |
| 741 | catch: (cause) => queryRunnerError(cause, "stopTask"), | |
| 742 | }), | |
| 737 | 743 | interrupt: Effect.tryPromise({ |
| 738 | 744 | try: () => queryRuntime.interrupt(), |
| 739 | 745 | catch: (cause) => queryRunnerError(cause, "interrupt"), |
| ... | ... | @@ -8092,6 +8098,34 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* ( |
| 8092 | 8098 | }), |
| 8093 | 8099 | steerTurn, |
| 8094 | 8100 | interruptTurn, |
| 8101 | stopSubagent: Effect.fn("ClaudeAdapterV2.stopSubagent")(function* (input) { | |
| 8102 | const existing = yield* Ref.get(queryContext); | |
| 8103 | const nativeThreadId = input.providerThread.nativeThreadRef?.nativeId; | |
| 8104 | const subagent = (yield* Ref.get(sessionSubagentsByTaskId)).get(input.nativeTaskId); | |
| 8105 | if (subagent === undefined || subagent.task.status !== "running") return; | |
| 8106 | if ( | |
| 8107 | existing === null || | |
| 8108 | existing.nativeThreadId !== nativeThreadId || | |
| 8109 | subagent.task.threadId !== input.providerThread.appThreadId || | |
| 8110 | existing.subagentsFromEarlierProcesses.has(subagent) || | |
| 8111 | existing.query.stopTask === undefined | |
| 8112 | ) { | |
| 8113 | return yield* new ProviderAdapter.ProviderAdapterProtocolError({ | |
| 8114 | driver: CLAUDE_PROVIDER, | |
| 8115 | detail: "Claude subagent has no live owning query with task stopping support.", | |
| 8116 | }); | |
| 8117 | } | |
| 8118 | yield* existing.query.stopTask(input.nativeTaskId).pipe( | |
| 8119 | Effect.mapError( | |
| 8120 | (cause) => | |
| 8121 | new ProviderAdapter.ProviderAdapterProtocolError({ | |
| 8122 | driver: CLAUDE_PROVIDER, | |
| 8123 | detail: "Claude native subagent stop failed.", | |
| 8124 | cause, | |
| 8125 | }), | |
| 8126 | ), | |
| 8127 | ); | |
| 8128 | }), | |
| 8095 | 8129 | respondToRuntimeRequest: Effect.fn("ClaudeAdapterV2.respondToRuntimeRequest")( |
| 8096 | 8130 | function* (requestInput) { |
| 8097 | 8131 | const pending = (yield* Ref.get(pendingRuntimeRequests)).get( |
apps/server/src/orchestration-v2/EffectOutbox.ts+2| ... | ... | @@ -3,6 +3,7 @@ import { |
| 3 | 3 | CheckpointScopeId, |
| 4 | 4 | CommandId, |
| 5 | 5 | MessageId, |
| 6 | NodeId, | |
| 6 | 7 | ProviderSessionId, |
| 7 | 8 | RunAttemptId, |
| 8 | 9 | ProviderApprovalDecision, |
| ... | ... | @@ -48,6 +49,7 @@ export const OrchestrationEffectRequestV2 = Schema.Union([ |
| 48 | 49 | providerSessionId: ProviderSessionId, |
| 49 | 50 | providerThreadId: ProviderThreadId, |
| 50 | 51 | providerTurnId: ProviderTurnId, |
| 52 | subagent: Schema.optional(Schema.Struct({ id: NodeId, nativeTaskId: Schema.String })), | |
| 51 | 53 | }), |
| 52 | 54 | Schema.Struct({ |
| 53 | 55 | type: Schema.Literal("provider-turn.steer"), |
apps/server/src/orchestration-v2/EffectWorker.test.ts+30| ... | ... | @@ -1,6 +1,7 @@ |
| 1 | 1 | import { assert, it } from "@effect/vitest"; |
| 2 | 2 | import { |
| 3 | 3 | CommandId, |
| 4 | NodeId, | |
| 4 | 5 | ProviderSessionId, |
| 5 | 6 | ProviderThreadId, |
| 6 | 7 | ProviderTurnId, |
| ... | ... | @@ -837,3 +838,32 @@ it.effect("settles a delegated child once its restart continuation fails for goo |
| 837 | 838 | }).pipe(Effect.provide(layer)); |
| 838 | 839 | }), |
| 839 | 840 | ); |
| 841 | ||
| 842 | it.effect("a native subagent stop does not settle its owner's background work", () => | |
| 843 | Effect.gen(function* () { | |
| 844 | const now = yield* DateTime.now; | |
| 845 | const events = yield* Ref.make<ReadonlyArray<string>>([]); | |
| 846 | const layer = layerExecutorFor({ | |
| 847 | events, | |
| 848 | interrupt: (input) => { | |
| 849 | assert.equal(input.subagent?.nativeTaskId, "native-task"); | |
| 850 | return Ref.update(events, (current) => [...current, "stop-child"]); | |
| 851 | }, | |
| 852 | threads: { dispatch: () => Effect.die("The owner's background work must stay running") }, | |
| 853 | }); | |
| 854 | yield* Effect.gen(function* () { | |
| 855 | const executor = yield* EffectWorker.OrchestrationEffectExecutorV2; | |
| 856 | yield* executor.execute({ | |
| 857 | ...restartEffect(now, { type: "detach" }), | |
| 858 | request: { | |
| 859 | type: "provider-turn.interrupt", | |
| 860 | providerSessionId: oldSessionId, | |
| 861 | providerThreadId, | |
| 862 | providerTurnId, | |
| 863 | subagent: { id: NodeId.make("subagent"), nativeTaskId: "native-task" }, | |
| 864 | }, | |
| 865 | }); | |
| 866 | }).pipe(Effect.provide(layer)); | |
| 867 | assert.deepEqual(yield* Ref.get(events), ["stop-child"]); | |
| 868 | }), | |
| 869 | ); |
apps/server/src/orchestration-v2/EffectWorker.ts+12-7| ... | ... | @@ -169,6 +169,9 @@ export const layerExecutor: Layer.Layer< |
| 169 | 169 | providerSessionId: effect.request.providerSessionId, |
| 170 | 170 | providerThreadId: effect.request.providerThreadId, |
| 171 | 171 | providerTurnId: effect.request.providerTurnId, |
| 172 | ...(effect.request.subagent === undefined | |
| 173 | ? {} | |
| 174 | : { subagent: effect.request.subagent }), | |
| 172 | 175 | }) |
| 173 | 176 | .pipe( |
| 174 | 177 | Effect.catch((cause) => |
| ... | ... | @@ -185,13 +188,15 @@ export const layerExecutor: Layer.Layer< |
| 185 | 188 | // One Stop can interrupt several provider threads, so the |
| 186 | 189 | // settle is keyed by effect, not by the Stop command. |
| 187 | 190 | Effect.andThen( |
| 188 | threads.dispatch({ | |
| 189 | type: "thread.background-work.settle", | |
| 190 | commandId: CommandId.make(`${effect.id}:background-work-settled`), | |
| 191 | threadId: effect.threadId, | |
| 192 | providerThreadId: effect.request.providerThreadId, | |
| 193 | providerTurnId: effect.request.providerTurnId, | |
| 194 | }), | |
| 191 | effect.request.subagent !== undefined | |
| 192 | ? Effect.void | |
| 193 | : threads.dispatch({ | |
| 194 | type: "thread.background-work.settle", | |
| 195 | commandId: CommandId.make(`${effect.id}:background-work-settled`), | |
| 196 | threadId: effect.threadId, | |
| 197 | providerThreadId: effect.request.providerThreadId, | |
| 198 | providerTurnId: effect.request.providerTurnId, | |
| 199 | }), | |
| 195 | 200 | ), |
| 196 | 201 | Effect.mapError( |
| 197 | 202 | (cause) => |
apps/server/src/orchestration-v2/Orchestrator.ts+89-1| ... | ... | @@ -463,6 +463,7 @@ function commandThreadId(command: OrchestrationV2ServerCommand): ThreadId { |
| 463 | 463 | case "prepared-run.progress": |
| 464 | 464 | case "prepared-run.fail": |
| 465 | 465 | case "prepared-run.retry": |
| 466 | case "subagent.stop": | |
| 466 | 467 | case "run.interrupt": |
| 467 | 468 | case "queued-message.promote-to-steer": |
| 468 | 469 | case "queue.resume": |
| ... | ... | @@ -8821,6 +8822,89 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio |
| 8821 | 8822 | }); |
| 8822 | 8823 | }); |
| 8823 | 8824 | |
| 8825 | const dispatchSubagentStop = ( | |
| 8826 | command: Extract<OrchestrationV2Command, { readonly type: "subagent.stop" }>, | |
| 8827 | events: Ref.Ref<Array<OrchestrationV2DomainEvent>>, | |
| 8828 | effects: Ref.Ref<Array<PendingOrchestrationEffectV2>>, | |
| 8829 | ) => | |
| 8830 | Effect.gen(function* () { | |
| 8831 | const projection = yield* loadProjectionForCommand(command, [ | |
| 8832 | "runs", | |
| 8833 | "subagents", | |
| 8834 | "providerThreads", | |
| 8835 | "providerTurns", | |
| 8836 | ]); | |
| 8837 | const run = projection.runs.find((candidate) => candidate.id === command.runId); | |
| 8838 | const subagent = projection.subagents.find( | |
| 8839 | (candidate) => candidate.id === command.subagentId, | |
| 8840 | ); | |
| 8841 | if ( | |
| 8842 | subagent === undefined || | |
| 8843 | subagent.runId !== command.runId || | |
| 8844 | subagent.origin !== "provider_native" || | |
| 8845 | subagent.driver !== "claudeAgent" || | |
| 8846 | subagent.nativeTaskRef?.strength !== "strong" || | |
| 8847 | subagent.nativeTaskRef.nativeId === null | |
| 8848 | ) { | |
| 8849 | return yield* new OrchestratorDispatchError({ | |
| 8850 | commandId: command.commandId, | |
| 8851 | commandType: command.type, | |
| 8852 | cause: "The requested native subagent cannot be stopped on this run.", | |
| 8853 | }); | |
| 8854 | } | |
| 8855 | if (!["pending", "running", "waiting"].includes(subagent.status)) return; | |
| 8856 | const providerThread = projection.providerThreads.find( | |
| 8857 | (candidate) => candidate.id === run?.providerThreadId, | |
| 8858 | ); | |
| 8859 | const providerTurn = projection.providerTurns.findLast( | |
| 8860 | (candidate) => candidate.providerThreadId === providerThread?.id, | |
| 8861 | ); | |
| 8862 | if ( | |
| 8863 | providerThread?.providerSessionId == null || | |
| 8864 | providerTurn === undefined || | |
| 8865 | providerThread.driver !== subagent.driver || | |
| 8866 | providerThread.providerInstanceId !== subagent.providerInstanceId | |
| 8867 | ) { | |
| 8868 | return yield* new OrchestratorDispatchError({ | |
| 8869 | commandId: command.commandId, | |
| 8870 | commandType: command.type, | |
| 8871 | cause: "The requested native subagent has no owning provider session.", | |
| 8872 | }); | |
| 8873 | } | |
| 8874 | const providerSessionId = providerThread.providerSessionId; | |
| 8875 | const nativeTaskId = subagent.nativeTaskRef.nativeId; | |
| 8876 | const now = yield* DateTime.now; | |
| 8877 | // Record the request with its effect; the provider notification supplies the outcome. | |
| 8878 | yield* emit( | |
| 8879 | events, | |
| 8880 | command, | |
| 8881 | )({ | |
| 8882 | type: "subagent.updated", | |
| 8883 | threadId: command.threadId, | |
| 8884 | runId: command.runId, | |
| 8885 | nodeId: subagent.id, | |
| 8886 | providerInstanceId: subagent.providerInstanceId, | |
| 8887 | occurredAt: now, | |
| 8888 | payload: { ...subagent, updatedAt: now }, | |
| 8889 | }); | |
| 8890 | yield* Ref.update(effects, (existing) => [ | |
| 8891 | ...existing, | |
| 8892 | { | |
| 8893 | id: `effect:${command.commandId}:subagent.stop:${subagent.id}`, | |
| 8894 | commandId: command.commandId, | |
| 8895 | threadId: command.threadId, | |
| 8896 | request: { | |
| 8897 | type: "provider-turn.interrupt", | |
| 8898 | providerSessionId, | |
| 8899 | providerThreadId: providerThread.id, | |
| 8900 | providerTurnId: providerTurn.id, | |
| 8901 | subagent: { id: subagent.id, nativeTaskId }, | |
| 8902 | }, | |
| 8903 | } satisfies PendingOrchestrationEffectV2, | |
| 8904 | ]); | |
| 8905 | return; | |
| 8906 | }); | |
| 8907 | ||
| 8824 | 8908 | const dispatchRunInterrupt = ( |
| 8825 | 8909 | command: Extract<OrchestrationV2Command, { readonly type: "run.interrupt" }>, |
| 8826 | 8910 | events: Ref.Ref<Array<OrchestrationV2DomainEvent>>, |
| ... | ... | @@ -10526,6 +10610,9 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio |
| 10526 | 10610 | case "thread.user-input.dismiss": |
| 10527 | 10611 | yield* dispatchThreadUserInputDismiss(command, events, effects); |
| 10528 | 10612 | break; |
| 10613 | case "subagent.stop": | |
| 10614 | yield* dispatchSubagentStop(command, events, effects); | |
| 10615 | break; | |
| 10529 | 10616 | case "run.interrupt": |
| 10530 | 10617 | cancelUnsettledEffects = yield* dispatchRunInterrupt(command, events, effects); |
| 10531 | 10618 | // Stop also stops every delegated task under the thread once it commits. |
| ... | ... | @@ -10739,7 +10826,8 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio |
| 10739 | 10826 | // its expected outcome, not a failure. |
| 10740 | 10827 | planned.events.length > 0 || |
| 10741 | 10828 | command.type === "thread.background-work.settle" || |
| 10742 | command.type === "thread.stop" | |
| 10829 | command.type === "thread.stop" || | |
| 10830 | command.type === "subagent.stop" | |
| 10743 | 10831 | ? Effect.succeed(planned) |
| 10744 | 10832 | : Effect.fail( |
| 10745 | 10833 | new OrchestratorDispatchError({ |
apps/server/src/orchestration-v2/ProviderTurnControlService.ts+17| ... | ... | @@ -1,6 +1,7 @@ |
| 1 | 1 | import { projectComposerContextForProvider } from "@t3tools/shared/composerContextReferences"; |
| 2 | 2 | import { |
| 3 | 3 | MessageId, |
| 4 | type NodeId, | |
| 4 | 5 | ProviderSessionId, |
| 5 | 6 | ProviderThreadId, |
| 6 | 7 | ProviderTurnId, |
| ... | ... | @@ -43,6 +44,7 @@ const isProviderTurnControlError = Schema.is(ProviderTurnControlError); |
| 43 | 44 | |
| 44 | 45 | export interface ProviderTurnControlServiceV2Shape { |
| 45 | 46 | readonly interrupt: (input: { |
| 47 | readonly subagent?: { readonly id: NodeId; readonly nativeTaskId: string }; | |
| 46 | 48 | readonly threadId: ThreadId; |
| 47 | 49 | readonly providerSessionId: ProviderSessionId; |
| 48 | 50 | readonly providerThreadId: ProviderThreadId; |
| ... | ... | @@ -177,6 +179,21 @@ export const layer: Layer.Layer< |
| 177 | 179 | ? loaded.session |
| 178 | 180 | : yield* sessions.get(input.providerSessionId); |
| 179 | 181 | if (Option.isNone(session)) return; |
| 182 | if (input.subagent !== undefined) { | |
| 183 | if (session.value.stopSubagent === undefined) { | |
| 184 | return yield* new ProviderTurnControlError({ | |
| 185 | threadId: input.threadId, | |
| 186 | operation: "interrupt", | |
| 187 | providerTurnId: input.providerTurnId, | |
| 188 | cause: "The provider does not support stopping a native subagent.", | |
| 189 | }); | |
| 190 | } | |
| 191 | yield* session.value.stopSubagent({ | |
| 192 | providerThread: loaded.providerThread, | |
| 193 | nativeTaskId: input.subagent.nativeTaskId, | |
| 194 | }); | |
| 195 | return; | |
| 196 | } | |
| 180 | 197 | // A settled turn reaches its adapter too: only the adapter knows |
| 181 | 198 | // whether it still runs work for the thread, and each one either |
| 182 | 199 | // stops it or reports there is nothing left to stop. Background work |
apps/server/src/orchestration-v2/ThreadStop.test.ts+119| ... | ... | @@ -7,6 +7,7 @@ import { |
| 7 | 7 | ProjectId, |
| 8 | 8 | ProviderDriverKind, |
| 9 | 9 | ProviderInstanceId, |
| 10 | ProviderSessionId, | |
| 10 | 11 | ProviderTurnId, |
| 11 | 12 | ThreadId, |
| 12 | 13 | TurnItemId, |
| ... | ... | @@ -20,6 +21,7 @@ import * as SqlClient from "effect/sql/SqlClient"; |
| 20 | 21 | import * as TestClock from "effect/testing/TestClock"; |
| 21 | 22 | |
| 22 | 23 | import * as SqlitePersistence from "../persistence/Sqlite.ts"; |
| 24 | import { CLAUDE_PROVIDER } from "./Adapters/ClaudeAdapterV2.ts"; | |
| 23 | 25 | import { CodexProviderCapabilitiesV2 } from "./Adapters/CodexAdapterV2.ts"; |
| 24 | 26 | import { OrchestrationEffectRequestV2 } from "./EffectOutbox.ts"; |
| 25 | 27 | import * as Orchestrator from "./Orchestrator.ts"; |
| ... | ... | @@ -582,3 +584,120 @@ it.effect("thread.stop marks a turn it cannot interrupt so a late agent watch is |
| 582 | 584 | assert.deepEqual(yield* threadState(threadId), { runs: ["running"], watched: [] }); |
| 583 | 585 | }).pipe(Effect.provide(layerTest)), |
| 584 | 586 | ); |
| 587 | ||
| 588 | it.effect( | |
| 589 | "a native child stop persists only its targeted effect and leaves its owner's queue and watch alone", | |
| 590 | () => | |
| 591 | Effect.gen(function* () { | |
| 592 | const orchestrator = yield* Orchestrator.OrchestratorV2; | |
| 593 | const projections = yield* ProjectionStore.ProjectionStoreV2; | |
| 594 | const sql = yield* SqlClient.SqlClient; | |
| 595 | const threadId = ThreadId.make("thread:native-child-stop"); | |
| 596 | yield* createWatchingThread(threadId, 20); | |
| 597 | yield* send(threadId, "owner", "start_immediately"); | |
| 598 | yield* delegate(threadId, "native-child-fixture"); | |
| 599 | const before = yield* orchestrator.getThreadProjection(threadId); | |
| 600 | const run = before.runs[0]!; | |
| 601 | const now = yield* DateTime.now; | |
| 602 | const subagent = { | |
| 603 | ...before.subagents[0]!, | |
| 604 | origin: "provider_native" as const, | |
| 605 | driver: CLAUDE_PROVIDER, | |
| 606 | providerInstanceId: ProviderInstanceId.make("claudeAgent"), | |
| 607 | nativeTaskRef: { | |
| 608 | driver: CLAUDE_PROVIDER, | |
| 609 | nativeId: "claude-task", | |
| 610 | strength: "strong" as const, | |
| 611 | }, | |
| 612 | status: "running" as const, | |
| 613 | startedAt: now, | |
| 614 | }; | |
| 615 | yield* projections.apply({ | |
| 616 | id: EventId.make("event:native-child-stop:agent"), | |
| 617 | type: "subagent.updated", | |
| 618 | threadId, | |
| 619 | runId: run.id, | |
| 620 | nodeId: subagent.id, | |
| 621 | occurredAt: now, | |
| 622 | payload: subagent, | |
| 623 | }); | |
| 624 | const providerThread = before.providerThreads.find( | |
| 625 | (thread) => thread.id === run.providerThreadId, | |
| 626 | )!; | |
| 627 | yield* projections.apply({ | |
| 628 | id: EventId.make("event:native-child-stop:provider-thread"), | |
| 629 | type: "provider-thread.updated", | |
| 630 | threadId, | |
| 631 | occurredAt: now, | |
| 632 | payload: { | |
| 633 | ...providerThread, | |
| 634 | driver: CLAUDE_PROVIDER, | |
| 635 | providerInstanceId: ProviderInstanceId.make("claudeAgent"), | |
| 636 | providerSessionId: ProviderSessionId.make("session:native-child-stop"), | |
| 637 | }, | |
| 638 | }); | |
| 639 | const providerTurnId = ProviderTurnId.make("turn:native-child-stop"); | |
| 640 | yield* projections.apply({ | |
| 641 | id: EventId.make("event:native-child-stop:provider-turn"), | |
| 642 | type: "provider-turn.updated", | |
| 643 | threadId, | |
| 644 | occurredAt: now, | |
| 645 | payload: { | |
| 646 | id: providerTurnId, | |
| 647 | providerThreadId: providerThread.id, | |
| 648 | nodeId: run.rootNodeId!, | |
| 649 | runAttemptId: run.activeAttemptId!, | |
| 650 | nativeTurnRef: null, | |
| 651 | ordinal: 1, | |
| 652 | status: "completed", | |
| 653 | startedAt: now, | |
| 654 | completedAt: now, | |
| 655 | }, | |
| 656 | }); | |
| 657 | // The owner has finished but its child and queued prompt remain. | |
| 658 | yield* projections.apply({ | |
| 659 | id: EventId.make("event:native-child-stop:owner-completed"), | |
| 660 | type: "run.updated", | |
| 661 | threadId, | |
| 662 | runId: run.id, | |
| 663 | occurredAt: now, | |
| 664 | payload: { ...run, status: "completed", startedAt: now, completedAt: now }, | |
| 665 | }); | |
| 666 | yield* send(threadId, "queued", "queue_after_active"); | |
| 667 | const stateBefore = yield* threadState(threadId); | |
| 668 | const commandId = CommandId.make("stop-native-child"); | |
| 669 | yield* orchestrator.dispatch({ | |
| 670 | type: "subagent.stop", | |
| 671 | commandId, | |
| 672 | threadId, | |
| 673 | runId: run.id, | |
| 674 | subagentId: subagent.id, | |
| 675 | }); | |
| 676 | assert.deepEqual(yield* threadState(threadId), stateBefore); | |
| 677 | const effects = yield* sql<{ payload_json: string }>` | |
| 678 | SELECT payload_json FROM orchestration_v2_effect_outbox WHERE command_id = ${commandId} | |
| 679 | `; | |
| 680 | assert.equal(effects.length, 1); | |
| 681 | assert.deepEqual(JSON.parse(effects[0]!.payload_json), { | |
| 682 | type: "provider-turn.interrupt", | |
| 683 | providerSessionId: "session:native-child-stop", | |
| 684 | providerThreadId: providerThread.id, | |
| 685 | providerTurnId, | |
| 686 | subagent: { id: subagent.id, nativeTaskId: "claude-task" }, | |
| 687 | }); | |
| 688 | assert.equal( | |
| 689 | (yield* orchestrator.getThreadProjection(threadId)).subagents[0]?.status, | |
| 690 | "running", | |
| 691 | ); | |
| 692 | const unrelated = yield* Effect.exit( | |
| 693 | orchestrator.dispatch({ | |
| 694 | type: "subagent.stop", | |
| 695 | commandId: CommandId.make("stop-unknown-child"), | |
| 696 | threadId, | |
| 697 | runId: run.id, | |
| 698 | subagentId: NodeId.make("unknown-child"), | |
| 699 | }), | |
| 700 | ); | |
| 701 | assert.isTrue(Exit.isFailure(unrelated)); | |
| 702 | }).pipe(Effect.provide(layerTest)), | |
| 703 | ); |
apps/server/src/orchestration-v2/testkit/OrchestratorScenario.ts+1| ... | ... | @@ -166,6 +166,7 @@ function commandThreadIds(command: OrchestrationV2Command): ReadonlyArray<Thread |
| 166 | 166 | case "prepared-run.progress": |
| 167 | 167 | case "prepared-run.fail": |
| 168 | 168 | case "prepared-run.retry": |
| 169 | case "subagent.stop": | |
| 169 | 170 | case "run.interrupt": |
| 170 | 171 | case "queued-message.promote-to-steer": |
| 171 | 172 | case "queue.resume": |
apps/web/src/components/chat/ThreadRelationshipsControl.agents.test.tsx+21-6| ... | ... | @@ -53,9 +53,13 @@ afterEach(async () => { |
| 53 | 53 | state.projection = null; |
| 54 | 54 | }); |
| 55 | 55 | |
| 56 | it.each(["codex", "claudeAgent"])( | |
| 57 | "stops only active app-owned %s subagents without opening their thread", | |
| 58 | async (driver) => { | |
| 56 | it.each([ | |
| 57 | { driver: "codex", origin: "app_owned" }, | |
| 58 | { driver: "claudeAgent", origin: "app_owned" }, | |
| 59 | { driver: "claudeAgent", origin: "provider_native" }, | |
| 60 | ])( | |
| 61 | "stops active $origin $driver subagents without opening their thread", | |
| 62 | async ({ driver, origin }) => { | |
| 59 | 63 | vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true); |
| 60 | 64 | const parent = { |
| 61 | 65 | id: "parent", |
| ... | ... | @@ -70,7 +74,9 @@ it.each(["codex", "claudeAgent"])( |
| 70 | 74 | const agent = { |
| 71 | 75 | id: "agent", |
| 72 | 76 | childThreadId: "child", |
| 73 | origin: "app_owned", | |
| 77 | origin, | |
| 78 | threadId: "parent", | |
| 79 | nativeTaskRef: { driver, nativeId: "claude-task", strength: "strong" }, | |
| 74 | 80 | driver, |
| 75 | 81 | providerInstanceId: "codex", |
| 76 | 82 | title: "Worker", |
| ... | ... | @@ -106,7 +112,10 @@ it.each(["codex", "claudeAgent"])( |
| 106 | 112 | await act(async () => stopButton().props.onClick()); |
| 107 | 113 | expect(state.command).toHaveBeenCalledWith({ |
| 108 | 114 | environmentId: "test", |
| 109 | input: { threadId: "child" }, | |
| 115 | input: | |
| 116 | origin === "provider_native" | |
| 117 | ? { threadId: "parent", subagentId: "agent" } | |
| 118 | : { threadId: "child" }, | |
| 110 | 119 | }); |
| 111 | 120 | expect(state.navigate).not.toHaveBeenCalled(); |
| 112 | 121 | |
| ... | ... | @@ -132,6 +141,12 @@ it.each(["codex", "claudeAgent"])( |
| 132 | 141 | ); |
| 133 | 142 | state.projection = { ...projection, subagents: [{ ...agent, status: "completed" }] }; |
| 134 | 143 | await act(async () => renderer.update(cloneElement(panel))); |
| 144 | if (origin === "provider_native") { | |
| 145 | expect(renderer.root.findAllByProps({ "aria-label": "Stop subagent Worker" })).toHaveLength( | |
| 146 | 0, | |
| 147 | ); | |
| 148 | continue; | |
| 149 | } | |
| 135 | 150 | await act(async () => stopButton().props.onClick()); |
| 136 | 151 | expect(state.command).toHaveBeenCalledTimes(1); |
| 137 | 152 | expect(state.command).toHaveBeenLastCalledWith({ |
| ... | ... | @@ -152,7 +167,7 @@ it.each(["codex", "claudeAgent"])( |
| 152 | 167 | expect(renderer.root.findAllByProps({ "aria-label": "Stop subagent Worker" })).toHaveLength(0); |
| 153 | 168 | state.projection = { |
| 154 | 169 | ...projection, |
| 155 | subagents: [{ ...agent, origin: "provider_native", driver: "claudeAgent" }], | |
| 170 | subagents: [{ ...agent, origin: "provider_native", driver: "codex" }], | |
| 156 | 171 | }; |
| 157 | 172 | await act(async () => renderer.update(cloneElement(panel))); |
| 158 | 173 | expect(renderer.root.findAllByProps({ "aria-label": "Stop subagent Worker" })).toHaveLength(0); |
apps/web/src/components/chat/ThreadRelationshipsControl.tsx+26-5| ... | ... | @@ -24,7 +24,12 @@ import { |
| 24 | 24 | canDetachThreadProviderSession, |
| 25 | 25 | resolveLatestMergeBackRun, |
| 26 | 26 | } from "@t3tools/client-runtime/state/thread-workflows"; |
| 27 | import type { EnvironmentId, OrchestrationV2ThreadShell, ThreadId } from "@t3tools/contracts"; | |
| 27 | import type { | |
| 28 | EnvironmentId, | |
| 29 | OrchestrationV2Subagent, | |
| 30 | OrchestrationV2ThreadShell, | |
| 31 | ThreadId, | |
| 32 | } from "@t3tools/contracts"; | |
| 28 | 33 | import { deriveSubagentElapsedMs } from "@t3tools/shared/orchestrationTiming"; |
| 29 | 34 | import { groupBy } from "effect/Array"; |
| 30 | 35 | import * as DateTime from "effect/DateTime"; |
| ... | ... | @@ -221,6 +226,10 @@ export function ThreadRelationshipsPanel(props: { |
| 221 | 226 | subagent.childThreadId, |
| 222 | 227 | { |
| 223 | 228 | ...projectedSubagentsToRuntime([subagent])[0]!, |
| 229 | id: subagent.id, | |
| 230 | threadId: subagent.threadId, | |
| 231 | nativeTaskRef: subagent.nativeTaskRef, | |
| 232 | nativeStatus: subagent.status, | |
| 224 | 233 | driver: subagent.driver, |
| 225 | 234 | providerInstanceId: subagent.providerInstanceId, |
| 226 | 235 | origin: subagent.origin, |
| ... | ... | @@ -328,12 +337,18 @@ export function ThreadRelationshipsPanel(props: { |
| 328 | 337 | setBusyAction(null); |
| 329 | 338 | }; |
| 330 | 339 | |
| 331 | const stopSubagent = async (childThreadId: ThreadId) => { | |
| 340 | const stopSubagent = async ( | |
| 341 | childThreadId: ThreadId, | |
| 342 | agent: Pick<OrchestrationV2Subagent, "id" | "origin" | "threadId">, | |
| 343 | ) => { | |
| 332 | 344 | if (stoppingThreadId !== null) return; |
| 333 | 345 | setStoppingThreadId(childThreadId); |
| 334 | 346 | const result = await interruptTurn({ |
| 335 | 347 | environmentId: props.environmentId, |
| 336 | input: { threadId: childThreadId }, | |
| 348 | input: | |
| 349 | agent.origin === "provider_native" | |
| 350 | ? { threadId: agent.threadId, subagentId: agent.id } | |
| 351 | : { threadId: childThreadId }, | |
| 337 | 352 | }); |
| 338 | 353 | setStoppingThreadId(null); |
| 339 | 354 | if (result._tag === "Failure") { |
| ... | ... | @@ -398,7 +413,13 @@ export function ThreadRelationshipsPanel(props: { |
| 398 | 413 | ); |
| 399 | 414 | const failed = status === "failed" || status === "error"; |
| 400 | 415 | const canStop = |
| 401 | agent?.origin === "app_owned" && | |
| 416 | agent && | |
| 417 | (agent.origin === "app_owned" || | |
| 418 | (agent.origin === "provider_native" && | |
| 419 | agent.driver === "claudeAgent" && | |
| 420 | agent.nativeTaskRef?.strength === "strong" && | |
| 421 | agent.nativeTaskRef.nativeId !== null && | |
| 422 | ["pending", "running", "waiting"].includes(agent.nativeStatus))) && | |
| 402 | 423 | agent.startedAt && |
| 403 | 424 | ["pending", "running", "waiting"].includes(agent.status); |
| 404 | 425 | const trailingVisibilityClass = canStop |
| ... | ... | @@ -566,7 +587,7 @@ export function ThreadRelationshipsPanel(props: { |
| 566 | 587 | tone="destructive" |
| 567 | 588 | aria-label={`Stop subagent ${threadTitle}`} |
| 568 | 589 | disabled={stoppingThreadId !== null} |
| 569 | onClick={() => void stopSubagent(threadId)} | |
| 590 | onClick={() => void stopSubagent(threadId, agent)} | |
| 570 | 591 | /> |
| 571 | 592 | } |
| 572 | 593 | > |
packages/client-runtime/src/operations/commands.test.ts+55| ... | ... | @@ -10,6 +10,7 @@ import { |
| 10 | 10 | PlanId, |
| 11 | 11 | ProjectId, |
| 12 | 12 | ProviderInstanceId, |
| 13 | ProviderDriverKind, | |
| 13 | 14 | RunId, |
| 14 | 15 | RuntimeRequestId, |
| 15 | 16 | ThreadId, |
| ... | ... | @@ -995,6 +996,60 @@ describe("V2 environment commands", () => { |
| 995 | 996 | }).pipe(Effect.provide(layerTestCrypto)), |
| 996 | 997 | ); |
| 997 | 998 | |
| 999 | it.effect( | |
| 1000 | "a native subagent stop targets its owning run rather than holding the parent queue", | |
| 1001 | () => | |
| 1002 | Effect.gen(function* () { | |
| 1003 | const subagentId = NodeId.make("native-subagent"); | |
| 1004 | const runId = RunId.make("settled-owner-run"); | |
| 1005 | const commands: OrchestrationV2Command[] = []; | |
| 1006 | const supervisor = yield* makeSupervisor({ | |
| 1007 | commands, | |
| 1008 | projects: [], | |
| 1009 | projection: { | |
| 1010 | ...v2Projection, | |
| 1011 | subagents: [ | |
| 1012 | { | |
| 1013 | id: subagentId, | |
| 1014 | threadId: v2ThreadId, | |
| 1015 | runId, | |
| 1016 | parentNodeId: NodeId.make("owner-node"), | |
| 1017 | origin: "provider_native", | |
| 1018 | createdBy: "agent", | |
| 1019 | driver: ProviderDriverKind.make("claudeAgent"), | |
| 1020 | providerInstanceId: ProviderInstanceId.make("claudeAgent"), | |
| 1021 | providerThreadId: null, | |
| 1022 | childThreadId: ThreadId.make("native-child-thread"), | |
| 1023 | nativeTaskRef: null, | |
| 1024 | prompt: "Work", | |
| 1025 | title: null, | |
| 1026 | model: null, | |
| 1027 | result: null, | |
| 1028 | status: "running", | |
| 1029 | startedAt: v2Now, | |
| 1030 | completedAt: null, | |
| 1031 | updatedAt: v2Now, | |
| 1032 | }, | |
| 1033 | ], | |
| 1034 | }, | |
| 1035 | }); | |
| 1036 | yield* interruptThreadTurn({ | |
| 1037 | commandId: CommandId.make("native-stop"), | |
| 1038 | threadId: v2ThreadId, | |
| 1039 | subagentId, | |
| 1040 | }).pipe(Effect.provideService(EnvironmentSupervisor.EnvironmentSupervisor, supervisor)); | |
| 1041 | expect(commands).toEqual([ | |
| 1042 | { | |
| 1043 | type: "subagent.stop", | |
| 1044 | commandId: "native-stop", | |
| 1045 | threadId: v2ThreadId, | |
| 1046 | runId, | |
| 1047 | subagentId, | |
| 1048 | }, | |
| 1049 | ]); | |
| 1050 | }).pipe(Effect.provide(layerTestCrypto)), | |
| 1051 | ); | |
| 1052 | ||
| 998 | 1053 | it.effect("interrupts a known run without fetching the full projection", () => |
| 999 | 1054 | Effect.gen(function* () { |
| 1000 | 1055 | const commands: OrchestrationV2Command[] = []; |
packages/client-runtime/src/operations/commands.ts+14| ... | ... | @@ -10,6 +10,7 @@ import { |
| 10 | 10 | type ChatAttachment, |
| 11 | 11 | type MessageId, |
| 12 | 12 | type ModelSelection, |
| 13 | type NodeId, | |
| 13 | 14 | type OrchestrationV2Command, |
| 14 | 15 | type OrchestrationV2CreationSource, |
| 15 | 16 | type PlanId, |
| ... | ... | @@ -183,6 +184,7 @@ export interface StartThreadTurnInput extends ThreadCommandInput { |
| 183 | 184 | |
| 184 | 185 | export interface InterruptThreadTurnInput extends ThreadCommandInput { |
| 185 | 186 | readonly runId?: RunId; |
| 187 | readonly subagentId?: NodeId; | |
| 186 | 188 | /** Temporary caller compatibility while UI naming moves from turns to runs. */ |
| 187 | 189 | readonly turnId?: string; |
| 188 | 190 | } |
| ... | ... | @@ -780,6 +782,18 @@ export const startThreadTurn = Effect.fn("EnvironmentCommands.startThreadTurn")( |
| 780 | 782 | export const interruptThreadTurn = Effect.fn("EnvironmentCommands.interruptThreadTurn")(function* ( |
| 781 | 783 | input: InterruptThreadTurnInput, |
| 782 | 784 | ) { |
| 785 | if (input.subagentId !== undefined) { | |
| 786 | const projection = yield* getProjection(input.threadId); | |
| 787 | const subagent = projection.subagents.find((agent) => agent.id === input.subagentId); | |
| 788 | if (subagent?.runId == null) return { sequence: 0 }; | |
| 789 | return yield* dispatch({ | |
| 790 | type: "subagent.stop", | |
| 791 | commandId: yield* allocateCommandId(input), | |
| 792 | threadId: input.threadId, | |
| 793 | runId: subagent.runId, | |
| 794 | subagentId: subagent.id, | |
| 795 | }); | |
| 796 | } | |
| 783 | 797 | let runId = input.runId ?? (input.turnId as RunId | undefined); |
| 784 | 798 | if (runId === undefined) { |
| 785 | 799 | const projection = yield* getProjection(input.threadId); |
packages/contracts/src/orchestrationV2.ts+8| ... | ... | @@ -2915,6 +2915,14 @@ export const OrchestrationV2Command = Schema.Union([ |
| 2915 | 2915 | threadId: ThreadId, |
| 2916 | 2916 | runId: RunId, |
| 2917 | 2917 | }), |
| 2918 | /** Stops one provider-native child while its owning run keeps working. */ | |
| 2919 | Schema.Struct({ | |
| 2920 | type: Schema.Literal("subagent.stop"), | |
| 2921 | commandId: CommandId, | |
| 2922 | threadId: ThreadId, | |
| 2923 | runId: RunId, | |
| 2924 | subagentId: NodeId, | |
| 2925 | }), | |
| 2918 | 2926 | Schema.Struct({ |
| 2919 | 2927 | type: Schema.Literal("run.interrupt"), |
| 2920 | 2928 | commandId: CommandId, |
packages/provider-core/src/server/ProviderAdapter.ts+5| ... | ... | @@ -598,6 +598,11 @@ export interface ProviderAdapterV2SessionRuntime { |
| 598 | 598 | readonly interruptTurn: ( |
| 599 | 599 | input: ProviderAdapterV2InterruptInput, |
| 600 | 600 | ) => Effect.Effect<void, ProviderAdapterV2Error>; |
| 601 | /** Stops one native child through its owning runtime; the owner keeps running. */ | |
| 602 | readonly stopSubagent?: (input: { | |
| 603 | readonly providerThread: OrchestrationV2ProviderThread; | |
| 604 | readonly nativeTaskId: string; | |
| 605 | }) => Effect.Effect<void, ProviderAdapterV2Error>; | |
| 601 | 606 | /** |
| 602 | 607 | * Lets a runtime shared by several app threads unload one provider thread's |
| 603 | 608 | * native state (and its MCP servers) when that app thread detaches, while |