| 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,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 the | 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,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 | ); |
| 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 | 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 @@ |
| 1 | import { assert, it } from "@effect/vitest"; | 1 | import { assert, it } from "@effect/vitest"; |
| 2 | import { | 2 | import { |
| 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 | |||
| 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,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 the | 188 | // 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 | }); |
| 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 | 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 @@ |
| 1 | import { projectComposerContextForProvider } from "@t3tools/shared/composerContextReferences"; | 1 | import { projectComposerContextForProvider } from "@t3tools/shared/composerContextReferences"; |
| 2 | import { | 2 | import { |
| 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); |
| 43 | 44 | ||
| 44 | export interface ProviderTurnControlServiceV2Shape { | 45 | export 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.session | 179 | ? 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 knows | 197 | // A settled turn reaches its adapter too: only the adapter knows |
| 181 | // whether it still runs work for the thread, and each one either | 198 | // whether it still runs work for the thread, and each one either |
| 182 | // stops it or reports there is nothing left to stop. Background work | 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,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"; |
| 20 | import * as TestClock from "effect/testing/TestClock"; | 21 | import * as TestClock from "effect/testing/TestClock"; |
| 21 | 22 | ||
| 22 | import * as SqlitePersistence from "../persistence/Sqlite.ts"; | 23 | import * as SqlitePersistence from "../persistence/Sqlite.ts"; |
| 24 | import { CLAUDE_PROVIDER } from "./Adapters/ClaudeAdapterV2.ts"; | ||
| 23 | import { CodexProviderCapabilitiesV2 } from "./Adapters/CodexAdapterV2.ts"; | 25 | import { CodexProviderCapabilitiesV2 } from "./Adapters/CodexAdapterV2.ts"; |
| 24 | import { OrchestrationEffectRequestV2 } from "./EffectOutbox.ts"; | 26 | import { OrchestrationEffectRequestV2 } from "./EffectOutbox.ts"; |
| 25 | import * as Orchestrator from "./Orchestrator.ts"; | 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,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 | |||
| 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,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 | }); |
| 55 | 55 | ||
| 56 | it.each(["codex", "claudeAgent"])( | 56 | it.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(); |
| 112 | 121 | ||
| ... | @@ -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"; |
| 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 | import { deriveSubagentElapsedMs } from "@t3tools/shared/orchestrationTiming"; | 33 | import { deriveSubagentElapsedMs } from "@t3tools/shared/orchestrationTiming"; |
| 29 | import { groupBy } from "effect/Array"; | 34 | import { groupBy } from "effect/Array"; |
| 30 | import * as DateTime from "effect/DateTime"; | 35 | import * 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 | }; |
| 330 | 339 | ||
| 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 = canStop | 425 | 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 | ); |
| 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 | 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 { |
| 183 | 184 | ||
| 184 | export interface InterruptThreadTurnInput extends ThreadCommandInput { | 185 | export 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")( |
| 780 | export const interruptThreadTurn = Effect.fn("EnvironmentCommands.interruptThreadTurn")(function* ( | 782 | export 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's | 607 | * 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, while | 608 | * native state (and its MCP servers) when that app thread detaches, while |