authorgravatar for saiansh2525@gmail.comYash Singh <saiansh2525@gmail.com> 2026-10-10 09:50:11-05:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-10-10 09:50:11-05:00
log8c777fbfade340f2092c011483c7fb0cde44c675
tree0279d4930624b58090a5c827559dfd16d393c0a3
parent98beed1a226c42b85c52ff5ee9d5dbb67d776a77
signature Signed by PGP key B5690EEEBB952194

feat(server): stop Claude subagents without stopping their owner (#17826)


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,6 +2130,7 @@ describe("ClaudeAdapterV2 background wake turns", () => {
2130 const makeWakeHarnessWithOptions = (options?: {2130 const makeWakeHarnessWithOptions = (options?: {
2131 readonly close?: (sdkMessages: Queue.Queue<SDKMessage>) => Effect.Effect<void>;2131 readonly close?: (sdkMessages: Queue.Queue<SDKMessage>) => Effect.Effect<void>;
2132 readonly interrupt?: Effect.Effect<void>;2132 readonly interrupt?: Effect.Effect<void>;
2133 readonly stopTask?: (taskId: string) => Effect.Effect<void>;
2133 readonly environment?: NodeJS.ProcessEnv;2134 readonly environment?: NodeJS.ProcessEnv;
2134 readonly settings?: ClaudeSettings;2135 readonly settings?: ClaudeSettings;
2135 // A CLI process opened after the first streams from its own queue, so the2136 // A CLI process opened after the first streams from its own queue, so the
...@@ -2155,6 +2156,10 @@ describe("ClaudeAdapterV2 background wake turns", () => {...@@ -2155,6 +2156,10 @@ describe("ClaudeAdapterV2 background wake turns", () => {
2155 const permissionModeChanges: Array<string> = [];2156 const permissionModeChanges: Array<string> = [];
2156 const continuationRequests: Array<ProviderContinuationRequests.ProviderContinuationRequest> =2157 const continuationRequests: Array<ProviderContinuationRequests.ProviderContinuationRequest> =
2157 [];2158 [];
2159 const subagentReceipts =
2160 yield* Queue.unbounded<
2161 Extract<ProviderAdapter.ProviderAdapterV2Event, { type: "subagent.updated" }>
2162 >();
2158 const terminalReceipts =2163 const terminalReceipts =
2159 yield* Queue.unbounded<2164 yield* Queue.unbounded<
2160 Extract<ProviderAdapter.ProviderAdapterV2Event, { type: "turn.terminal" }>2165 Extract<ProviderAdapter.ProviderAdapterV2Event, { type: "turn.terminal" }>
...@@ -2227,6 +2232,7 @@ describe("ClaudeAdapterV2 background wake turns", () => {...@@ -2227,6 +2232,7 @@ describe("ClaudeAdapterV2 background wake turns", () => {
2227 Effect.sync(() => {2232 Effect.sync(() => {
2228 permissionModeChanges.push(mode);2233 permissionModeChanges.push(mode);
2229 }),2234 }),
2235 ...(options?.stopTask === undefined ? {} : { stopTask: options.stopTask }),
2230 interrupt: options?.interrupt ?? Effect.void,2236 interrupt: options?.interrupt ?? Effect.void,
2231 close: options?.close?.(sdkMessages) ?? Effect.void,2237 close: options?.close?.(sdkMessages) ?? Effect.void,
2232 };2238 };
...@@ -2253,6 +2259,7 @@ describe("ClaudeAdapterV2 background wake turns", () => {...@@ -2253,6 +2259,7 @@ describe("ClaudeAdapterV2 background wake turns", () => {
2253 Stream.runForEach((event) =>2259 Stream.runForEach((event) =>
2254 Effect.gen(function* () {2260 Effect.gen(function* () {
2255 events.push(event);2261 events.push(event);
2262 if (event.type === "subagent.updated") yield* Queue.offer(subagentReceipts, event);
2256 if (event.type === "turn.terminal") {2263 if (event.type === "turn.terminal") {
2257 yield* Queue.offer(terminalReceipts, event);2264 yield* Queue.offer(terminalReceipts, event);
2258 }2265 }
...@@ -2292,6 +2299,7 @@ describe("ClaudeAdapterV2 background wake turns", () => {...@@ -2292,6 +2299,7 @@ describe("ClaudeAdapterV2 background wake turns", () => {
2292 continuationRequests,2299 continuationRequests,
2293 events,2300 events,
2294 terminalReceipts,2301 terminalReceipts,
2302 subagentReceipts,
2295 systemNoticeReceipts,2303 systemNoticeReceipts,
2296 getOpenedOptions: () => openedOptions,2304 getOpenedOptions: () => openedOptions,
2297 terminalEvents,2305 terminalEvents,
...@@ -8076,6 +8084,107 @@ describe("ClaudeAdapterV2 background wake turns", () => {...@@ -8076,6 +8084,107 @@ describe("ClaudeAdapterV2 background wake turns", () => {
8076 ),8084 ),
8077 );8085 );
80788086
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 it.effect.each(["requested", "observed-before", "observed-after", "inherit", "unknown"] as const)(8188 it.effect.each(["requested", "observed-before", "observed-after", "inherit", "unknown"] as const)(
8080 "records the subagent model from %s without inheriting the parent override",8189 "records the subagent model from %s without inheriting the parent override",
8081 (source) =>8190 (source) =>
apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts+34
...@@ -342,6 +342,7 @@ export interface ClaudeAgentSdkQuerySession {...@@ -342,6 +342,7 @@ export interface ClaudeAgentSdkQuerySession {
342 readonly setPermissionMode: (342 readonly setPermissionMode: (
343 mode: PermissionMode,343 mode: PermissionMode,
344 ) => Effect.Effect<void, ClaudeAgentSdkQueryRunnerError>;344 ) => Effect.Effect<void, ClaudeAgentSdkQueryRunnerError>;
345 readonly stopTask?: (taskId: string) => Effect.Effect<void, ClaudeAgentSdkQueryRunnerError>;
345 readonly interrupt: Effect.Effect<void, ClaudeAgentSdkQueryRunnerError>;346 readonly interrupt: Effect.Effect<void, ClaudeAgentSdkQueryRunnerError>;
346 readonly close: Effect.Effect<void, ClaudeAgentSdkQueryRunnerError>;347 readonly close: Effect.Effect<void, ClaudeAgentSdkQueryRunnerError>;
347}348}
...@@ -734,6 +735,11 @@ export const layerQueryRunner: Layer.Layer<...@@ -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 interrupt: Effect.tryPromise({743 interrupt: Effect.tryPromise({
738 try: () => queryRuntime.interrupt(),744 try: () => queryRuntime.interrupt(),
739 catch: (cause) => queryRunnerError(cause, "interrupt"),745 catch: (cause) => queryRunnerError(cause, "interrupt"),
...@@ -8092,6 +8098,34 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* (...@@ -8092,6 +8098,34 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* (
8092 }),8098 }),
8093 steerTurn,8099 steerTurn,
8094 interruptTurn,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 respondToRuntimeRequest: Effect.fn("ClaudeAdapterV2.respondToRuntimeRequest")(8129 respondToRuntimeRequest: Effect.fn("ClaudeAdapterV2.respondToRuntimeRequest")(
8096 function* (requestInput) {8130 function* (requestInput) {
8097 const pending = (yield* Ref.get(pendingRuntimeRequests)).get(8131 const pending = (yield* Ref.get(pendingRuntimeRequests)).get(
apps/server/src/orchestration-v2/EffectOutbox.ts+2
...@@ -3,6 +3,7 @@ import {...@@ -3,6 +3,7 @@ import {
3 CheckpointScopeId,3 CheckpointScopeId,
4 CommandId,4 CommandId,
5 MessageId,5 MessageId,
6 NodeId,
6 ProviderSessionId,7 ProviderSessionId,
7 RunAttemptId,8 RunAttemptId,
8 ProviderApprovalDecision,9 ProviderApprovalDecision,
...@@ -48,6 +49,7 @@ export const OrchestrationEffectRequestV2 = Schema.Union([...@@ -48,6 +49,7 @@ export const OrchestrationEffectRequestV2 = Schema.Union([
48 providerSessionId: ProviderSessionId,49 providerSessionId: ProviderSessionId,
49 providerThreadId: ProviderThreadId,50 providerThreadId: ProviderThreadId,
50 providerTurnId: ProviderTurnId,51 providerTurnId: ProviderTurnId,
52 subagent: Schema.optional(Schema.Struct({ id: NodeId, nativeTaskId: Schema.String })),
51 }),53 }),
52 Schema.Struct({54 Schema.Struct({
53 type: Schema.Literal("provider-turn.steer"),55 type: Schema.Literal("provider-turn.steer"),
apps/server/src/orchestration-v2/EffectWorker.test.ts+30
...@@ -1,6 +1,7 @@...@@ -1,6 +1,7 @@
1import { assert, it } from "@effect/vitest";1import { assert, it } from "@effect/vitest";
2import {2import {
3 CommandId,3 CommandId,
4 NodeId,
4 ProviderSessionId,5 ProviderSessionId,
5 ProviderThreadId,6 ProviderThreadId,
6 ProviderTurnId,7 ProviderTurnId,
...@@ -837,3 +838,32 @@ it.effect("settles a delegated child once its restart continuation fails for goo...@@ -837,3 +838,32 @@ it.effect("settles a delegated child once its restart continuation fails for goo
837 }).pipe(Effect.provide(layer));838 }).pipe(Effect.provide(layer));
838 }),839 }),
839);840);
841
842it.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,6 +169,9 @@ export const layerExecutor: Layer.Layer<
169 providerSessionId: effect.request.providerSessionId,169 providerSessionId: effect.request.providerSessionId,
170 providerThreadId: effect.request.providerThreadId,170 providerThreadId: effect.request.providerThreadId,
171 providerTurnId: effect.request.providerTurnId,171 providerTurnId: effect.request.providerTurnId,
172 ...(effect.request.subagent === undefined
173 ? {}
174 : { subagent: effect.request.subagent }),
172 })175 })
173 .pipe(176 .pipe(
174 Effect.catch((cause) =>177 Effect.catch((cause) =>
...@@ -185,13 +188,15 @@ export const layerExecutor: Layer.Layer<...@@ -185,13 +188,15 @@ export const layerExecutor: Layer.Layer<
185 // One Stop can interrupt several provider threads, so the188 // One Stop can interrupt several provider threads, so the
186 // settle is keyed by effect, not by the Stop command.189 // settle is keyed by effect, not by the Stop command.
187 Effect.andThen(190 Effect.andThen(
188 threads.dispatch({191 effect.request.subagent !== undefined
189 type: "thread.background-work.settle",192 ? Effect.void
190 commandId: CommandId.make(`${effect.id}:background-work-settled`),193 : threads.dispatch({
191 threadId: effect.threadId,194 type: "thread.background-work.settle",
192 providerThreadId: effect.request.providerThreadId,195 commandId: CommandId.make(`${effect.id}:background-work-settled`),
193 providerTurnId: effect.request.providerTurnId,196 threadId: effect.threadId,
194 }),197 providerThreadId: effect.request.providerThreadId,
198 providerTurnId: effect.request.providerTurnId,
199 }),
195 ),200 ),
196 Effect.mapError(201 Effect.mapError(
197 (cause) =>202 (cause) =>
apps/server/src/orchestration-v2/Orchestrator.ts+89-1
...@@ -463,6 +463,7 @@ function commandThreadId(command: OrchestrationV2ServerCommand): ThreadId {...@@ -463,6 +463,7 @@ function commandThreadId(command: OrchestrationV2ServerCommand): ThreadId {
463 case "prepared-run.progress":463 case "prepared-run.progress":
464 case "prepared-run.fail":464 case "prepared-run.fail":
465 case "prepared-run.retry":465 case "prepared-run.retry":
466 case "subagent.stop":
466 case "run.interrupt":467 case "run.interrupt":
467 case "queued-message.promote-to-steer":468 case "queued-message.promote-to-steer":
468 case "queue.resume":469 case "queue.resume":
...@@ -8821,6 +8822,89 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio...@@ -8821,6 +8822,89 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio
8821 });8822 });
8822 });8823 });
88238824
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 const dispatchRunInterrupt = (8908 const dispatchRunInterrupt = (
8825 command: Extract<OrchestrationV2Command, { readonly type: "run.interrupt" }>,8909 command: Extract<OrchestrationV2Command, { readonly type: "run.interrupt" }>,
8826 events: Ref.Ref<Array<OrchestrationV2DomainEvent>>,8910 events: Ref.Ref<Array<OrchestrationV2DomainEvent>>,
...@@ -10526,6 +10610,9 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio...@@ -10526,6 +10610,9 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio
10526 case "thread.user-input.dismiss":10610 case "thread.user-input.dismiss":
10527 yield* dispatchThreadUserInputDismiss(command, events, effects);10611 yield* dispatchThreadUserInputDismiss(command, events, effects);
10528 break;10612 break;
10613 case "subagent.stop":
10614 yield* dispatchSubagentStop(command, events, effects);
10615 break;
10529 case "run.interrupt":10616 case "run.interrupt":
10530 cancelUnsettledEffects = yield* dispatchRunInterrupt(command, events, effects);10617 cancelUnsettledEffects = yield* dispatchRunInterrupt(command, events, effects);
10531 // Stop also stops every delegated task under the thread once it commits.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,7 +10826,8 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio
10739 // its expected outcome, not a failure.10826 // its expected outcome, not a failure.
10740 planned.events.length > 0 ||10827 planned.events.length > 0 ||
10741 command.type === "thread.background-work.settle" ||10828 command.type === "thread.background-work.settle" ||
10742 command.type === "thread.stop"10829 command.type === "thread.stop" ||
10830 command.type === "subagent.stop"
10743 ? Effect.succeed(planned)10831 ? Effect.succeed(planned)
10744 : Effect.fail(10832 : Effect.fail(
10745 new OrchestratorDispatchError({10833 new OrchestratorDispatchError({
apps/server/src/orchestration-v2/ProviderTurnControlService.ts+17
...@@ -1,6 +1,7 @@...@@ -1,6 +1,7 @@
1import { projectComposerContextForProvider } from "@t3tools/shared/composerContextReferences";1import { projectComposerContextForProvider } from "@t3tools/shared/composerContextReferences";
2import {2import {
3 MessageId,3 MessageId,
4 type NodeId,
4 ProviderSessionId,5 ProviderSessionId,
5 ProviderThreadId,6 ProviderThreadId,
6 ProviderTurnId,7 ProviderTurnId,
...@@ -43,6 +44,7 @@ const isProviderTurnControlError = Schema.is(ProviderTurnControlError);...@@ -43,6 +44,7 @@ const isProviderTurnControlError = Schema.is(ProviderTurnControlError);
4344
44export interface ProviderTurnControlServiceV2Shape {45export interface ProviderTurnControlServiceV2Shape {
45 readonly interrupt: (input: {46 readonly interrupt: (input: {
47 readonly subagent?: { readonly id: NodeId; readonly nativeTaskId: string };
46 readonly threadId: ThreadId;48 readonly threadId: ThreadId;
47 readonly providerSessionId: ProviderSessionId;49 readonly providerSessionId: ProviderSessionId;
48 readonly providerThreadId: ProviderThreadId;50 readonly providerThreadId: ProviderThreadId;
...@@ -177,6 +179,21 @@ export const layer: Layer.Layer<...@@ -177,6 +179,21 @@ export const layer: Layer.Layer<
177 ? loaded.session179 ? loaded.session
178 : yield* sessions.get(input.providerSessionId);180 : yield* sessions.get(input.providerSessionId);
179 if (Option.isNone(session)) return;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 // A settled turn reaches its adapter too: only the adapter knows197 // A settled turn reaches its adapter too: only the adapter knows
181 // whether it still runs work for the thread, and each one either198 // whether it still runs work for the thread, and each one either
182 // stops it or reports there is nothing left to stop. Background work199 // 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,6 +7,7 @@ import {
7 ProjectId,7 ProjectId,
8 ProviderDriverKind,8 ProviderDriverKind,
9 ProviderInstanceId,9 ProviderInstanceId,
10 ProviderSessionId,
10 ProviderTurnId,11 ProviderTurnId,
11 ThreadId,12 ThreadId,
12 TurnItemId,13 TurnItemId,
...@@ -20,6 +21,7 @@ import * as SqlClient from "effect/sql/SqlClient";...@@ -20,6 +21,7 @@ import * as SqlClient from "effect/sql/SqlClient";
20import * as TestClock from "effect/testing/TestClock";21import * as TestClock from "effect/testing/TestClock";
2122
22import * as SqlitePersistence from "../persistence/Sqlite.ts";23import * as SqlitePersistence from "../persistence/Sqlite.ts";
24import { CLAUDE_PROVIDER } from "./Adapters/ClaudeAdapterV2.ts";
23import { CodexProviderCapabilitiesV2 } from "./Adapters/CodexAdapterV2.ts";25import { CodexProviderCapabilitiesV2 } from "./Adapters/CodexAdapterV2.ts";
24import { OrchestrationEffectRequestV2 } from "./EffectOutbox.ts";26import { OrchestrationEffectRequestV2 } from "./EffectOutbox.ts";
25import * as Orchestrator from "./Orchestrator.ts";27import * 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,3 +584,120 @@ it.effect("thread.stop marks a turn it cannot interrupt so a late agent watch is
582 assert.deepEqual(yield* threadState(threadId), { runs: ["running"], watched: [] });584 assert.deepEqual(yield* threadState(threadId), { runs: ["running"], watched: [] });
583 }).pipe(Effect.provide(layerTest)),585 }).pipe(Effect.provide(layerTest)),
584);586);
587
588it.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,6 +166,7 @@ function commandThreadIds(command: OrchestrationV2Command): ReadonlyArray<Thread
166 case "prepared-run.progress":166 case "prepared-run.progress":
167 case "prepared-run.fail":167 case "prepared-run.fail":
168 case "prepared-run.retry":168 case "prepared-run.retry":
169 case "subagent.stop":
169 case "run.interrupt":170 case "run.interrupt":
170 case "queued-message.promote-to-steer":171 case "queued-message.promote-to-steer":
171 case "queue.resume":172 case "queue.resume":
apps/web/src/components/chat/ThreadRelationshipsControl.agents.test.tsx+21-6
...@@ -53,9 +53,13 @@ afterEach(async () => {...@@ -53,9 +53,13 @@ afterEach(async () => {
53 state.projection = null;53 state.projection = null;
54});54});
5555
56it.each(["codex", "claudeAgent"])(56it.each([
57 "stops only active app-owned %s subagents without opening their thread",57 { driver: "codex", origin: "app_owned" },
58 async (driver) => {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 vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true);63 vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true);
60 const parent = {64 const parent = {
61 id: "parent",65 id: "parent",
...@@ -70,7 +74,9 @@ it.each(["codex", "claudeAgent"])(...@@ -70,7 +74,9 @@ it.each(["codex", "claudeAgent"])(
70 const agent = {74 const agent = {
71 id: "agent",75 id: "agent",
72 childThreadId: "child",76 childThreadId: "child",
73 origin: "app_owned",77 origin,
78 threadId: "parent",
79 nativeTaskRef: { driver, nativeId: "claude-task", strength: "strong" },
74 driver,80 driver,
75 providerInstanceId: "codex",81 providerInstanceId: "codex",
76 title: "Worker",82 title: "Worker",
...@@ -106,7 +112,10 @@ it.each(["codex", "claudeAgent"])(...@@ -106,7 +112,10 @@ it.each(["codex", "claudeAgent"])(
106 await act(async () => stopButton().props.onClick());112 await act(async () => stopButton().props.onClick());
107 expect(state.command).toHaveBeenCalledWith({113 expect(state.command).toHaveBeenCalledWith({
108 environmentId: "test",114 environmentId: "test",
109 input: { threadId: "child" },115 input:
116 origin === "provider_native"
117 ? { threadId: "parent", subagentId: "agent" }
118 : { threadId: "child" },
110 });119 });
111 expect(state.navigate).not.toHaveBeenCalled();120 expect(state.navigate).not.toHaveBeenCalled();
112121
...@@ -132,6 +141,12 @@ it.each(["codex", "claudeAgent"])(...@@ -132,6 +141,12 @@ it.each(["codex", "claudeAgent"])(
132 );141 );
133 state.projection = { ...projection, subagents: [{ ...agent, status: "completed" }] };142 state.projection = { ...projection, subagents: [{ ...agent, status: "completed" }] };
134 await act(async () => renderer.update(cloneElement(panel)));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 await act(async () => stopButton().props.onClick());150 await act(async () => stopButton().props.onClick());
136 expect(state.command).toHaveBeenCalledTimes(1);151 expect(state.command).toHaveBeenCalledTimes(1);
137 expect(state.command).toHaveBeenLastCalledWith({152 expect(state.command).toHaveBeenLastCalledWith({
...@@ -152,7 +167,7 @@ it.each(["codex", "claudeAgent"])(...@@ -152,7 +167,7 @@ it.each(["codex", "claudeAgent"])(
152 expect(renderer.root.findAllByProps({ "aria-label": "Stop subagent Worker" })).toHaveLength(0);167 expect(renderer.root.findAllByProps({ "aria-label": "Stop subagent Worker" })).toHaveLength(0);
153 state.projection = {168 state.projection = {
154 ...projection,169 ...projection,
155 subagents: [{ ...agent, origin: "provider_native", driver: "claudeAgent" }],170 subagents: [{ ...agent, origin: "provider_native", driver: "codex" }],
156 };171 };
157 await act(async () => renderer.update(cloneElement(panel)));172 await act(async () => renderer.update(cloneElement(panel)));
158 expect(renderer.root.findAllByProps({ "aria-label": "Stop subagent Worker" })).toHaveLength(0);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,7 +24,12 @@ import {
24 canDetachThreadProviderSession,24 canDetachThreadProviderSession,
25 resolveLatestMergeBackRun,25 resolveLatestMergeBackRun,
26} from "@t3tools/client-runtime/state/thread-workflows";26} from "@t3tools/client-runtime/state/thread-workflows";
27import type { EnvironmentId, OrchestrationV2ThreadShell, ThreadId } from "@t3tools/contracts";27import type {
28 EnvironmentId,
29 OrchestrationV2Subagent,
30 OrchestrationV2ThreadShell,
31 ThreadId,
32} from "@t3tools/contracts";
28import { deriveSubagentElapsedMs } from "@t3tools/shared/orchestrationTiming";33import { deriveSubagentElapsedMs } from "@t3tools/shared/orchestrationTiming";
29import { groupBy } from "effect/Array";34import { groupBy } from "effect/Array";
30import * as DateTime from "effect/DateTime";35import * as DateTime from "effect/DateTime";
...@@ -221,6 +226,10 @@ export function ThreadRelationshipsPanel(props: {...@@ -221,6 +226,10 @@ export function ThreadRelationshipsPanel(props: {
221 subagent.childThreadId,226 subagent.childThreadId,
222 {227 {
223 ...projectedSubagentsToRuntime([subagent])[0]!,228 ...projectedSubagentsToRuntime([subagent])[0]!,
229 id: subagent.id,
230 threadId: subagent.threadId,
231 nativeTaskRef: subagent.nativeTaskRef,
232 nativeStatus: subagent.status,
224 driver: subagent.driver,233 driver: subagent.driver,
225 providerInstanceId: subagent.providerInstanceId,234 providerInstanceId: subagent.providerInstanceId,
226 origin: subagent.origin,235 origin: subagent.origin,
...@@ -328,12 +337,18 @@ export function ThreadRelationshipsPanel(props: {...@@ -328,12 +337,18 @@ export function ThreadRelationshipsPanel(props: {
328 setBusyAction(null);337 setBusyAction(null);
329 };338 };
330339
331 const stopSubagent = async (childThreadId: ThreadId) => {340 const stopSubagent = async (
341 childThreadId: ThreadId,
342 agent: Pick<OrchestrationV2Subagent, "id" | "origin" | "threadId">,
343 ) => {
332 if (stoppingThreadId !== null) return;344 if (stoppingThreadId !== null) return;
333 setStoppingThreadId(childThreadId);345 setStoppingThreadId(childThreadId);
334 const result = await interruptTurn({346 const result = await interruptTurn({
335 environmentId: props.environmentId,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 setStoppingThreadId(null);353 setStoppingThreadId(null);
339 if (result._tag === "Failure") {354 if (result._tag === "Failure") {
...@@ -398,7 +413,13 @@ export function ThreadRelationshipsPanel(props: {...@@ -398,7 +413,13 @@ export function ThreadRelationshipsPanel(props: {
398 );413 );
399 const failed = status === "failed" || status === "error";414 const failed = status === "failed" || status === "error";
400 const canStop =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 agent.startedAt &&423 agent.startedAt &&
403 ["pending", "running", "waiting"].includes(agent.status);424 ["pending", "running", "waiting"].includes(agent.status);
404 const trailingVisibilityClass = canStop425 const trailingVisibilityClass = canStop
...@@ -566,7 +587,7 @@ export function ThreadRelationshipsPanel(props: {...@@ -566,7 +587,7 @@ export function ThreadRelationshipsPanel(props: {
566 tone="destructive"587 tone="destructive"
567 aria-label={`Stop subagent ${threadTitle}`}588 aria-label={`Stop subagent ${threadTitle}`}
568 disabled={stoppingThreadId !== null}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,6 +10,7 @@ import {
10 PlanId,10 PlanId,
11 ProjectId,11 ProjectId,
12 ProviderInstanceId,12 ProviderInstanceId,
13 ProviderDriverKind,
13 RunId,14 RunId,
14 RuntimeRequestId,15 RuntimeRequestId,
15 ThreadId,16 ThreadId,
...@@ -995,6 +996,60 @@ describe("V2 environment commands", () => {...@@ -995,6 +996,60 @@ describe("V2 environment commands", () => {
995 }).pipe(Effect.provide(layerTestCrypto)),996 }).pipe(Effect.provide(layerTestCrypto)),
996 );997 );
997998
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 it.effect("interrupts a known run without fetching the full projection", () =>1053 it.effect("interrupts a known run without fetching the full projection", () =>
999 Effect.gen(function* () {1054 Effect.gen(function* () {
1000 const commands: OrchestrationV2Command[] = [];1055 const commands: OrchestrationV2Command[] = [];
packages/client-runtime/src/operations/commands.ts+14
...@@ -10,6 +10,7 @@ import {...@@ -10,6 +10,7 @@ import {
10 type ChatAttachment,10 type ChatAttachment,
11 type MessageId,11 type MessageId,
12 type ModelSelection,12 type ModelSelection,
13 type NodeId,
13 type OrchestrationV2Command,14 type OrchestrationV2Command,
14 type OrchestrationV2CreationSource,15 type OrchestrationV2CreationSource,
15 type PlanId,16 type PlanId,
...@@ -183,6 +184,7 @@ export interface StartThreadTurnInput extends ThreadCommandInput {...@@ -183,6 +184,7 @@ export interface StartThreadTurnInput extends ThreadCommandInput {
183184
184export interface InterruptThreadTurnInput extends ThreadCommandInput {185export interface InterruptThreadTurnInput extends ThreadCommandInput {
185 readonly runId?: RunId;186 readonly runId?: RunId;
187 readonly subagentId?: NodeId;
186 /** Temporary caller compatibility while UI naming moves from turns to runs. */188 /** Temporary caller compatibility while UI naming moves from turns to runs. */
187 readonly turnId?: string;189 readonly turnId?: string;
188}190}
...@@ -780,6 +782,18 @@ export const startThreadTurn = Effect.fn("EnvironmentCommands.startThreadTurn")(...@@ -780,6 +782,18 @@ export const startThreadTurn = Effect.fn("EnvironmentCommands.startThreadTurn")(
780export const interruptThreadTurn = Effect.fn("EnvironmentCommands.interruptThreadTurn")(function* (782export const interruptThreadTurn = Effect.fn("EnvironmentCommands.interruptThreadTurn")(function* (
781 input: InterruptThreadTurnInput,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 let runId = input.runId ?? (input.turnId as RunId | undefined);797 let runId = input.runId ?? (input.turnId as RunId | undefined);
784 if (runId === undefined) {798 if (runId === undefined) {
785 const projection = yield* getProjection(input.threadId);799 const projection = yield* getProjection(input.threadId);
packages/contracts/src/orchestrationV2.ts+8
...@@ -2915,6 +2915,14 @@ export const OrchestrationV2Command = Schema.Union([...@@ -2915,6 +2915,14 @@ export const OrchestrationV2Command = Schema.Union([
2915 threadId: ThreadId,2915 threadId: ThreadId,
2916 runId: RunId,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 Schema.Struct({2926 Schema.Struct({
2919 type: Schema.Literal("run.interrupt"),2927 type: Schema.Literal("run.interrupt"),
2920 commandId: CommandId,2928 commandId: CommandId,
packages/provider-core/src/server/ProviderAdapter.ts+5
...@@ -598,6 +598,11 @@ export interface ProviderAdapterV2SessionRuntime {...@@ -598,6 +598,11 @@ export interface ProviderAdapterV2SessionRuntime {
598 readonly interruptTurn: (598 readonly interruptTurn: (
599 input: ProviderAdapterV2InterruptInput,599 input: ProviderAdapterV2InterruptInput,
600 ) => Effect.Effect<void, ProviderAdapterV2Error>;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 * Lets a runtime shared by several app threads unload one provider thread's607 * Lets a runtime shared by several app threads unload one provider thread's
603 * native state (and its MCP servers) when that app thread detaches, while608 * native state (and its MCP servers) when that app thread detaches, while